File
Blob: src/worker/caldav/ical/parse.ts
| 1 | import type { CalendarComponentType } from "@/worker/db/types"; |
| 2 | |
| 3 | import { |
| 4 | byteLength, |
| 5 | decodeUtf8, |
| 6 | firstProperty, |
| 7 | normalizeLineEndings, |
| 8 | parseDateValue, |
| 9 | parseDuration, |
| 10 | parseProperty, |
| 11 | supportedComponentTypes, |
| 12 | unfoldLines, |
| 13 | } from "@/worker/caldav/ical/core"; |
| 14 | import { recurrenceSummary } from "@/worker/caldav/ical/recurrence"; |
| 15 | import type { ParsedCalendarDocument, ParsedComponent, ParsedICalendar } from "@/worker/caldav/ical/types"; |
| 16 | import type { RecurrenceBounds } from "@/worker/caldav/ical/types"; |
| 17 | |
| 18 | export interface CalendarParseOptions { |
| 19 | floatingTimeZone?: string | null; |
| 20 | } |
| 21 | |
| 22 | function componentRanges(lines: string[]): { componentType: CalendarComponentType; start: number; end: number }[] { |
| 23 | const ranges: { componentType: CalendarComponentType; start: number; end: number }[] = []; |
| 24 | const stack: string[] = []; |
| 25 | let current: { componentType: CalendarComponentType; start: number } | null = null; |
| 26 | |
| 27 | for (const [index, rawLine] of lines.entries()) { |
| 28 | const line = rawLine.toUpperCase(); |
| 29 | if (line.startsWith("BEGIN:")) { |
| 30 | const name = line.slice("BEGIN:".length); |
| 31 | if (stack.length === 1 && supportedComponentTypes.has(name as CalendarComponentType)) { |
| 32 | current = { componentType: name as CalendarComponentType, start: index }; |
| 33 | } |
| 34 | stack.push(name); |
| 35 | continue; |
| 36 | } |
| 37 | if (!line.startsWith("END:")) continue; |
| 38 | const name = line.slice("END:".length); |
| 39 | if (current && current.componentType === name && stack.at(-1) === name) { |
| 40 | ranges.push({ ...current, end: index }); |
| 41 | current = null; |
| 42 | } |
| 43 | if (stack.at(-1) === name) stack.pop(); |
| 44 | } |
| 45 | |
| 46 | return ranges; |
| 47 | } |
| 48 | |
| 49 | function parseComponent( |
| 50 | componentType: CalendarComponentType, |
| 51 | lines: string[], |
| 52 | options: CalendarParseOptions, |
| 53 | ): ParsedComponent { |
| 54 | const properties = lines.map(parseProperty).filter((prop) => prop !== null); |
| 55 | const uid = firstProperty(properties, "UID")?.value; |
| 56 | if (!uid) throw new Error("iCalendar UID is required"); |
| 57 | // RFC 5545 3.6.1/3.6.2/3.6.3: DTSTAMP is required for VEVENT, VTODO, and VJOURNAL. |
| 58 | if (!firstProperty(properties, "DTSTAMP")) { |
| 59 | throw new Error(`iCalendar ${componentType} requires DTSTAMP`); |
| 60 | } |
| 61 | const dtstart = parseDateValue(firstProperty(properties, "DTSTART"), options); |
| 62 | // RFC 5545 3.6.1: VEVENT requires DTSTART for stored objects (no METHOD). |
| 63 | if (componentType === "VEVENT" && !dtstart) { |
| 64 | throw new Error("iCalendar VEVENT requires DTSTART"); |
| 65 | } |
| 66 | return { |
| 67 | componentType, |
| 68 | lines, |
| 69 | properties, |
| 70 | uid, |
| 71 | recurrenceId: parseDateValue(firstProperty(properties, "RECURRENCE-ID"), options), |
| 72 | floatingTimeZone: options.floatingTimeZone ?? null, |
| 73 | dtstart, |
| 74 | dtend: parseDateValue(firstProperty(properties, "DTEND"), options), |
| 75 | due: parseDateValue(firstProperty(properties, "DUE"), options), |
| 76 | completed: parseDateValue(firstProperty(properties, "COMPLETED"), options), |
| 77 | created: parseDateValue(firstProperty(properties, "CREATED"), options), |
| 78 | durationMs: parseDuration(firstProperty(properties, "DURATION")?.value ?? ""), |
| 79 | summary: firstProperty(properties, "SUMMARY")?.value ?? null, |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | export function parseCalendarDocument(body: string, options: CalendarParseOptions = {}): ParsedCalendarDocument { |
| 84 | const lines = unfoldLines(body); |
| 85 | if (lines[0]?.toUpperCase() !== "BEGIN:VCALENDAR" || lines.at(-1)?.toUpperCase() !== "END:VCALENDAR") { |
| 86 | throw new Error("iCalendar must contain one VCALENDAR"); |
| 87 | } |
| 88 | if (lines.some((line) => parseProperty(line)?.name === "METHOD")) { |
| 89 | throw new Error("Stored calendar resources must not include METHOD"); |
| 90 | } |
| 91 | // RFC 5545 3.6: VCALENDAR MUST contain VERSION and PRODID. |
| 92 | const calendarProperties = lines |
| 93 | .map(parseProperty) |
| 94 | .filter((prop): prop is NonNullable<ReturnType<typeof parseProperty>> => prop !== null); |
| 95 | if (!calendarProperties.some((prop) => prop.name === "VERSION")) { |
| 96 | throw new Error("iCalendar VCALENDAR requires VERSION"); |
| 97 | } |
| 98 | if (!calendarProperties.some((prop) => prop.name === "PRODID")) { |
| 99 | throw new Error("iCalendar VCALENDAR requires PRODID"); |
| 100 | } |
| 101 | |
| 102 | const ranges = componentRanges(lines); |
| 103 | if (ranges.length === 0) throw new Error("iCalendar must contain at least one VEVENT, VTODO, or VJOURNAL"); |
| 104 | const componentTypes = new Set(ranges.map((range) => range.componentType)); |
| 105 | if (componentTypes.size !== 1) throw new Error("iCalendar must contain exactly one VEVENT, VTODO, or VJOURNAL type"); |
| 106 | |
| 107 | const components = ranges.map((range) => |
| 108 | parseComponent(range.componentType, lines.slice(range.start + 1, range.end), options), |
| 109 | ); |
| 110 | const uids = new Set(components.map((component) => component.uid)); |
| 111 | if (uids.size !== 1) throw new Error("Calendar object recurrence sets must use one UID"); |
| 112 | |
| 113 | const masters = components.filter((component) => !component.recurrenceId); |
| 114 | if (masters.length > 1) throw new Error("Calendar object resources can contain only one master component"); |
| 115 | |
| 116 | return { |
| 117 | body, |
| 118 | size: byteLength(body), |
| 119 | uid: components[0]!.uid, |
| 120 | componentType: components[0]!.componentType, |
| 121 | components, |
| 122 | master: masters[0] ?? null, |
| 123 | overrides: components.filter((component) => component.recurrenceId), |
| 124 | }; |
| 125 | } |
| 126 | |
| 127 | export function parseICalendarBytes(bytes: ArrayBuffer, maxBytes: number, bounds: RecurrenceBounds): ParsedICalendar { |
| 128 | if (bytes.byteLength > maxBytes) throw new Error("iCalendar exceeds configured maximum size"); |
| 129 | const body = normalizeLineEndings(decodeUtf8(bytes)); |
| 130 | const size = byteLength(body); |
| 131 | if (size > maxBytes) throw new Error("iCalendar exceeds configured maximum size after normalization"); |
| 132 | const document = parseCalendarDocument(body); |
| 133 | const recurrence = recurrenceSummary(document, bounds); |
| 134 | const primary = document.master ?? document.components[0]!; |
| 135 | |
| 136 | return { |
| 137 | body, |
| 138 | size, |
| 139 | uid: document.uid, |
| 140 | componentType: document.componentType, |
| 141 | dtstartMs: primary.dtstart?.ms ?? null, |
| 142 | dtendMs: primary.dtend?.ms ?? primary.dtstart?.ms ?? null, |
| 143 | dueMs: primary.due?.ms ?? null, |
| 144 | completedMs: primary.completed?.ms ?? null, |
| 145 | summary: primary.summary, |
| 146 | hasRecurrence: recurrence.hasRecurrence, |
| 147 | recurrenceMinMs: recurrence.minMs, |
| 148 | recurrenceMaxMs: recurrence.maxMs, |
| 149 | }; |
| 150 | } |