Skip to content
File

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

typescript207 lines
1import type { CalendarComponentType } from "@/worker/db/types";
2 
3import type { DateValue, ParsedComponent, ParsedProperty } from "@/worker/caldav/ical/types";
4 
5export const supportedComponentTypes = new Set<CalendarComponentType>(["VEVENT", "VTODO", "VJOURNAL"]);
6export const dayMs = 24 * 60 * 60 * 1000;
7export const weekdayByName: Record<string, number> = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
8 
9export function byteLength(value: string): number {
10 return new TextEncoder().encode(value).byteLength;
11}
12 
13export function decodeUtf8(bytes: ArrayBuffer): string {
14 return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
15}
16 
17export function normalizeLineEndings(text: string): string {
18 return `${text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+$/g, "").split("\n").join("\r\n")}\r\n`;
19}
20 
21export function unfoldLines(body: string): string[] {
22 const lines = body.replace(/\r\n/g, "\n").split("\n");
23 const unfolded: string[] = [];
24 for (const line of lines) {
25 if ((line.startsWith(" ") || line.startsWith("\t")) && unfolded.length > 0) {
26 unfolded[unfolded.length - 1] += line.slice(1);
27 } else if (line !== "") {
28 unfolded.push(line);
29 }
30 }
31 return unfolded;
32}
33 
34export function parseProperty(line: string): ParsedProperty | null {
35 const colon = line.indexOf(":");
36 if (colon === -1) return null;
37 const head = line.slice(0, colon);
38 const value = line.slice(colon + 1);
39 const [rawName, ...rawParams] = head.split(";");
40 if (!rawName) return null;
41 const params: Record<string, string> = {};
42 for (const rawParam of rawParams) {
43 const equals = rawParam.indexOf("=");
44 if (equals === -1) continue;
45 params[rawParam.slice(0, equals).toUpperCase()] = rawParam.slice(equals + 1);
46 }
47 return { name: rawName.toUpperCase(), params, value, rawLine: line };
48}
49 
50export function firstProperty(properties: ParsedProperty[], name: string): ParsedProperty | null {
51 return properties.find((prop) => prop.name === name) ?? null;
52}
53 
54export function propertiesByName(properties: ParsedProperty[], name: string): ParsedProperty[] {
55 return properties.filter((prop) => prop.name === name);
56}
57 
58export interface DateParseOptions {
59 floatingTimeZone?: string | null;
60}
61 
62export function parseDateValue(prop: ParsedProperty | null, options: DateParseOptions = {}): DateValue | null {
63 if (!prop) return null;
64 const value = prop.value;
65 const date = /^(\d{4})(\d{2})(\d{2})$/.exec(value);
66 if (date) {
67 const local = {
68 year: Number(date[1]),
69 month: Number(date[2]),
70 day: Number(date[3]),
71 hour: 0,
72 minute: 0,
73 second: 0,
74 };
75 return {
76 raw: value,
77 valueType: "date",
78 ms: options.floatingTimeZone
79 ? zonedDateTimeToUtcMs(options.floatingTimeZone, local)
80 : Date.UTC(local.year, local.month - 1, local.day),
81 floating: true,
82 };
83 }
84 const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(value);
85 if (!dateTime) throw new Error(`Invalid iCalendar date value for ${prop.name}`);
86 const utcMs = Date.UTC(
87 Number(dateTime[1]),
88 Number(dateTime[2]) - 1,
89 Number(dateTime[3]),
90 Number(dateTime[4]),
91 Number(dateTime[5]),
92 Number(dateTime[6]),
93 );
94 const floating = dateTime[7] !== "Z" && !prop.params.TZID;
95 return {
96 raw: value,
97 valueType: "date-time",
98 ms:
99 dateTime[7] === "Z"
100 ? utcMs
101 : zonedDateTimeToUtcMs(prop.params.TZID ?? options.floatingTimeZone ?? "UTC", {
102 year: Number(dateTime[1]),
103 month: Number(dateTime[2]),
104 day: Number(dateTime[3]),
105 hour: Number(dateTime[4]),
106 minute: Number(dateTime[5]),
107 second: Number(dateTime[6]),
108 }),
109 floating,
110 };
111}
112 
113function zonedDateTimeToUtcMs(
114 timeZone: string,
115 local: { year: number; month: number; day: number; hour: number; minute: number; second: number },
116): number {
117 let guess = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second);
118 let formatter: Intl.DateTimeFormat;
119 try {
120 formatter = new Intl.DateTimeFormat("en-US", {
121 timeZone,
122 hour12: false,
123 year: "numeric",
124 month: "2-digit",
125 day: "2-digit",
126 hour: "2-digit",
127 minute: "2-digit",
128 second: "2-digit",
129 });
130 } catch {
131 throw new Error(`Unsupported iCalendar TZID ${timeZone}`);
132 }
133 
134 const desired = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute, local.second);
135 for (let index = 0; index < 4; index += 1) {
136 const parts = Object.fromEntries(formatter.formatToParts(new Date(guess)).map((part) => [part.type, part.value]));
137 const actual = Date.UTC(
138 Number(parts.year),
139 Number(parts.month) - 1,
140 Number(parts.day),
141 Number(parts.hour) % 24,
142 Number(parts.minute),
143 Number(parts.second),
144 );
145 const diff = actual - desired;
146 if (diff === 0) return guess;
147 guess -= diff;
148 }
149 return guess;
150}
151 
152export function parseDateList(prop: ParsedProperty, options: DateParseOptions = {}): DateValue[] {
153 return prop.value
154 .split(",")
155 .map((value) =>
156 parseDateValue({ ...prop, value: value.includes("/") ? value.slice(0, value.indexOf("/")) : value }, options),
157 )
158 .filter((value): value is DateValue => value !== null);
159}
160 
161export function parseDuration(value: string): number | null {
162 const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(value);
163 if (!match) return null;
164 const weeks = Number(match[1] ?? 0);
165 const days = Number(match[2] ?? 0);
166 const hours = Number(match[3] ?? 0);
167 const minutes = Number(match[4] ?? 0);
168 const seconds = Number(match[5] ?? 0);
169 if (weeks + days + hours + minutes + seconds === 0) return 0;
170 return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 * 1000 + seconds * 1000;
171}
172 
173export function recurrenceBase(component: ParsedComponent): DateValue | null {
174 return component.dtstart ?? component.due ?? component.recurrenceId;
175}
176 
177export function componentDurationMs(component: ParsedComponent): number {
178 if (component.componentType === "VEVENT") {
179 if (component.dtstart && component.dtend) return Math.max(0, component.dtend.ms - component.dtstart.ms);
180 if (component.durationMs !== null) return Math.max(0, component.durationMs);
181 return component.dtstart?.valueType === "date" ? dayMs : 0;
182 }
183 if (component.componentType === "VJOURNAL") {
184 return component.dtstart?.valueType === "date" ? dayMs : 0;
185 }
186 if (component.dtstart && component.due) return Math.max(0, component.due.ms - component.dtstart.ms);
187 if (component.durationMs !== null) return Math.max(0, component.durationMs);
188 return 0;
189}
190 
191export function propertyValues(component: ParsedComponent, name: string): string[] {
192 return propertiesByName(component.properties, name.toUpperCase()).map((prop) => prop.value);
193}
194 
195export function isCancelled(component: ParsedComponent): boolean {
196 return propertyValues(component, "STATUS").some((value) => value.toUpperCase() === "CANCELLED");
197}
198 
199export function documentHasRecurrence(components: ParsedComponent[]): boolean {
200 return (
201 components.length > 1 ||
202 components.some((component) =>
203 component.properties.some((prop) => prop.name === "RRULE" || prop.name === "RDATE" || prop.name === "EXDATE"),
204 )
205 );
206}