import type { CalendarComponentType } from "@/worker/db/types"; import { byteLength, decodeUtf8, firstProperty, normalizeLineEndings, parseDateValue, parseDuration, parseProperty, supportedComponentTypes, unfoldLines, } from "@/worker/caldav/ical/core"; import { recurrenceSummary } from "@/worker/caldav/ical/recurrence"; import type { ParsedCalendarDocument, ParsedComponent, ParsedICalendar } from "@/worker/caldav/ical/types"; import type { RecurrenceBounds } from "@/worker/caldav/ical/types"; export interface CalendarParseOptions { floatingTimeZone?: string | null; } function componentRanges(lines: string[]): { componentType: CalendarComponentType; start: number; end: number }[] { const ranges: { componentType: CalendarComponentType; start: number; end: number }[] = []; const stack: string[] = []; let current: { componentType: CalendarComponentType; start: number } | null = null; for (const [index, rawLine] of lines.entries()) { const line = rawLine.toUpperCase(); if (line.startsWith("BEGIN:")) { const name = line.slice("BEGIN:".length); if (stack.length === 1 && supportedComponentTypes.has(name as CalendarComponentType)) { current = { componentType: name as CalendarComponentType, start: index }; } stack.push(name); continue; } if (!line.startsWith("END:")) continue; const name = line.slice("END:".length); if (current && current.componentType === name && stack.at(-1) === name) { ranges.push({ ...current, end: index }); current = null; } if (stack.at(-1) === name) stack.pop(); } return ranges; } function parseComponent( componentType: CalendarComponentType, lines: string[], options: CalendarParseOptions, ): ParsedComponent { const properties = lines.map(parseProperty).filter((prop) => prop !== null); const uid = firstProperty(properties, "UID")?.value; if (!uid) throw new Error("iCalendar UID is required"); // RFC 5545 3.6.1/3.6.2/3.6.3: DTSTAMP is required for VEVENT, VTODO, and VJOURNAL. if (!firstProperty(properties, "DTSTAMP")) { throw new Error(`iCalendar ${componentType} requires DTSTAMP`); } const dtstart = parseDateValue(firstProperty(properties, "DTSTART"), options); // RFC 5545 3.6.1: VEVENT requires DTSTART for stored objects (no METHOD). if (componentType === "VEVENT" && !dtstart) { throw new Error("iCalendar VEVENT requires DTSTART"); } return { componentType, lines, properties, uid, recurrenceId: parseDateValue(firstProperty(properties, "RECURRENCE-ID"), options), floatingTimeZone: options.floatingTimeZone ?? null, dtstart, dtend: parseDateValue(firstProperty(properties, "DTEND"), options), due: parseDateValue(firstProperty(properties, "DUE"), options), completed: parseDateValue(firstProperty(properties, "COMPLETED"), options), created: parseDateValue(firstProperty(properties, "CREATED"), options), durationMs: parseDuration(firstProperty(properties, "DURATION")?.value ?? ""), summary: firstProperty(properties, "SUMMARY")?.value ?? null, }; } export function parseCalendarDocument(body: string, options: CalendarParseOptions = {}): ParsedCalendarDocument { const lines = unfoldLines(body); if (lines[0]?.toUpperCase() !== "BEGIN:VCALENDAR" || lines.at(-1)?.toUpperCase() !== "END:VCALENDAR") { throw new Error("iCalendar must contain one VCALENDAR"); } if (lines.some((line) => parseProperty(line)?.name === "METHOD")) { throw new Error("Stored calendar resources must not include METHOD"); } // RFC 5545 3.6: VCALENDAR MUST contain VERSION and PRODID. const calendarProperties = lines .map(parseProperty) .filter((prop): prop is NonNullable> => prop !== null); if (!calendarProperties.some((prop) => prop.name === "VERSION")) { throw new Error("iCalendar VCALENDAR requires VERSION"); } if (!calendarProperties.some((prop) => prop.name === "PRODID")) { throw new Error("iCalendar VCALENDAR requires PRODID"); } const ranges = componentRanges(lines); if (ranges.length === 0) throw new Error("iCalendar must contain at least one VEVENT, VTODO, or VJOURNAL"); const componentTypes = new Set(ranges.map((range) => range.componentType)); if (componentTypes.size !== 1) throw new Error("iCalendar must contain exactly one VEVENT, VTODO, or VJOURNAL type"); const components = ranges.map((range) => parseComponent(range.componentType, lines.slice(range.start + 1, range.end), options), ); const uids = new Set(components.map((component) => component.uid)); if (uids.size !== 1) throw new Error("Calendar object recurrence sets must use one UID"); const masters = components.filter((component) => !component.recurrenceId); if (masters.length > 1) throw new Error("Calendar object resources can contain only one master component"); return { body, size: byteLength(body), uid: components[0]!.uid, componentType: components[0]!.componentType, components, master: masters[0] ?? null, overrides: components.filter((component) => component.recurrenceId), }; } export function parseICalendarBytes(bytes: ArrayBuffer, maxBytes: number, bounds: RecurrenceBounds): ParsedICalendar { if (bytes.byteLength > maxBytes) throw new Error("iCalendar exceeds configured maximum size"); const body = normalizeLineEndings(decodeUtf8(bytes)); const size = byteLength(body); if (size > maxBytes) throw new Error("iCalendar exceeds configured maximum size after normalization"); const document = parseCalendarDocument(body); const recurrence = recurrenceSummary(document, bounds); const primary = document.master ?? document.components[0]!; return { body, size, uid: document.uid, componentType: document.componentType, dtstartMs: primary.dtstart?.ms ?? null, dtendMs: primary.dtend?.ms ?? primary.dtstart?.ms ?? null, dueMs: primary.due?.ms ?? null, completedMs: primary.completed?.ms ?? null, summary: primary.summary, hasRecurrence: recurrence.hasRecurrence, recurrenceMinMs: recurrence.minMs, recurrenceMaxMs: recurrence.maxMs, }; }