Skip to content
File

Blob: src/worker/caldav/ical/parse.ts

typescript151 lines
1import type { CalendarComponentType } from "@/worker/db/types";
2 
3import {
4 byteLength,
5 decodeUtf8,
6 firstProperty,
7 normalizeLineEndings,
8 parseDateValue,
9 parseDuration,
10 parseProperty,
11 supportedComponentTypes,
12 unfoldLines,
13} from "@/worker/caldav/ical/core";
14import { recurrenceSummary } from "@/worker/caldav/ical/recurrence";
15import type { ParsedCalendarDocument, ParsedComponent, ParsedICalendar } from "@/worker/caldav/ical/types";
16import type { RecurrenceBounds } from "@/worker/caldav/ical/types";
17 
18export interface CalendarParseOptions {
19 floatingTimeZone?: string | null;
20}
21 
22function 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 
49function 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 
83export 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 
127export 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}