diff --git a/packages/aris-source-caldav/fixtures/daily-recurring-allday.ics b/packages/aris-source-caldav/fixtures/daily-recurring-allday.ics new file mode 100644 index 0000000..d375add --- /dev/null +++ b/packages/aris-source-caldav/fixtures/daily-recurring-allday.ics @@ -0,0 +1,12 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:daily-allday-001@test +DTSTART;VALUE=DATE:20260112 +DTEND;VALUE=DATE:20260113 +SUMMARY:Daily Reminder +RRULE:FREQ=DAILY;COUNT=7 +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/fixtures/weekly-recurring-with-exception.ics b/packages/aris-source-caldav/fixtures/weekly-recurring-with-exception.ics new file mode 100644 index 0000000..95b4826 --- /dev/null +++ b/packages/aris-source-caldav/fixtures/weekly-recurring-with-exception.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:weekly-exc-001@test +DTSTART:20260101T140000Z +DTEND:20260101T150000Z +SUMMARY:Standup +RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=8 +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +UID:weekly-exc-001@test +RECURRENCE-ID:20260115T140000Z +DTSTART:20260115T160000Z +DTEND:20260115T170000Z +SUMMARY:Standup (rescheduled) +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/fixtures/weekly-recurring.ics b/packages/aris-source-caldav/fixtures/weekly-recurring.ics new file mode 100644 index 0000000..91ef552 --- /dev/null +++ b/packages/aris-source-caldav/fixtures/weekly-recurring.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:weekly-001@test +DTSTART:20260101T100000Z +DTEND:20260101T110000Z +SUMMARY:Weekly Team Meeting +RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10 +LOCATION:Room B +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/scripts/test-live.ts b/packages/aris-source-caldav/scripts/test-live.ts index c2b1617..3632210 100644 --- a/packages/aris-source-caldav/scripts/test-live.ts +++ b/packages/aris-source-caldav/scripts/test-live.ts @@ -3,8 +3,13 @@ * * Usage: * bun run test-live.ts + * + * Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection. */ +import { mkdirSync, writeFileSync } from "node:fs" +import { join } from "node:path" + import { Context } from "@aris/core" import { CalDavSource } from "../src/index.ts" @@ -51,6 +56,9 @@ for (const item of items) { console.log(` Status: ${item.data.status ?? "(none)"}`) console.log(` Urgency: ${item.signals?.urgency}`) console.log(` Relevance: ${item.signals?.timeRelevance}`) + if (item.slots) { + console.log(` Slots: ${Object.keys(item.slots).join(", ")}`) + } if (item.data.attendees.length > 0) { console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`) } @@ -62,3 +70,11 @@ for (const item of items) { if (items.length === 0) { console.log("(no events found in the time window)") } + +// Write feed items to .cache for slot testing +const cacheDir = join(import.meta.dir, ".cache") +mkdirSync(cacheDir, { recursive: true }) + +const outPath = join(cacheDir, "feed-items.json") +writeFileSync(outPath, JSON.stringify(items, null, 2)) +console.log(`\nFeed items written to ${outPath}`) diff --git a/packages/aris-source-caldav/src/caldav-source.test.ts b/packages/aris-source-caldav/src/caldav-source.test.ts index d5c70e7..968a3a7 100644 --- a/packages/aris-source-caldav/src/caldav-source.test.ts +++ b/packages/aris-source-caldav/src/caldav-source.test.ts @@ -208,7 +208,7 @@ describe("CalDavSource", () => { expect(items[0]!.data.calendarName).toBeNull() }) - test("handles recurring events with exceptions", async () => { + test("expands recurring events within the time range", async () => { const objects: Record = { "/cal/work": [ { @@ -218,21 +218,42 @@ describe("CalDavSource", () => { ], } const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + // lookAheadDays=0 → range is Jan 15 only const source = createSource(client) const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z"))) - expect(items).toHaveLength(2) + // Only the Jan 15 occurrence falls in the single-day window + expect(items).toHaveLength(1) + expect(items[0]!.data.title).toBe("Weekly Sync") + expect(items[0]!.data.startDate).toEqual(new Date("2026-01-15T09:00:00Z")) + }) - const base = items.find((i) => i.data.title === "Weekly Sync") + test("includes exception overrides when they fall in range", async () => { + const objects: Record = { + "/cal/work": [ + { + url: "/cal/work/recurring.ics", + data: loadFixture("recurring-event.ics"), + }, + ], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + // lookAheadDays=8 → range covers Jan 15 through Jan 23, includes the Jan 22 exception + const source = createSource(client, 8) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z"))) + + const base = items.filter((i) => i.data.title === "Weekly Sync") const exception = items.find((i) => i.data.title === "Weekly Sync (moved)") - expect(base).toBeDefined() - expect(base!.data.recurrenceId).toBeNull() + // Jan 15 base occurrence + expect(base.length).toBeGreaterThanOrEqual(1) + // Jan 22 exception replaces the base occurrence expect(exception).toBeDefined() - expect(exception!.data.recurrenceId).not.toBeNull() - expect(exception!.id).toContain("-") + expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z")) + expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z")) }) test("caches events within the same refresh cycle", async () => { @@ -512,3 +533,5 @@ describe("computeSignals", () => { expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2) }) }) + + diff --git a/packages/aris-source-caldav/src/caldav-source.ts b/packages/aris-source-caldav/src/caldav-source.ts index 9020680..6736137 100644 --- a/packages/aris-source-caldav/src/caldav-source.ts +++ b/packages/aris-source-caldav/src/caldav-source.ts @@ -184,7 +184,7 @@ export class CalDavSource implements FeedSource { for (const obj of objects) { if (typeof obj.data !== "string") continue - const events = parseICalEvents(obj.data, calendarName) + const events = parseICalEvents(obj.data, calendarName, { start, end }) for (const event of events) { allEvents.push(event) } diff --git a/packages/aris-source-caldav/src/ical-parser.test.ts b/packages/aris-source-caldav/src/ical-parser.test.ts index 13d2b9e..60602f9 100644 --- a/packages/aris-source-caldav/src/ical-parser.test.ts +++ b/packages/aris-source-caldav/src/ical-parser.test.ts @@ -105,3 +105,94 @@ describe("parseICalEvents", () => { expect(events[0]!.status).toBe("cancelled") }) }) + +describe("parseICalEvents with timeRange (recurrence expansion)", () => { + test("expands weekly recurring event into occurrences within range", () => { + // weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10 + // Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5 + // Query window: Jan 14 – Jan 23 → should get Jan 15 and Jan 22 + const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", { + start: new Date("2026-01-14T00:00:00Z"), + end: new Date("2026-01-23T00:00:00Z"), + }) + + expect(events).toHaveLength(2) + expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z")) + expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z")) + expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z")) + expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z")) + + // All occurrences share the same UID and metadata + for (const event of events) { + expect(event.uid).toBe("weekly-001@test") + expect(event.title).toBe("Weekly Team Meeting") + expect(event.location).toBe("Room B") + expect(event.calendarName).toBe("Work") + } + }) + + test("returns empty array when no occurrences fall in range", () => { + // Query window: Dec 2025 — before the first occurrence + const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, { + start: new Date("2025-12-01T00:00:00Z"), + end: new Date("2025-12-31T00:00:00Z"), + }) + + expect(events).toHaveLength(0) + }) + + test("applies exception overrides during expansion", () => { + // weekly-recurring-with-exception.ics: + // Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8 + // Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed + // Query window: Jan 14 – Jan 16 → should get the exception occurrence for Jan 15 + const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", { + start: new Date("2026-01-14T00:00:00Z"), + end: new Date("2026-01-16T00:00:00Z"), + }) + + expect(events).toHaveLength(1) + expect(events[0]!.title).toBe("Standup (rescheduled)") + expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z")) + expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z")) + }) + + test("expands recurring all-day events", () => { + // daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7 + // Occurrences: Jan 12, 13, 14, 15, 16, 17, 18 + // Query window: Jan 14 – Jan 17 → should get Jan 14, 15, 16 + const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, { + start: new Date("2026-01-14T00:00:00Z"), + end: new Date("2026-01-17T00:00:00Z"), + }) + + expect(events).toHaveLength(3) + for (const event of events) { + expect(event.isAllDay).toBe(true) + expect(event.title).toBe("Daily Reminder") + } + }) + + test("non-recurring events are filtered by range", () => { + // single-event.ics: 2026-01-15T14:00 – 15:00 + // Query window that includes it + const included = parseICalEvents(loadFixture("single-event.ics"), null, { + start: new Date("2026-01-15T00:00:00Z"), + end: new Date("2026-01-16T00:00:00Z"), + }) + expect(included).toHaveLength(1) + + // Query window that excludes it + const excluded = parseICalEvents(loadFixture("single-event.ics"), null, { + start: new Date("2026-01-16T00:00:00Z"), + end: new Date("2026-01-17T00:00:00Z"), + }) + expect(excluded).toHaveLength(0) + }) + + test("without timeRange, recurring events return raw VEVENTs (legacy)", () => { + // Legacy behavior: no expansion, just returns the VEVENT components as-is + const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team") + expect(events).toHaveLength(2) + }) +}) diff --git a/packages/aris-source-caldav/src/ical-parser.ts b/packages/aris-source-caldav/src/ical-parser.ts index 218301b..67e16e8 100644 --- a/packages/aris-source-caldav/src/ical-parser.ts +++ b/packages/aris-source-caldav/src/ical-parser.ts @@ -9,21 +9,191 @@ import { type CalDavEventData, } from "./types.ts" +export interface ICalTimeRange { + start: Date + end: Date +} + /** - * Parses a raw iCalendar string and extracts all VEVENT components + * Safety cap to prevent runaway iteration on pathological recurrence rules. + * Each iteration is pure date math (no I/O), so a high cap is fine. + * 10,000 covers a daily event with DTSTART ~27 years in the past. + */ +const MAX_RECURRENCE_ITERATIONS = 10_000 + +/** + * Parses a raw iCalendar string and extracts VEVENT components * into CalDavEventData objects. * + * When a timeRange is provided, recurring events are expanded into + * individual occurrences within that range. Without a timeRange, + * each VEVENT component is returned as-is (legacy behavior). + * * @param icsData - Raw iCalendar string from a CalDAV response * @param calendarName - Display name of the calendar this event belongs to + * @param timeRange - When set, expand recurrences and filter to this window */ -export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] { +export function parseICalEvents( + icsData: string, + calendarName: string | null, + timeRange?: ICalTimeRange, +): CalDavEventData[] { const jcal = ICAL.parse(icsData) const comp = new ICAL.Component(jcal) const vevents = comp.getAllSubcomponents("vevent") - return vevents.map((vevent: InstanceType) => - parseVEvent(vevent, calendarName), - ) + if (!timeRange) { + return vevents.map((vevent: InstanceType) => + parseVEvent(vevent, calendarName), + ) + } + + // Group VEVENTs by UID: master + exceptions + const byUid = new Map< + string, + { + master: InstanceType | null + exceptions: InstanceType[] + } + >() + + for (const vevent of vevents as InstanceType[]) { + const uid = vevent.getFirstPropertyValue("uid") as string | null + if (!uid) continue + + const hasRecurrenceId = vevent.getFirstPropertyValue("recurrence-id") !== null + let group = byUid.get(uid) + if (!group) { + group = { master: null, exceptions: [] } + byUid.set(uid, group) + } + + if (hasRecurrenceId) { + group.exceptions.push(vevent) + } else { + group.master = vevent + } + } + + const results: CalDavEventData[] = [] + const rangeStart = ICAL.Time.fromJSDate(timeRange.start, true) + const rangeEnd = ICAL.Time.fromJSDate(timeRange.end, true) + + for (const group of byUid.values()) { + if (!group.master) { + // Orphan exceptions — parse them directly if they fall in range + for (const exc of group.exceptions) { + const parsed = parseVEvent(exc, calendarName) + if (overlapsRange(parsed, timeRange)) { + results.push(parsed) + } + } + continue + } + + const masterEvent = new ICAL.Event(group.master) + + // Register exceptions so getOccurrenceDetails resolves them + for (const exc of group.exceptions) { + masterEvent.relateException(exc) + } + + if (!masterEvent.isRecurring()) { + const parsed = parseVEvent(group.master, calendarName) + if (overlapsRange(parsed, timeRange)) { + results.push(parsed) + } + // Also include standalone exceptions for non-recurring events + for (const exc of group.exceptions) { + const parsedExc = parseVEvent(exc, calendarName) + if (overlapsRange(parsedExc, timeRange)) { + results.push(parsedExc) + } + } + continue + } + + // Expand recurring event occurrences within the time range. + // The iterator must start from DTSTART (not rangeStart) because + // ical.js needs to walk the recurrence rule grid from the original + // anchor. We cap iterations to avoid runaway expansion on + // pathological rules. + const iter = masterEvent.iterator() + let next: InstanceType | null = iter.next() + let iterations = 0 + + while (next) { + if (++iterations > MAX_RECURRENCE_ITERATIONS) { + console.warn( + `[aris.caldav] Recurrence expansion for "${masterEvent.uid}" hit iteration limit (${MAX_RECURRENCE_ITERATIONS}), stopping`, + ) + break + } + + // Stop once we're past the range end + if (next.compare(rangeEnd) >= 0) break + + const details = masterEvent.getOccurrenceDetails(next) + const occEnd = details.endDate + + // Skip occurrences that end before the range starts + if (occEnd.compare(rangeStart) <= 0) { + next = iter.next() + continue + } + + const occEvent = details.item + const occComponent = occEvent.component + + const parsed = parseVEventWithDates( + occComponent, + calendarName, + details.startDate.toJSDate(), + details.endDate.toJSDate(), + details.recurrenceId ? details.recurrenceId.toString() : null, + ) + results.push(parsed) + + next = iter.next() + } + } + + return results +} + +function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean { + return event.startDate < range.end && event.endDate > range.start +} + +/** + * Parse a VEVENT component, overriding start/end/recurrenceId with + * values from recurrence expansion. + */ +function parseVEventWithDates( + vevent: InstanceType, + calendarName: string | null, + startDate: Date, + endDate: Date, + recurrenceId: string | null, +): CalDavEventData { + const event = new ICAL.Event(vevent) + + return { + uid: event.uid ?? "", + title: event.summary ?? "", + startDate, + endDate, + isAllDay: event.startDate?.isDate ?? false, + location: event.location ?? null, + description: event.description ?? null, + calendarName, + status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))), + url: asStringOrNull(vevent.getFirstPropertyValue("url")), + organizer: parseOrganizer(asStringOrNull(event.organizer), vevent), + attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []), + alarms: parseAlarms(vevent), + recurrenceId, + } } function parseVEvent( diff --git a/packages/aris-source-caldav/src/index.ts b/packages/aris-source-caldav/src/index.ts index 2ede542..2059e5a 100644 --- a/packages/aris-source-caldav/src/index.ts +++ b/packages/aris-source-caldav/src/index.ts @@ -1,6 +1,6 @@ export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts" -export { parseICalEvents } from "./ical-parser.ts" +export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts" export { AttendeeRole, AttendeeStatus,