diff --git a/frontend/src/composables/__tests__/useIcalDownload.spec.ts b/frontend/src/composables/__tests__/useIcalDownload.spec.ts new file mode 100644 index 0000000..9bfbc13 --- /dev/null +++ b/frontend/src/composables/__tests__/useIcalDownload.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import { generateIcs } from '../useIcalDownload' + +describe('generateIcs', () => { + const baseEvent = { + eventToken: '550e8400-e29b-41d4-a716-446655440000', + title: 'Sommerfest am See', + dateTime: '2026-07-15T18:00:00+02:00', + location: 'Stadtpark Berlin', + description: 'Bring your own drinks', + } + + it('generates valid VCALENDAR wrapper', () => { + const ics = generateIcs(baseEvent) + expect(ics).toMatch(/^BEGIN:VCALENDAR\r\n/) + expect(ics).toMatch(/\r\nEND:VCALENDAR\r\n$/) + }) + + it('includes VERSION and PRODID', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('VERSION:2.0\r\n') + expect(ics).toContain('PRODID:-//fete//EN\r\n') + }) + + it('generates valid VEVENT block', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('BEGIN:VEVENT\r\n') + expect(ics).toContain('END:VEVENT\r\n') + }) + + it('sets UID from eventToken', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('UID:550e8400-e29b-41d4-a716-446655440000@fete\r\n') + }) + + it('sets DTSTART in UTC format', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('DTSTART:20260715T160000Z\r\n') + }) + + it('does NOT include DTEND or DURATION', () => { + const ics = generateIcs(baseEvent) + expect(ics).not.toContain('DTEND') + expect(ics).not.toContain('DURATION') + }) + + it('sets SUMMARY from title', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('SUMMARY:Sommerfest am See\r\n') + }) + + it('sets LOCATION when present', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('LOCATION:Stadtpark Berlin\r\n') + }) + + it('sets DESCRIPTION when present', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('DESCRIPTION:Bring your own drinks\r\n') + }) + + it('omits LOCATION when not provided', () => { + const { location: _location, ...noLocation } = baseEvent + const ics = generateIcs(noLocation) + expect(ics).not.toContain('LOCATION') + }) + + it('omits DESCRIPTION when not provided', () => { + const { description: _description, ...noDesc } = baseEvent + const ics = generateIcs(noDesc) + expect(ics).not.toContain('DESCRIPTION') + }) + + it('includes SEQUENCE:0', () => { + const ics = generateIcs(baseEvent) + expect(ics).toContain('SEQUENCE:0\r\n') + }) + + it('includes DTSTAMP in UTC format', () => { + const ics = generateIcs(baseEvent) + expect(ics).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/) + }) + + it('escapes commas in text fields', () => { + const ics = generateIcs({ ...baseEvent, title: 'Hello, World' }) + expect(ics).toContain('SUMMARY:Hello\\, World\r\n') + }) + + it('escapes semicolons in text fields', () => { + const ics = generateIcs({ ...baseEvent, description: 'foo; bar' }) + expect(ics).toContain('DESCRIPTION:foo\\; bar\r\n') + }) + + it('escapes backslashes in text fields', () => { + const ics = generateIcs({ ...baseEvent, title: 'path\\to' }) + expect(ics).toContain('SUMMARY:path\\\\to\r\n') + }) + + it('escapes newlines in text fields', () => { + const ics = generateIcs({ ...baseEvent, description: 'line1\nline2' }) + expect(ics).toContain('DESCRIPTION:line1\\nline2\r\n') + }) + + it('produces deterministic output for the same input', () => { + const ics1 = generateIcs(baseEvent) + const ics2 = generateIcs(baseEvent) + // DTSTAMP changes with time, so strip it for comparison + const strip = (s: string) => s.replace(/DTSTAMP:\d{8}T\d{6}Z\r\n/, '') + expect(strip(ics1)).toBe(strip(ics2)) + }) + + it('uses CRLF line endings throughout', () => { + const ics = generateIcs(baseEvent) + const lines = ics.split('\r\n') + // Every "line" split by CRLF should not contain a bare LF + for (const line of lines) { + expect(line).not.toContain('\n') + } + }) +}) diff --git a/frontend/src/composables/useIcalDownload.ts b/frontend/src/composables/useIcalDownload.ts new file mode 100644 index 0000000..2994cf7 --- /dev/null +++ b/frontend/src/composables/useIcalDownload.ts @@ -0,0 +1,71 @@ +import { slugify } from '@/utils/slugify' + +export interface IcalEvent { + eventToken: string + title: string + dateTime: string + location?: string + description?: string +} + +function escapeText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') +} + +function toUtcString(isoDateTime: string): string { + const d = new Date(isoDateTime) + const pad = (n: number) => String(n).padStart(2, '0') + return ( + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z` + ) +} + +export function generateIcs(event: IcalEvent): string { + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//fete//EN', + 'BEGIN:VEVENT', + `UID:${event.eventToken}@fete`, + `DTSTAMP:${toUtcString(new Date().toISOString())}`, + `DTSTART:${toUtcString(event.dateTime)}`, + `SUMMARY:${escapeText(event.title)}`, + 'SEQUENCE:0', + ] + + if (event.location) { + lines.push(`LOCATION:${escapeText(event.location)}`) + } + + if (event.description) { + lines.push(`DESCRIPTION:${escapeText(event.description)}`) + } + + lines.push('END:VEVENT', 'END:VCALENDAR', '') + + return lines.join('\r\n') +} + +export function useIcalDownload() { + function download(event: IcalEvent) { + const ics = generateIcs(event) + const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }) + const url = URL.createObjectURL(blob) + + const filename = `${slugify(event.title) || 'event'}.ics` + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return { download } +}