import { componentDurationMs, dayMs, documentHasRecurrence, firstProperty, isCancelled, parseDateList, parseDateValue, propertiesByName, recurrenceBase, weekdayByName, } from "@/worker/caldav/ical/core"; import type { CalendarOccurrence, CalendarTimeRange, ParsedCalendarDocument, ParsedComponent, ParsedProperty, RecurrenceBounds, } from "@/worker/caldav/ical/types"; function shifted(value: { ms: number } | null, deltaMs: number): number | null { return value ? value.ms + deltaMs : null; } function rangeStart(range: CalendarTimeRange): number { return range.startMs ?? Number.NEGATIVE_INFINITY; } function rangeEnd(range: CalendarTimeRange): number { return range.endMs ?? Number.POSITIVE_INFINITY; } function componentOverlaps(component: ParsedComponent, range: CalendarTimeRange, deltaMs = 0): boolean { const start = rangeStart(range); const end = rangeEnd(range); const dtstart = shifted(component.dtstart, deltaMs); const dtend = shifted(component.dtend, deltaMs); const due = shifted(component.due, deltaMs); const completed = shifted(component.completed, deltaMs); const created = shifted(component.created, deltaMs); const duration = component.durationMs ?? 0; if (component.componentType === "VEVENT") { if (dtstart === null) return false; if (dtend !== null) return start < dtend && end > dtstart; if (duration > 0) return start < dtstart + duration && end > dtstart; if (component.dtstart?.valueType === "date") return start < dtstart + dayMs && end > dtstart; return start <= dtstart && end > dtstart; } if (component.componentType === "VJOURNAL") { if (dtstart === null) return false; return component.dtstart?.valueType === "date" ? start < dtstart + dayMs && end > dtstart : start <= dtstart && end > dtstart; } if (dtstart !== null && duration > 0 && due === null) { return start <= dtstart + duration && (end > dtstart || end >= dtstart + duration); } if (dtstart !== null && due !== null) { return (start < due || start <= dtstart) && (end > dtstart || end >= due); } if (dtstart !== null) return start <= dtstart && end > dtstart; if (due !== null) return start < due && end >= due; if (completed !== null && created !== null) { return (start <= created || start <= completed) && (end >= created || end >= completed); } if (completed !== null) return start <= completed && end >= completed; if (created !== null) return end > created; return true; } function parseRRule(value: string): Record { const rule = Object.fromEntries( value .split(";") .map((part) => part.split("=")) .filter((part): part is [string, string] => part.length === 2 && Boolean(part[0])) .map(([key, val]) => [key.toUpperCase(), val.toUpperCase()]), ); const supported = new Set(["FREQ", "INTERVAL", "COUNT", "UNTIL", "BYMONTH", "BYMONTHDAY", "BYDAY", "WKST"]); const unsupported = Object.keys(rule).find((key) => !supported.has(key)); if (unsupported) throw new Error(`Unsupported RRULE part ${unsupported}`); return rule; } function parseIntegerList(value: string | undefined): number[] | null { if (!value) return null; return value .split(",") .map((item) => Number.parseInt(item, 10)) .filter((item) => Number.isInteger(item)); } function parseByDayList(value: string | undefined): { ordinal: number | null; weekday: number }[] | null { if (!value) return null; return value .split(",") .map((item) => /^([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(item)) .filter((match): match is RegExpExecArray => match !== null) .map((match) => ({ ordinal: match[1] ? Number.parseInt(match[1], 10) : null, weekday: weekdayByName[match[2]!]!, })); } function startOfUtcDay(value: number): number { const date = new Date(value); return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); } function daysInMonth(year: number, month: number): number { return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); } function monthDiff(start: Date, date: Date): number { return (date.getUTCFullYear() - start.getUTCFullYear()) * 12 + date.getUTCMonth() - start.getUTCMonth(); } function weekStart(value: number, weekStartDay: number): number { const date = new Date(startOfUtcDay(value)); const diff = (date.getUTCDay() - weekStartDay + 7) % 7; date.setUTCDate(date.getUTCDate() - diff); return date.getTime(); } function monthDayMatches(date: Date, byMonthDay: number[] | null, defaultDay: number): boolean { if (!byMonthDay) return date.getUTCDate() === defaultDay; const monthLength = daysInMonth(date.getUTCFullYear(), date.getUTCMonth()); return byMonthDay.some((day) => (day > 0 ? date.getUTCDate() === day : date.getUTCDate() === monthLength + day + 1)); } function byDayMatches(date: Date, byDay: { ordinal: number | null; weekday: number }[]): boolean { return byDay.some((entry) => { if (date.getUTCDay() !== entry.weekday) return false; if (entry.ordinal === null) return true; const monthLength = daysInMonth(date.getUTCFullYear(), date.getUTCMonth()); const ordinal = entry.ordinal > 0 ? Math.floor((date.getUTCDate() - 1) / 7) + 1 : -Math.floor((monthLength - date.getUTCDate()) / 7) - 1; return ordinal === entry.ordinal; }); } function ruleDateMatches(rule: Record, startDate: Date, date: Date, interval: number): boolean { const freq = rule.FREQ; const byMonth = parseIntegerList(rule.BYMONTH); const byMonthDay = parseIntegerList(rule.BYMONTHDAY); const byDay = parseByDayList(rule.BYDAY); if (byMonth && !byMonth.includes(date.getUTCMonth() + 1)) return false; switch (freq) { case "DAILY": { const diff = Math.floor((startOfUtcDay(date.getTime()) - startOfUtcDay(startDate.getTime())) / dayMs); if (diff < 0 || diff % interval !== 0) return false; if (byMonthDay && !monthDayMatches(date, byMonthDay, date.getUTCDate())) return false; return byDay ? byDayMatches(date, byDay) : true; } case "WEEKLY": { const weekStartDay = weekdayByName[rule.WKST ?? "MO"] ?? weekdayByName.MO; const diff = Math.floor( (weekStart(date.getTime(), weekStartDay) - weekStart(startDate.getTime(), weekStartDay)) / (7 * dayMs), ); if (diff < 0 || diff % interval !== 0) return false; return byDay ? byDay.some((entry) => entry.weekday === date.getUTCDay()) : date.getUTCDay() === startDate.getUTCDay(); } case "MONTHLY": { const diff = monthDiff(startDate, date); if (diff < 0 || diff % interval !== 0) return false; if (byMonthDay) return monthDayMatches(date, byMonthDay, startDate.getUTCDate()); if (byDay) return byDayMatches(date, byDay); return date.getUTCDate() === startDate.getUTCDate(); } case "YEARLY": { const diff = date.getUTCFullYear() - startDate.getUTCFullYear(); if (diff < 0 || diff % interval !== 0) return false; if (!byMonth && date.getUTCMonth() !== startDate.getUTCMonth()) return false; if (byMonthDay) return monthDayMatches(date, byMonthDay, startDate.getUTCDate()); if (byDay) return byDayMatches(date, byDay); return date.getUTCMonth() === startDate.getUTCMonth() && date.getUTCDate() === startDate.getUTCDate(); } default: throw new Error(`Unsupported RRULE FREQ ${freq ?? "(missing)"}`); } } function generateRRuleStarts( component: ParsedComponent, rrule: ParsedProperty, bounds: RecurrenceBounds, window?: CalendarTimeRange, ): number[] { const base = recurrenceBase(component); if (!base) return []; const rule = parseRRule(rrule.value); if (!rule.FREQ) throw new Error("RRULE must include FREQ"); const interval = rule.INTERVAL ? Number.parseInt(rule.INTERVAL, 10) : 1; if (!Number.isInteger(interval) || interval <= 0) throw new Error("RRULE INTERVAL must be positive"); const count = rule.COUNT ? Number.parseInt(rule.COUNT, 10) : null; if (count !== null && (!Number.isInteger(count) || count <= 0)) throw new Error("RRULE COUNT must be positive"); if (count !== null && count > bounds.maxInstances) throw new Error("RRULE exceeds configured maximum instances"); const until = rule.UNTIL ? parseDateValue( { name: "UNTIL", params: {}, value: rule.UNTIL, rawLine: rule.UNTIL }, { floatingTimeZone: component.floatingTimeZone }, ) : null; const unbounded = count === null && until === null; const startDate = new Date(base.ms); const maxDate = new Date(base.ms); maxDate.setUTCFullYear(maxDate.getUTCFullYear() + bounds.maxYears); const scanEnd = unbounded && window ? (window.endMs ?? (window.startMs === null ? maxDate.getTime() : window.startMs + 366 * dayMs)) : Math.min(maxDate.getTime(), until?.ms ?? maxDate.getTime()); const scanStart = unbounded && window?.startMs !== null && window?.startMs !== undefined ? Math.max( startOfUtcDay(base.ms), startOfUtcDay(window.startMs - Math.max(componentDurationMs(component), dayMs)), ) : startOfUtcDay(base.ms); const time = { hour: startDate.getUTCHours(), minute: startDate.getUTCMinutes(), second: startDate.getUTCSeconds(), }; const starts: number[] = []; for (let cursor = scanStart; cursor <= scanEnd; cursor += dayMs) { const date = new Date(cursor); if (!ruleDateMatches(rule, startDate, date, interval)) continue; const candidate = Date.UTC( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), time.hour, time.minute, time.second, ); if (candidate < base.ms || (until && candidate > until.ms)) continue; starts.push(candidate); if (starts.length > bounds.maxInstances) throw new Error("RRULE exceeds configured maximum instances"); if (count !== null && starts.length >= count) break; } return starts; } function recurrenceStarts(component: ParsedComponent, bounds: RecurrenceBounds, window?: CalendarTimeRange): number[] { const base = recurrenceBase(component); if (!base) return []; const rrules = propertiesByName(component.properties, "RRULE"); const included = new Set(rrules.length === 0 ? [base.ms] : []); for (const rrule of rrules) { for (const start of generateRRuleStarts(component, rrule, bounds, window)) included.add(start); } for (const rdate of propertiesByName(component.properties, "RDATE")) { for (const value of parseDateList(rdate, { floatingTimeZone: component.floatingTimeZone })) included.add(value.ms); } const excluded = new Set(); for (const exdate of propertiesByName(component.properties, "EXDATE")) { for (const value of parseDateList(exdate, { floatingTimeZone: component.floatingTimeZone })) excluded.add(value.ms); } const starts = [...included].filter((value) => !excluded.has(value)).sort((a, b) => a - b); if (starts.length > bounds.maxInstances) throw new Error("RRULE exceeds configured maximum instances"); return starts; } function occurrenceFor( component: ParsedComponent, originalStartMs: number, fallbackDurationMs: number, isInitial: boolean, shiftRecurrenceId = false, ): CalendarOccurrence { const anchor = recurrenceBase(component); const deltaMs = component.recurrenceId && !shiftRecurrenceId ? 0 : anchor ? originalStartMs - anchor.ms : 0; const startMs = (component.dtstart?.ms ?? component.due?.ms ?? component.recurrenceId?.ms ?? originalStartMs) + deltaMs; const duration = componentDurationMs(component) || fallbackDurationMs; const endMs = component.componentType === "VTODO" && component.due ? component.due.ms + deltaMs : startMs + Math.max(0, duration); return { component, originalStartMs, startMs, endMs, isInitial }; } export function calendarOccurrences( document: ParsedCalendarDocument, bounds: RecurrenceBounds, window?: CalendarTimeRange, ): CalendarOccurrence[] { const occurrences: CalendarOccurrence[] = []; const overridesByOriginalStart = new Map(); const rangeOverrides: ParsedComponent[] = []; for (const override of document.overrides) { if (override.recurrenceId) overridesByOriginalStart.set(override.recurrenceId.ms, override); if (firstProperty(override.properties, "RECURRENCE-ID")?.params.RANGE?.toUpperCase() === "THISANDFUTURE") { rangeOverrides.push(override); } } if (document.master) { const base = recurrenceBase(document.master); const fallbackDuration = componentDurationMs(document.master); for (const originalStartMs of recurrenceStarts(document.master, bounds, window)) { const exactOverride = overridesByOriginalStart.get(originalStartMs); const rangeOverride = exactOverride ?? rangeOverrides .filter((override) => override.recurrenceId && override.recurrenceId.ms <= originalStartMs) .sort((a, b) => b.recurrenceId!.ms - a.recurrenceId!.ms)[0]; const override = exactOverride ?? rangeOverride; if (override && isCancelled(override)) continue; occurrences.push( occurrenceFor( override ?? document.master, originalStartMs, fallbackDuration, base?.ms === originalStartMs, Boolean(!exactOverride && rangeOverride), ), ); } } const generatedOriginals = new Set(occurrences.map((occurrence) => occurrence.originalStartMs)); for (const override of document.overrides) { if (isCancelled(override)) continue; const originalStartMs = override.recurrenceId?.ms ?? recurrenceBase(override)?.ms; if (originalStartMs === undefined || generatedOriginals.has(originalStartMs)) continue; occurrences.push(occurrenceFor(override, originalStartMs, componentDurationMs(override), false)); } if (!document.master && occurrences.length === 0) { for (const component of document.components) { const base = recurrenceBase(component); if (base) occurrences.push(occurrenceFor(component, base.ms, componentDurationMs(component), true)); } } return occurrences.sort((a, b) => a.startMs - b.startMs); } export function occurrenceOverlaps( document: ParsedCalendarDocument, occurrence: CalendarOccurrence, range: CalendarTimeRange, ): boolean { if (occurrence.component === document.master) { const base = recurrenceBase(occurrence.component); const deltaMs = base ? occurrence.originalStartMs - base.ms : 0; return componentOverlaps(occurrence.component, range, deltaMs); } return componentOverlaps(occurrence.component, range); } export function matchesTimeRange( document: ParsedCalendarDocument, range: CalendarTimeRange, bounds: RecurrenceBounds, ): boolean { for (const occurrence of calendarOccurrences(document, bounds, range)) { if (occurrenceOverlaps(document, occurrence, range)) return true; } return false; } export function recurrenceSummary(document: ParsedCalendarDocument, bounds: RecurrenceBounds) { const occurrences = calendarOccurrences(document, bounds); const hasRecurrence = documentHasRecurrence(document.components); if (occurrences.length === 0) return { hasRecurrence, minMs: null, maxMs: null }; return { hasRecurrence, minMs: Math.min(...occurrences.map((occurrence) => occurrence.startMs)), maxMs: Math.max(...occurrences.map((occurrence) => occurrence.endMs)), }; }