diff --git a/components.d.ts b/components.d.ts index 3e65c3cc..cb18410b 100644 --- a/components.d.ts +++ b/components.d.ts @@ -89,6 +89,9 @@ declare module '@vue/runtime-core' { HtmlWysiwygEditor: typeof import('./src/tools/html-wysiwyg-editor/html-wysiwyg-editor.vue')['default'] HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default'] IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] + IcalGenerator: typeof import('./src/tools/ical-generator/ical-generator.vue')['default'] + IcalMerger: typeof import('./src/tools/ical-merger/ical-merger.vue')['default'] + IcalParser: typeof import('./src/tools/ical-parser/ical-parser.vue')['default'] 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] 'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] @@ -130,17 +133,28 @@ declare module '@vue/runtime-core' { MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] + NButton: typeof import('naive-ui')['NButton'] NCheckbox: typeof import('naive-ui')['NCheckbox'] + NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] + NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] NEllipsis: typeof import('naive-ui')['NEllipsis'] + NFormItem: typeof import('naive-ui')['NFormItem'] + NGi: typeof import('naive-ui')['NGi'] + NGrid: typeof import('naive-ui')['NGrid'] NH1: typeof import('naive-ui')['NH1'] NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] + NInput: typeof import('naive-ui')['NInput'] + NInputGroup: typeof import('naive-ui')['NInputGroup'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] + NRadio: typeof import('naive-ui')['NRadio'] + NRadioGroup: typeof import('naive-ui')['NRadioGroup'] + NScrollbar: typeof import('naive-ui')['NScrollbar'] NSpace: typeof import('naive-ui')['NSpace'] NTable: typeof import('naive-ui')['NTable'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] diff --git a/package.json b/package.json index 5c991cff..71b48afd 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,8 @@ "highlight.js": "^11.7.0", "iarna-toml-esm": "^3.0.5", "ibantools": "^4.3.3", + "ical-generator": "^8.0.0", + "ical.js": "^2.1.0", "js-base64": "^3.7.6", "json5": "^2.2.3", "jwt-decode": "^3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3798ae17..357066e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,12 @@ dependencies: ibantools: specifier: ^4.3.3 version: 4.3.3 + ical-generator: + specifier: ^8.0.0 + version: 8.0.0(@types/node@18.15.11) + ical.js: + specifier: ^2.1.0 + version: 2.1.0 js-base64: specifier: ^3.7.6 version: 3.7.7 @@ -3060,7 +3066,6 @@ packages: /@types/node@18.15.11: resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==} - dev: true /@types/node@18.18.8: resolution: {integrity: sha512-OLGBaaK5V3VRBS1bAkMVP2/W9B+H8meUfl866OrMNQqt7wDgdpWPp5o6gmIc9pB+lIQHSq4ZL8ypeH1vPxcPaQ==} @@ -3412,7 +3417,7 @@ packages: dependencies: '@unhead/dom': 0.5.1 '@unhead/schema': 0.5.1 - '@vueuse/shared': 11.0.3(vue@3.3.4) + '@vueuse/shared': 11.1.0(vue@3.3.4) unhead: 0.5.1 vue: 3.3.4 transitivePeerDependencies: @@ -4054,8 +4059,8 @@ packages: - vue dev: false - /@vueuse/shared@11.0.3(vue@3.3.4): - resolution: {integrity: sha512-0rY2m6HS5t27n/Vp5cTDsKTlNnimCqsbh/fmT2LgE+aaU42EMfXo8+bNX91W9I7DDmxfuACXMmrd7d79JxkqWA==} + /@vueuse/shared@11.1.0(vue@3.3.4): + resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==} dependencies: vue-demi: 0.14.10(vue@3.3.4) transitivePeerDependencies: @@ -6162,6 +6167,47 @@ packages: resolution: {integrity: sha512-RUTlGuFj3cU/Qfu5YIrsIZjW34/VDgKOz5fDr64Mc4NWP9b2i48vQ39r5xCl1yyFQeyEG/lASstIQHAUX18rRA==} dev: false + /ical-generator@8.0.0(@types/node@18.15.11): + resolution: {integrity: sha512-CvVKK3JJrKop6z7i7/NS69FjYdR6ux1LswIeiZ3yaicX7ocMFLQI475JhQabCT7koUkaCVFNVxLaYaxtdPgwww==} + engines: {node: 18 || 20 || >=22.0.0} + peerDependencies: + '@touch4it/ical-timezones': '>=1.6.0' + '@types/luxon': '>= 1.26.0' + '@types/mocha': '>= 8.2.1' + '@types/node': '*' + dayjs: '>= 1.10.0' + luxon: '>= 1.26.0' + moment: '>= 2.29.0' + moment-timezone: '>= 0.5.33' + rrule: '>= 2.6.8' + peerDependenciesMeta: + '@touch4it/ical-timezones': + optional: true + '@types/luxon': + optional: true + '@types/mocha': + optional: true + '@types/node': + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + moment-timezone: + optional: true + rrule: + optional: true + dependencies: + '@types/node': 18.15.11 + uuid-random: 1.3.2 + dev: false + + /ical.js@2.1.0: + resolution: {integrity: sha512-BOVfrH55xQ6kpS3muGvIXIg2l7p+eoe12/oS7R5yrO3TL/j/bLsR0PR+tYQESFbyTbvGgPHn9zQ6tI4FWyuSaQ==} + dev: false + /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -9052,6 +9098,10 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true + /uuid-random@1.3.2: + resolution: {integrity: sha512-UOzej0Le/UgkbWEO8flm+0y+G+ljUon1QWTEZOq1rnMAsxo2+SckbiZdKzAHHlVh6gJqI1TjC/xwgR50MuCrBQ==} + dev: false + /uuid@9.0.0: resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} dev: false diff --git a/src/components/TextareaCopyable.vue b/src/components/TextareaCopyable.vue index 9585177d..a0961f1b 100644 --- a/src/components/TextareaCopyable.vue +++ b/src/components/TextareaCopyable.vue @@ -7,8 +7,15 @@ import sqlHljs from 'highlight.js/lib/languages/sql'; import xmlHljs from 'highlight.js/lib/languages/xml'; import yamlHljs from 'highlight.js/lib/languages/yaml'; import iniHljs from 'highlight.js/lib/languages/ini'; +import bashHljs from 'highlight.js/lib/languages/bash'; import markdownHljs from 'highlight.js/lib/languages/markdown'; +import jsHljs from 'highlight.js/lib/languages/javascript'; +import cssHljs from 'highlight.js/lib/languages/css'; +import goHljs from 'highlight.js/lib/languages/go'; +import csharpHljs from 'highlight.js/lib/languages/csharp'; +import { Base64 } from 'js-base64'; import { useCopy } from '@/composable/copy'; +import { useDownloadFileFromBase64 } from '@/composable/downloadBase64'; const props = withDefaults( defineProps<{ @@ -17,12 +24,17 @@ const props = withDefaults( language?: string copyPlacement?: 'top-right' | 'bottom-right' | 'outside' | 'none' copyMessage?: string + wordWrap?: boolean + downloadFileName?: string + downloadButtonText?: string }>(), { followHeightOf: null, language: 'txt', copyPlacement: 'top-right', copyMessage: 'Copy to clipboard', + downloadFileName: '', + downloadButtonText: 'Download', }, ); hljs.registerLanguage('sql', sqlHljs); @@ -31,13 +43,25 @@ hljs.registerLanguage('html', xmlHljs); hljs.registerLanguage('xml', xmlHljs); hljs.registerLanguage('yaml', yamlHljs); hljs.registerLanguage('toml', iniHljs); +hljs.registerLanguage('bash', bashHljs); hljs.registerLanguage('markdown', markdownHljs); +hljs.registerLanguage('css', cssHljs); +hljs.registerLanguage('javascript', jsHljs); +hljs.registerLanguage('go', goHljs); +hljs.registerLanguage('csharp', csharpHljs); -const { value, language, followHeightOf, copyPlacement, copyMessage } = toRefs(props); +const { value, language, followHeightOf, copyPlacement, copyMessage, downloadFileName, downloadButtonText } = toRefs(props); const { height } = followHeightOf.value ? useElementSize(followHeightOf) : { height: ref(null) }; const { copy, isJustCopied } = useCopy({ source: value, createToast: false }); const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage.value); + +const valueBase64 = computed(() => Base64.encode(value.value)); +const { download } = useDownloadFileFromBase64( + { + source: valueBase64, + filename: downloadFileName, + }); @@ -49,11 +73,16 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage. :style="height ? `min-height: ${height - 40 /* card padding */ + 10 /* negative margin compensation */}px` : ''" > - + - - + + @@ -65,6 +94,11 @@ const tooltipText = computed(() => isJustCopied.value ? 'Copied!' : copyMessage. {{ tooltipText }} + + + {{ downloadButtonText }} + + diff --git a/src/composable/downloadBase64.ts b/src/composable/downloadBase64.ts index 3bc20226..773541e2 100644 --- a/src/composable/downloadBase64.ts +++ b/src/composable/downloadBase64.ts @@ -1,6 +1,7 @@ import { extension as getExtensionFromMimeType, extension as getMimeTypeFromExtension } from 'mime-types'; -import type { Ref } from 'vue'; +import type { MaybeRef, Ref } from 'vue'; import _ from 'lodash'; +import { get } from '@vueuse/core'; export { getMimeTypeFromBase64, @@ -75,21 +76,11 @@ function downloadFromBase64({ sourceValue, filename, extension, fileMimeType }: } function useDownloadFileFromBase64( - { source, filename, extension, fileMimeType }: - { source: Ref; filename?: string; extension?: string; fileMimeType?: string }) { - return { - download() { - downloadFromBase64({ sourceValue: source.value, filename, extension, fileMimeType }); - }, - }; -} - -function useDownloadFileFromBase64Refs( { source, filename, extension }: - { source: Ref; filename?: Ref; extension?: Ref }) { + { source: MaybeRef; filename?: MaybeRef; extension?: MaybeRef }) { return { download() { - downloadFromBase64({ sourceValue: source.value, filename: filename?.value, extension: extension?.value }); + downloadFromBase64({ sourceValue: get(source), filename: get(filename), extension: get(extension) }); }, }; } @@ -116,3 +107,13 @@ function previewImageFromBase64(base64String: string): HTMLImageElement { return img; } + +function useDownloadFileFromBase64Refs( + { source, filename, extension }: + { source: Ref; filename?: Ref; extension?: Ref }) { + return { + download() { + downloadFromBase64({ sourceValue: source.value, filename: filename?.value, extension: extension?.value }); + }, + }; +} diff --git a/src/tools/ical-generator/ical-generator.vue b/src/tools/ical-generator/ical-generator.vue new file mode 100644 index 00000000..942df575 --- /dev/null +++ b/src/tools/ical-generator/ical-generator.vue @@ -0,0 +1,127 @@ + + + + + + + + Download ICS + + + + + {{ output.error }} + + + + + + + + + + + + + + Delete + + + + + + Add Event + + + + diff --git a/src/tools/ical-generator/index.ts b/src/tools/ical-generator/index.ts new file mode 100644 index 00000000..ef5bbc4c --- /dev/null +++ b/src/tools/ical-generator/index.ts @@ -0,0 +1,12 @@ +import { CalendarEvent } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'ICAL Generator', + path: '/ical-generator', + description: 'Generate ICAL/ICS file from event infos', + keywords: ['ical', 'calendar', 'event', 'generator'], + component: () => import('./ical-generator.vue'), + icon: CalendarEvent, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/ical-merger/ical-merger.service.test.ts b/src/tools/ical-merger/ical-merger.service.test.ts new file mode 100644 index 00000000..6727aeb0 --- /dev/null +++ b/src/tools/ical-merger/ical-merger.service.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { mergeIcals } from './ical-merger.service'; + +describe('ical-merger', () => { + describe('mergeIcals', () => { + it('merge correctly', () => { + expect(mergeIcals([ +`BEGIN:VCALENDAR +PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN +VERSION:2.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:Networld+Interop Conference + and Exhibit\\nAtlanta World Congress Center\\n + Atlanta\\, Georgia +END:VEVENT +END:VCALENDAR`, + `BEGIN:VCALENDAR +METHOD:xyz +VERSION:2.0 +PRODID:-//ABC Corporation//NONSGML My Product//EN +BEGIN:VEVENT +DTSTAMP:19970324T120000Z +SEQUENCE:0 +UID:uid3@example.com +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com +DTSTART:19970324T123000Z +DTEND:19970324T210000Z +CATEGORIES:MEETING,PROJECT +CLASS:PUBLIC +SUMMARY:Calendaring Interoperability Planning Meeting +DESCRIPTION:Discuss how we can test c&s interoperability\\n + using iCalendar and other IETF standards. +LOCATION:LDB Lobby +ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/ + conf/bkgrnd.ps +END:VEVENT +END:VCALENDAR`, + ])).to.eq( +`BEGIN:VCALENDAR +PRODID:it-tools-ical-merger +VERSION:1.0 +BEGIN:VEVENT +DTSTAMP:19960704T120000Z +UID:uid1@example.com +ORGANIZER:mailto:jsmith@example.com +DTSTART:19960918T143000Z +DTEND:19960920T220000Z +STATUS:CONFIRMED +CATEGORIES:CONFERENCE +SUMMARY:Networld+Interop Conference +DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congress + Center\\nAtlanta\\, Georgia +END:VEVENT +BEGIN:VEVENT +DTSTAMP:19970324T120000Z +SEQUENCE:0 +UID:uid3@example.com +ORGANIZER:mailto:jdoe@example.com +ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com +DTSTART:19970324T123000Z +DTEND:19970324T210000Z +CATEGORIES:MEETING,PROJECT +CLASS:PUBLIC +SUMMARY:Calendaring Interoperability Planning Meeting +DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar a + nd other IETF standards. +LOCATION:LDB Lobby +ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/conf/bkgrnd.ps +END:VEVENT +END:VCALENDAR`.replace(/\n/g, '\r\n')); + }); + }); +}); diff --git a/src/tools/ical-merger/ical-merger.service.ts b/src/tools/ical-merger/ical-merger.service.ts new file mode 100644 index 00000000..95a774b4 --- /dev/null +++ b/src/tools/ical-merger/ical-merger.service.ts @@ -0,0 +1,45 @@ +import ICAL from 'ical.js'; + +export function mergeIcals(inputs: Array, options: { + calname?: string + timezone?: string + caldesc?: string +} = {}) { + let calendar; + for (const input of inputs) { + try { + const jcal = ICAL.parse(input); + const cal = new ICAL.Component(jcal); + + if (!calendar) { + calendar = cal; + calendar.updatePropertyWithValue('prodid', 'it-tools-ical-merger'); + calendar.updatePropertyWithValue('version', '1.0'); + + if (options.calname) { + calendar.updatePropertyWithValue('x-wr-calname', options.calname); + } + if (options.timezone) { + calendar.updatePropertyWithValue('x-wr-timezone', options.timezone); + } + if (options.caldesc) { + calendar.updatePropertyWithValue('x-wr-caldesc', options.caldesc); + } + } + else { + for (const vevent of cal.getAllSubcomponents('vevent')) { + calendar.addSubcomponent(vevent); + } + } + } + catch (e) { + throw new Error(`Failed to merge: ${e}\n\nWith input: ${input}`); + } + } + + if (!calendar) { + throw new Error('No icals parsed successfully'); + } + + return calendar.toString(); +} diff --git a/src/tools/ical-merger/ical-merger.vue b/src/tools/ical-merger/ical-merger.vue new file mode 100644 index 00000000..23b9fd03 --- /dev/null +++ b/src/tools/ical-merger/ical-merger.vue @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + Delete + + File to merge: {{ file.name }} + + + + + + Merge iCal files + + + + + + + {{ errors }} + + + + + + + diff --git a/src/tools/ical-merger/index.ts b/src/tools/ical-merger/index.ts new file mode 100644 index 00000000..9bb8c232 --- /dev/null +++ b/src/tools/ical-merger/index.ts @@ -0,0 +1,12 @@ +import { CalendarPlus } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'iCal Merger', + path: '/ical-merger', + description: 'Merge many iCal file to a single', + keywords: ['ical', 'ics', 'merger'], + component: () => import('./ical-merger.vue'), + icon: CalendarPlus, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/ical-parser/ical-parser.vue b/src/tools/ical-parser/ical-parser.vue new file mode 100644 index 00000000..236ddf74 --- /dev/null +++ b/src/tools/ical-parser/ical-parser.vue @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/tools/ical-parser/index.ts b/src/tools/ical-parser/index.ts new file mode 100644 index 00000000..58fdafcd --- /dev/null +++ b/src/tools/ical-parser/index.ts @@ -0,0 +1,12 @@ +import { CalendarEvent } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'ICAL File Parser', + path: '/ical-parser', + description: 'Parse ICAL/ICS file to event display', + keywords: ['ical', 'ics', 'calendar', 'parser'], + component: () => import('./ical-parser.vue'), + icon: CalendarEvent, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..51f3d5fb 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,6 +2,7 @@ import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as emailNormalizer } from './email-normalizer'; +import { tool as icalMerger } from './ical-merger'; import { tool as asciiTextDrawer } from './ascii-text-drawer'; @@ -12,6 +13,8 @@ import { tool as jsonToXml } from './json-to-xml'; import { tool as regexTester } from './regex-tester'; import { tool as regexMemo } from './regex-memo'; import { tool as markdownToHtml } from './markdown-to-html'; +import { tool as icalParser } from './ical-parser'; +import { tool as icalGenerator } from './ical-generator'; import { tool as pdfSignatureChecker } from './pdf-signature-checker'; import { tool as numeronymGenerator } from './numeronym-generator'; import { tool as macAddressGenerator } from './mac-address-generator'; @@ -184,6 +187,9 @@ export const toolsByCategory: ToolCategory[] = [ textDiff, numeronymGenerator, asciiTextDrawer, + icalGenerator, + icalParser, + icalMerger, ], }, {