Skip to content
File

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

typescript383 lines
1import {
2 componentDurationMs,
3 dayMs,
4 documentHasRecurrence,
5 firstProperty,
6 isCancelled,
7 parseDateList,
8 parseDateValue,
9 propertiesByName,
10 recurrenceBase,
11 weekdayByName,
12} from "@/worker/caldav/ical/core";
13import type {
14 CalendarOccurrence,
15 CalendarTimeRange,
16 ParsedCalendarDocument,
17 ParsedComponent,
18 ParsedProperty,
19 RecurrenceBounds,
20} from "@/worker/caldav/ical/types";
21 
22function shifted(value: { ms: number } | null, deltaMs: number): number | null {
23 return value ? value.ms + deltaMs : null;
24}
25 
26function rangeStart(range: CalendarTimeRange): number {
27 return range.startMs ?? Number.NEGATIVE_INFINITY;
28}
29 
30function rangeEnd(range: CalendarTimeRange): number {
31 return range.endMs ?? Number.POSITIVE_INFINITY;
32}
33 
34function componentOverlaps(component: ParsedComponent, range: CalendarTimeRange, deltaMs = 0): boolean {
35 const start = rangeStart(range);
36 const end = rangeEnd(range);
37 const dtstart = shifted(component.dtstart, deltaMs);
38 const dtend = shifted(component.dtend, deltaMs);
39 const due = shifted(component.due, deltaMs);
40 const completed = shifted(component.completed, deltaMs);
41 const created = shifted(component.created, deltaMs);
42 const duration = component.durationMs ?? 0;
43 
44 if (component.componentType === "VEVENT") {
45 if (dtstart === null) return false;
46 if (dtend !== null) return start < dtend && end > dtstart;
47 if (duration > 0) return start < dtstart + duration && end > dtstart;
48 if (component.dtstart?.valueType === "date") return start < dtstart + dayMs && end > dtstart;
49 return start <= dtstart && end > dtstart;
50 }
51 
52 if (component.componentType === "VJOURNAL") {
53 if (dtstart === null) return false;
54 return component.dtstart?.valueType === "date"
55 ? start < dtstart + dayMs && end > dtstart
56 : start <= dtstart && end > dtstart;
57 }
58 
59 if (dtstart !== null && duration > 0 && due === null) {
60 return start <= dtstart + duration && (end > dtstart || end >= dtstart + duration);
61 }
62 if (dtstart !== null && due !== null) {
63 return (start < due || start <= dtstart) && (end > dtstart || end >= due);
64 }
65 if (dtstart !== null) return start <= dtstart && end > dtstart;
66 if (due !== null) return start < due && end >= due;
67 if (completed !== null && created !== null) {
68 return (start <= created || start <= completed) && (end >= created || end >= completed);
69 }
70 if (completed !== null) return start <= completed && end >= completed;
71 if (created !== null) return end > created;
72 return true;
73}
74 
75function parseRRule(value: string): Record<string, string> {
76 const rule = Object.fromEntries(
77 value
78 .split(";")
79 .map((part) => part.split("="))
80 .filter((part): part is [string, string] => part.length === 2 && Boolean(part[0]))
81 .map(([key, val]) => [key.toUpperCase(), val.toUpperCase()]),
82 );
83 const supported = new Set(["FREQ", "INTERVAL", "COUNT", "UNTIL", "BYMONTH", "BYMONTHDAY", "BYDAY", "WKST"]);
84 const unsupported = Object.keys(rule).find((key) => !supported.has(key));
85 if (unsupported) throw new Error(`Unsupported RRULE part ${unsupported}`);
86 return rule;
87}
88 
89function parseIntegerList(value: string | undefined): number[] | null {
90 if (!value) return null;
91 return value
92 .split(",")
93 .map((item) => Number.parseInt(item, 10))
94 .filter((item) => Number.isInteger(item));
95}
96 
97function parseByDayList(value: string | undefined): { ordinal: number | null; weekday: number }[] | null {
98 if (!value) return null;
99 return value
100 .split(",")
101 .map((item) => /^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(item))
102 .filter((match): match is RegExpExecArray => match !== null)
103 .map((match) => ({
104 ordinal: match[1] ? Number.parseInt(match[1], 10) : null,
105 weekday: weekdayByName[match[2]!]!,
106 }));
107}
108 
109function startOfUtcDay(value: number): number {
110 const date = new Date(value);
111 return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
112}
113 
114function daysInMonth(year: number, month: number): number {
115 return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
116}
117 
118function monthDiff(start: Date, date: Date): number {
119 return (date.getUTCFullYear() - start.getUTCFullYear()) * 12 + date.getUTCMonth() - start.getUTCMonth();
120}
121 
122function weekStart(value: number, weekStartDay: number): number {
123 const date = new Date(startOfUtcDay(value));
124 const diff = (date.getUTCDay() - weekStartDay + 7) % 7;
125 date.setUTCDate(date.getUTCDate() - diff);
126 return date.getTime();
127}
128 
129function monthDayMatches(date: Date, byMonthDay: number[] | null, defaultDay: number): boolean {
130 if (!byMonthDay) return date.getUTCDate() === defaultDay;
131 const monthLength = daysInMonth(date.getUTCFullYear(), date.getUTCMonth());
132 return byMonthDay.some((day) => (day > 0 ? date.getUTCDate() === day : date.getUTCDate() === monthLength + day + 1));
133}
134 
135function byDayMatches(date: Date, byDay: { ordinal: number | null; weekday: number }[]): boolean {
136 return byDay.some((entry) => {
137 if (date.getUTCDay() !== entry.weekday) return false;
138 if (entry.ordinal === null) return true;
139 const monthLength = daysInMonth(date.getUTCFullYear(), date.getUTCMonth());
140 const ordinal =
141 entry.ordinal > 0
142 ? Math.floor((date.getUTCDate() - 1) / 7) + 1
143 : -Math.floor((monthLength - date.getUTCDate()) / 7) - 1;
144 return ordinal === entry.ordinal;
145 });
146}
147 
148function ruleDateMatches(rule: Record<string, string>, startDate: Date, date: Date, interval: number): boolean {
149 const freq = rule.FREQ;
150 const byMonth = parseIntegerList(rule.BYMONTH);
151 const byMonthDay = parseIntegerList(rule.BYMONTHDAY);
152 const byDay = parseByDayList(rule.BYDAY);
153 if (byMonth && !byMonth.includes(date.getUTCMonth() + 1)) return false;
154 
155 switch (freq) {
156 case "DAILY": {
157 const diff = Math.floor((startOfUtcDay(date.getTime()) - startOfUtcDay(startDate.getTime())) / dayMs);
158 if (diff < 0 || diff % interval !== 0) return false;
159 if (byMonthDay && !monthDayMatches(date, byMonthDay, date.getUTCDate())) return false;
160 return byDay ? byDayMatches(date, byDay) : true;
161 }
162 case "WEEKLY": {
163 const weekStartDay = weekdayByName[rule.WKST ?? "MO"] ?? weekdayByName.MO;
164 const diff = Math.floor(
165 (weekStart(date.getTime(), weekStartDay) - weekStart(startDate.getTime(), weekStartDay)) / (7 * dayMs),
166 );
167 if (diff < 0 || diff % interval !== 0) return false;
168 return byDay
169 ? byDay.some((entry) => entry.weekday === date.getUTCDay())
170 : date.getUTCDay() === startDate.getUTCDay();
171 }
172 case "MONTHLY": {
173 const diff = monthDiff(startDate, date);
174 if (diff < 0 || diff % interval !== 0) return false;
175 if (byMonthDay) return monthDayMatches(date, byMonthDay, startDate.getUTCDate());
176 if (byDay) return byDayMatches(date, byDay);
177 return date.getUTCDate() === startDate.getUTCDate();
178 }
179 case "YEARLY": {
180 const diff = date.getUTCFullYear() - startDate.getUTCFullYear();
181 if (diff < 0 || diff % interval !== 0) return false;
182 if (!byMonth && date.getUTCMonth() !== startDate.getUTCMonth()) return false;
183 if (byMonthDay) return monthDayMatches(date, byMonthDay, startDate.getUTCDate());
184 if (byDay) return byDayMatches(date, byDay);
185 return date.getUTCMonth() === startDate.getUTCMonth() && date.getUTCDate() === startDate.getUTCDate();
186 }
187 default:
188 throw new Error(`Unsupported RRULE FREQ ${freq ?? "(missing)"}`);
189 }
190}
191 
192function generateRRuleStarts(
193 component: ParsedComponent,
194 rrule: ParsedProperty,
195 bounds: RecurrenceBounds,
196 window?: CalendarTimeRange,
197): number[] {
198 const base = recurrenceBase(component);
199 if (!base) return [];
200 const rule = parseRRule(rrule.value);
201 if (!rule.FREQ) throw new Error("RRULE must include FREQ");
202 const interval = rule.INTERVAL ? Number.parseInt(rule.INTERVAL, 10) : 1;
203 if (!Number.isInteger(interval) || interval <= 0) throw new Error("RRULE INTERVAL must be positive");
204 const count = rule.COUNT ? Number.parseInt(rule.COUNT, 10) : null;
205 if (count !== null && (!Number.isInteger(count) || count <= 0)) throw new Error("RRULE COUNT must be positive");
206 if (count !== null && count > bounds.maxInstances) throw new Error("RRULE exceeds configured maximum instances");
207 const until = rule.UNTIL
208 ? parseDateValue(
209 { name: "UNTIL", params: {}, value: rule.UNTIL, rawLine: rule.UNTIL },
210 { floatingTimeZone: component.floatingTimeZone },
211 )
212 : null;
213 const unbounded = count === null && until === null;
214 
215 const startDate = new Date(base.ms);
216 const maxDate = new Date(base.ms);
217 maxDate.setUTCFullYear(maxDate.getUTCFullYear() + bounds.maxYears);
218 const scanEnd =
219 unbounded && window
220 ? (window.endMs ?? (window.startMs === null ? maxDate.getTime() : window.startMs + 366 * dayMs))
221 : Math.min(maxDate.getTime(), until?.ms ?? maxDate.getTime());
222 const scanStart =
223 unbounded && window?.startMs !== null && window?.startMs !== undefined
224 ? Math.max(
225 startOfUtcDay(base.ms),
226 startOfUtcDay(window.startMs - Math.max(componentDurationMs(component), dayMs)),
227 )
228 : startOfUtcDay(base.ms);
229 const time = {
230 hour: startDate.getUTCHours(),
231 minute: startDate.getUTCMinutes(),
232 second: startDate.getUTCSeconds(),
233 };
234 
235 const starts: number[] = [];
236 for (let cursor = scanStart; cursor <= scanEnd; cursor += dayMs) {
237 const date = new Date(cursor);
238 if (!ruleDateMatches(rule, startDate, date, interval)) continue;
239 const candidate = Date.UTC(
240 date.getUTCFullYear(),
241 date.getUTCMonth(),
242 date.getUTCDate(),
243 time.hour,
244 time.minute,
245 time.second,
246 );
247 if (candidate < base.ms || (until && candidate > until.ms)) continue;
248 starts.push(candidate);
249 if (starts.length > bounds.maxInstances) throw new Error("RRULE exceeds configured maximum instances");
250 if (count !== null && starts.length >= count) break;
251 }
252 return starts;
253}
254 
255function recurrenceStarts(component: ParsedComponent, bounds: RecurrenceBounds, window?: CalendarTimeRange): number[] {
256 const base = recurrenceBase(component);
257 if (!base) return [];
258 const rrules = propertiesByName(component.properties, "RRULE");
259 const included = new Set<number>(rrules.length === 0 ? [base.ms] : []);
260 for (const rrule of rrules) {
261 for (const start of generateRRuleStarts(component, rrule, bounds, window)) included.add(start);
262 }
263 for (const rdate of propertiesByName(component.properties, "RDATE")) {
264 for (const value of parseDateList(rdate, { floatingTimeZone: component.floatingTimeZone })) included.add(value.ms);
265 }
266 const excluded = new Set<number>();
267 for (const exdate of propertiesByName(component.properties, "EXDATE")) {
268 for (const value of parseDateList(exdate, { floatingTimeZone: component.floatingTimeZone })) excluded.add(value.ms);
269 }
270 const starts = [...included].filter((value) => !excluded.has(value)).sort((a, b) => a - b);
271 if (starts.length > bounds.maxInstances) throw new Error("RRULE exceeds configured maximum instances");
272 return starts;
273}
274 
275function occurrenceFor(
276 component: ParsedComponent,
277 originalStartMs: number,
278 fallbackDurationMs: number,
279 isInitial: boolean,
280 shiftRecurrenceId = false,
281): CalendarOccurrence {
282 const anchor = recurrenceBase(component);
283 const deltaMs = component.recurrenceId && !shiftRecurrenceId ? 0 : anchor ? originalStartMs - anchor.ms : 0;
284 const startMs =
285 (component.dtstart?.ms ?? component.due?.ms ?? component.recurrenceId?.ms ?? originalStartMs) + deltaMs;
286 const duration = componentDurationMs(component) || fallbackDurationMs;
287 const endMs =
288 component.componentType === "VTODO" && component.due ? component.due.ms + deltaMs : startMs + Math.max(0, duration);
289 return { component, originalStartMs, startMs, endMs, isInitial };
290}
291 
292export function calendarOccurrences(
293 document: ParsedCalendarDocument,
294 bounds: RecurrenceBounds,
295 window?: CalendarTimeRange,
296): CalendarOccurrence[] {
297 const occurrences: CalendarOccurrence[] = [];
298 const overridesByOriginalStart = new Map<number, ParsedComponent>();
299 const rangeOverrides: ParsedComponent[] = [];
300 for (const override of document.overrides) {
301 if (override.recurrenceId) overridesByOriginalStart.set(override.recurrenceId.ms, override);
302 if (firstProperty(override.properties, "RECURRENCE-ID")?.params.RANGE?.toUpperCase() === "THISANDFUTURE") {
303 rangeOverrides.push(override);
304 }
305 }
306 
307 if (document.master) {
308 const base = recurrenceBase(document.master);
309 const fallbackDuration = componentDurationMs(document.master);
310 for (const originalStartMs of recurrenceStarts(document.master, bounds, window)) {
311 const exactOverride = overridesByOriginalStart.get(originalStartMs);
312 const rangeOverride =
313 exactOverride ??
314 rangeOverrides
315 .filter((override) => override.recurrenceId && override.recurrenceId.ms <= originalStartMs)
316 .sort((a, b) => b.recurrenceId!.ms - a.recurrenceId!.ms)[0];
317 const override = exactOverride ?? rangeOverride;
318 if (override && isCancelled(override)) continue;
319 occurrences.push(
320 occurrenceFor(
321 override ?? document.master,
322 originalStartMs,
323 fallbackDuration,
324 base?.ms === originalStartMs,
325 Boolean(!exactOverride && rangeOverride),
326 ),
327 );
328 }
329 }
330 
331 const generatedOriginals = new Set(occurrences.map((occurrence) => occurrence.originalStartMs));
332 for (const override of document.overrides) {
333 if (isCancelled(override)) continue;
334 const originalStartMs = override.recurrenceId?.ms ?? recurrenceBase(override)?.ms;
335 if (originalStartMs === undefined || generatedOriginals.has(originalStartMs)) continue;
336 occurrences.push(occurrenceFor(override, originalStartMs, componentDurationMs(override), false));
337 }
338 
339 if (!document.master && occurrences.length === 0) {
340 for (const component of document.components) {
341 const base = recurrenceBase(component);
342 if (base) occurrences.push(occurrenceFor(component, base.ms, componentDurationMs(component), true));
343 }
344 }
345 
346 return occurrences.sort((a, b) => a.startMs - b.startMs);
347}
348 
349export function occurrenceOverlaps(
350 document: ParsedCalendarDocument,
351 occurrence: CalendarOccurrence,
352 range: CalendarTimeRange,
353): boolean {
354 if (occurrence.component === document.master) {
355 const base = recurrenceBase(occurrence.component);
356 const deltaMs = base ? occurrence.originalStartMs - base.ms : 0;
357 return componentOverlaps(occurrence.component, range, deltaMs);
358 }
359 return componentOverlaps(occurrence.component, range);
360}
361 
362export function matchesTimeRange(
363 document: ParsedCalendarDocument,
364 range: CalendarTimeRange,
365 bounds: RecurrenceBounds,
366): boolean {
367 for (const occurrence of calendarOccurrences(document, bounds, range)) {
368 if (occurrenceOverlaps(document, occurrence, range)) return true;
369 }
370 return false;
371}
372 
373export function recurrenceSummary(document: ParsedCalendarDocument, bounds: RecurrenceBounds) {
374 const occurrences = calendarOccurrences(document, bounds);
375 const hasRecurrence = documentHasRecurrence(document.components);
376 if (occurrences.length === 0) return { hasRecurrence, minMs: null, maxMs: null };
377 return {
378 hasRecurrence,
379 minMs: Math.min(...occurrences.map((occurrence) => occurrence.startMs)),
380 maxMs: Math.max(...occurrences.map((occurrence) => occurrence.endMs)),
381 };
382}