File
Blob: src/worker/caldav/ical/recurrence.ts
| 1 | import { |
| 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"; |
| 13 | import type { |
| 14 | CalendarOccurrence, |
| 15 | CalendarTimeRange, |
| 16 | ParsedCalendarDocument, |
| 17 | ParsedComponent, |
| 18 | ParsedProperty, |
| 19 | RecurrenceBounds, |
| 20 | } from "@/worker/caldav/ical/types"; |
| 21 | |
| 22 | function shifted(value: { ms: number } | null, deltaMs: number): number | null { |
| 23 | return value ? value.ms + deltaMs : null; |
| 24 | } |
| 25 | |
| 26 | function rangeStart(range: CalendarTimeRange): number { |
| 27 | return range.startMs ?? Number.NEGATIVE_INFINITY; |
| 28 | } |
| 29 | |
| 30 | function rangeEnd(range: CalendarTimeRange): number { |
| 31 | return range.endMs ?? Number.POSITIVE_INFINITY; |
| 32 | } |
| 33 | |
| 34 | function 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 | |
| 75 | function 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 | |
| 89 | function 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 | |
| 97 | function 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 | |
| 109 | function startOfUtcDay(value: number): number { |
| 110 | const date = new Date(value); |
| 111 | return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()); |
| 112 | } |
| 113 | |
| 114 | function daysInMonth(year: number, month: number): number { |
| 115 | return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); |
| 116 | } |
| 117 | |
| 118 | function monthDiff(start: Date, date: Date): number { |
| 119 | return (date.getUTCFullYear() - start.getUTCFullYear()) * 12 + date.getUTCMonth() - start.getUTCMonth(); |
| 120 | } |
| 121 | |
| 122 | function 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 | |
| 129 | function 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 | |
| 135 | function 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 | |
| 148 | function 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 | |
| 192 | function 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 | |
| 255 | function 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 | |
| 275 | function 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 | |
| 292 | export 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 | |
| 349 | export 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 | |
| 362 | export 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 | |
| 373 | export 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 | } |