From 40ad90aa2d443bc77d4d84bd6aab33e13c7ab8fe Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 28 Feb 2026 16:09:11 +0000 Subject: [PATCH] feat: add generic CalDAV calendar data source (#42) * feat: add generic CalDAV calendar data source Add @aris/source-caldav package that fetches calendar events from any CalDAV server via tsdav + ical.js. - Supports Basic auth and OAuth via explicit authMethod discriminant - serverUrl provided at construction time, not hardcoded - Optional timeZone for correct local day boundaries - Credentials cleared from memory after client login - Failed calendar fetches logged, not silently dropped - Login promise cached with retry on failure Co-authored-by: Ona * fix: deduplicate concurrent fetchEvents calls Co-authored-by: Ona * fix: timezone-aware signals, low-priority cancelled events - computeSignals uses startOfDay(timeZone) for 'later today' boundary - Cancelled events get urgency 0.1, excluded from context inProgress/nextEvent Co-authored-by: Ona --------- Co-authored-by: Ona --- bun.lock | 25 +- packages/aris-source-caldav/README.md | 58 ++ .../fixtures/all-day-event.ics | 11 + .../fixtures/cancelled-event.ics | 11 + .../fixtures/minimal-event.ics | 10 + .../fixtures/recurring-event.ics | 20 + .../fixtures/single-event.ics | 26 + packages/aris-source-caldav/package.json | 16 + .../aris-source-caldav/scripts/test-live.ts | 62 +++ .../src/caldav-source.test.ts | 507 ++++++++++++++++++ .../aris-source-caldav/src/caldav-source.ts | 348 ++++++++++++ .../src/calendar-context.ts | 24 + .../src/ical-parser.test.ts | 107 ++++ .../aris-source-caldav/src/ical-parser.ts | 153 ++++++ packages/aris-source-caldav/src/index.ts | 15 + packages/aris-source-caldav/src/types.ts | 93 ++++ 16 files changed, 1485 insertions(+), 1 deletion(-) create mode 100644 packages/aris-source-caldav/README.md create mode 100644 packages/aris-source-caldav/fixtures/all-day-event.ics create mode 100644 packages/aris-source-caldav/fixtures/cancelled-event.ics create mode 100644 packages/aris-source-caldav/fixtures/minimal-event.ics create mode 100644 packages/aris-source-caldav/fixtures/recurring-event.ics create mode 100644 packages/aris-source-caldav/fixtures/single-event.ics create mode 100644 packages/aris-source-caldav/package.json create mode 100644 packages/aris-source-caldav/scripts/test-live.ts create mode 100644 packages/aris-source-caldav/src/caldav-source.test.ts create mode 100644 packages/aris-source-caldav/src/caldav-source.ts create mode 100644 packages/aris-source-caldav/src/calendar-context.ts create mode 100644 packages/aris-source-caldav/src/ical-parser.test.ts create mode 100644 packages/aris-source-caldav/src/ical-parser.ts create mode 100644 packages/aris-source-caldav/src/index.ts create mode 100644 packages/aris-source-caldav/src/types.ts diff --git a/bun.lock b/bun.lock index 093e0ed..8edf71e 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,15 @@ "arktype": "^2.1.0", }, }, + "packages/aris-source-caldav": { + "name": "@aris/source-caldav", + "version": "0.0.0", + "dependencies": { + "@aris/core": "workspace:*", + "ical.js": "^2.1.0", + "tsdav": "^2.1.7", + }, + }, "packages/aris-source-google-calendar": { "name": "@aris/source-google-calendar", "version": "0.0.0", @@ -135,6 +144,8 @@ "@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"], + "@aris/source-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"], + "@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"], "@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"], @@ -899,6 +910,8 @@ "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], @@ -1011,7 +1024,7 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], - "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1389,6 +1402,8 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], + "ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.0", "", {}, "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg=="], @@ -2163,6 +2178,8 @@ "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + "tsdav": ["tsdav@2.1.8", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-zvQvhZLzTaEmNNgJbBlUYT/JOq9Xpw/xkxCqs7IT2d2/7o7pss0iZOlZXuHJ5VcvSvTny42Vc6+6GyzZcrCJ1g=="], + "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -2285,6 +2302,8 @@ "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="], "xmlbuilder": ["xmlbuilder@14.0.0", "", {}, "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg=="], @@ -2613,6 +2632,8 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "fbjs/cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + "fbjs/promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -2939,6 +2960,8 @@ "expo/@expo/config-plugins/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "fbjs/cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], diff --git a/packages/aris-source-caldav/README.md b/packages/aris-source-caldav/README.md new file mode 100644 index 0000000..275ce0a --- /dev/null +++ b/packages/aris-source-caldav/README.md @@ -0,0 +1,58 @@ +# @aris/source-caldav + +A FeedSource that fetches calendar events from any CalDAV server. + +## Usage + +```ts +import { CalDavSource } from "@aris/source-caldav" + +// Basic auth (Nextcloud, Radicale, Baikal, iCloud, etc.) +const source = new CalDavSource({ + serverUrl: "https://caldav.example.com", + authMethod: "basic", + username: "user", + password: "pass", + lookAheadDays: 7, // optional, default: 0 (today only) + timeZone: "America/New_York", // optional, default: UTC +}) + +// OAuth +const source = new CalDavSource({ + serverUrl: "https://caldav.provider.com", + authMethod: "oauth", + accessToken: "...", + refreshToken: "...", + tokenUrl: "https://provider.com/oauth/token", +}) +``` + +### iCloud + +Use your Apple ID email as the username and an [app-specific password](https://support.apple.com/en-us/102654): + +```ts +const source = new CalDavSource({ + serverUrl: "https://caldav.icloud.com", + authMethod: "basic", + username: "you@icloud.com", + password: "", +}) +``` + +## Testing + +```bash +bun test +``` + +### Live test + +`bun run test:live` connects to a real CalDAV server and prints all events to the console. It prompts for: + +- **CalDAV server URL** — e.g. `https://caldav.icloud.com` +- **Username** — your account email +- **Password** — your password (or app-specific password for iCloud) +- **Look-ahead days** — how many days beyond today to fetch (default: 0) + +The script runs both `fetchContext` and `fetchItems`, printing the calendar context (in-progress events, next event, today's count) followed by each event with its title, time, location, signals, and attendees. diff --git a/packages/aris-source-caldav/fixtures/all-day-event.ics b/packages/aris-source-caldav/fixtures/all-day-event.ics new file mode 100644 index 0000000..6d99fce --- /dev/null +++ b/packages/aris-source-caldav/fixtures/all-day-event.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:all-day-001@test +DTSTART;VALUE=DATE:20260115 +DTEND;VALUE=DATE:20260116 +SUMMARY:Company Holiday +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/fixtures/cancelled-event.ics b/packages/aris-source-caldav/fixtures/cancelled-event.ics new file mode 100644 index 0000000..2b099ae --- /dev/null +++ b/packages/aris-source-caldav/fixtures/cancelled-event.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:cancelled-001@test +DTSTART:20260115T120000Z +DTEND:20260115T130000Z +SUMMARY:Cancelled Meeting +STATUS:CANCELLED +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/fixtures/minimal-event.ics b/packages/aris-source-caldav/fixtures/minimal-event.ics new file mode 100644 index 0000000..20605f2 --- /dev/null +++ b/packages/aris-source-caldav/fixtures/minimal-event.ics @@ -0,0 +1,10 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:minimal-001@test +DTSTART:20260115T180000Z +DTEND:20260115T190000Z +SUMMARY:Quick Chat +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/fixtures/recurring-event.ics b/packages/aris-source-caldav/fixtures/recurring-event.ics new file mode 100644 index 0000000..98d67e6 --- /dev/null +++ b/packages/aris-source-caldav/fixtures/recurring-event.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:recurring-001@test +DTSTART:20260115T090000Z +DTEND:20260115T093000Z +SUMMARY:Weekly Sync +RRULE:FREQ=WEEKLY;COUNT=4 +STATUS:CONFIRMED +END:VEVENT +BEGIN:VEVENT +UID:recurring-001@test +RECURRENCE-ID:20260122T090000Z +DTSTART:20260122T100000Z +DTEND:20260122T103000Z +SUMMARY:Weekly Sync (moved) +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/fixtures/single-event.ics b/packages/aris-source-caldav/fixtures/single-event.ics new file mode 100644 index 0000000..01d3546 --- /dev/null +++ b/packages/aris-source-caldav/fixtures/single-event.ics @@ -0,0 +1,26 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:single-event-001@test +DTSTART:20260115T140000Z +DTEND:20260115T150000Z +SUMMARY:Team Standup +LOCATION:Conference Room A +DESCRIPTION:Daily standup meeting +STATUS:CONFIRMED +URL:https://example.com/meeting/123 +ORGANIZER;CN=Alice Smith:mailto:alice@example.com +ATTENDEE;CN=Bob Jones;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@example.com +ATTENDEE;CN=Carol White;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:carol@example.com +BEGIN:VALARM +TRIGGER:-PT15M +ACTION:DISPLAY +DESCRIPTION:Reminder +END:VALARM +BEGIN:VALARM +TRIGGER:-PT5M +ACTION:AUDIO +END:VALARM +END:VEVENT +END:VCALENDAR diff --git a/packages/aris-source-caldav/package.json b/packages/aris-source-caldav/package.json new file mode 100644 index 0000000..1e69ffa --- /dev/null +++ b/packages/aris-source-caldav/package.json @@ -0,0 +1,16 @@ +{ + "name": "@aris/source-caldav", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test .", + "test:live": "bun run scripts/test-live.ts" + }, + "dependencies": { + "@aris/core": "workspace:*", + "ical.js": "^2.1.0", + "tsdav": "^2.1.7" + } +} diff --git a/packages/aris-source-caldav/scripts/test-live.ts b/packages/aris-source-caldav/scripts/test-live.ts new file mode 100644 index 0000000..070a987 --- /dev/null +++ b/packages/aris-source-caldav/scripts/test-live.ts @@ -0,0 +1,62 @@ +/** + * Live test script for CalDavSource. + * + * Usage: + * bun run test-live.ts + */ + +import { CalDavSource } from "../src/index.ts" + +const serverUrl = prompt("CalDAV server URL:") +const username = prompt("Username:") +const password = prompt("Password:") +const lookAheadRaw = prompt("Look-ahead days (default 0):") + +if (!serverUrl || !username || !password) { + console.error("Server URL, username, and password are required.") + process.exit(1) +} + +const lookAheadDays = Number(lookAheadRaw) || 0 + +const source = new CalDavSource({ + serverUrl, + authMethod: "basic", + username, + password, + lookAheadDays, +}) + +const context = { time: new Date() } + +console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`) + +const contextResult = await source.fetchContext(context) +const items = await source.fetchItems(context) + +console.log("=== Context ===") +console.log(JSON.stringify(contextResult, null, 2)) + +console.log(`\n=== Feed Items (${items.length}) ===`) +for (const item of items) { + console.log(`\n--- ${item.data.title} ---`) + console.log(` ID: ${item.id}`) + console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`) + console.log(` Start: ${item.data.startDate.toISOString()}`) + console.log(` End: ${item.data.endDate.toISOString()}`) + console.log(` All-day: ${item.data.isAllDay}`) + console.log(` Location: ${item.data.location ?? "(none)"}`) + console.log(` Status: ${item.data.status ?? "(none)"}`) + console.log(` Urgency: ${item.signals?.urgency}`) + console.log(` Relevance: ${item.signals?.timeRelevance}`) + if (item.data.attendees.length > 0) { + console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`) + } + if (item.data.description) { + console.log(` Desc: ${item.data.description.slice(0, 100)}`) + } +} + +if (items.length === 0) { + console.log("(no events found in the time window)") +} diff --git a/packages/aris-source-caldav/src/caldav-source.test.ts b/packages/aris-source-caldav/src/caldav-source.test.ts new file mode 100644 index 0000000..130417e --- /dev/null +++ b/packages/aris-source-caldav/src/caldav-source.test.ts @@ -0,0 +1,507 @@ +import type { Context } from "@aris/core" + +import { TimeRelevance, contextValue } from "@aris/core" +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import type { + CalDavDAVCalendar, + CalDavDAVClient, + CalDavDAVObject, + CalDavEventData, +} from "./types.ts" + +import { CalDavSource, computeSignals } from "./caldav-source.ts" +import { CalDavCalendarKey } from "./calendar-context.ts" + +function loadFixture(name: string): string { + return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") +} + +function createContext(time: Date): Context { + return { time } +} + +class MockDAVClient implements CalDavDAVClient { + credentials: Record = {} + fetchCalendarsCallCount = 0 + lastTimeRange: { start: string; end: string } | null = null + private calendars: CalDavDAVCalendar[] + private objectsByCalendarUrl: Record + + constructor( + calendars: CalDavDAVCalendar[], + objectsByCalendarUrl: Record, + ) { + this.calendars = calendars + this.objectsByCalendarUrl = objectsByCalendarUrl + } + + async login(): Promise {} + + async fetchCalendars(): Promise { + this.fetchCalendarsCallCount++ + return this.calendars + } + + async fetchCalendarObjects(params: { + calendar: CalDavDAVCalendar + timeRange: { start: string; end: string } + }): Promise { + this.lastTimeRange = params.timeRange + return this.objectsByCalendarUrl[params.calendar.url] ?? [] + } +} + +function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource { + return new CalDavSource({ + serverUrl: "https://caldav.example.com", + authMethod: "basic", + username: "user", + password: "pass", + davClient: client, + lookAheadDays, + }) +} + +describe("CalDavSource", () => { + test("has correct id", () => { + const client = new MockDAVClient([], {}) + const source = createSource(client) + expect(source.id).toBe("aris.caldav") + }) + + test("returns empty array when no calendars exist", async () => { + const client = new MockDAVClient([], {}) + const source = createSource(client) + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + expect(items).toEqual([]) + }) + + test("returns feed items from a single calendar", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + + expect(items).toHaveLength(1) + expect(items[0]!.type).toBe("caldav-event") + expect(items[0]!.id).toBe("caldav-event-single-event-001@test") + expect(items[0]!.data.title).toBe("Team Standup") + expect(items[0]!.data.location).toBe("Conference Room A") + expect(items[0]!.data.calendarName).toBe("Work") + expect(items[0]!.data.attendees).toHaveLength(2) + expect(items[0]!.data.alarms).toHaveLength(2) + }) + + test("returns feed items from multiple calendars", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + "/cal/personal": [ + { + url: "/cal/personal/event2.ics", + data: loadFixture("all-day-event.ics"), + }, + ], + } + const client = new MockDAVClient( + [ + { url: "/cal/work", displayName: "Work" }, + { url: "/cal/personal", displayName: "Personal" }, + ], + objects, + ) + const source = createSource(client) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + + expect(items).toHaveLength(2) + + const standup = items.find((i) => i.data.title === "Team Standup") + const holiday = items.find((i) => i.data.title === "Company Holiday") + + expect(standup).toBeDefined() + expect(standup!.data.calendarName).toBe("Work") + + expect(holiday).toBeDefined() + expect(holiday!.data.calendarName).toBe("Personal") + expect(holiday!.data.isAllDay).toBe(true) + }) + + test("skips objects with non-string data", async () => { + const objects: Record = { + "/cal/work": [ + { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, + { url: "/cal/work/bad.ics", data: 12345 }, + { url: "/cal/work/empty.ics" }, + ], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + expect(items).toHaveLength(1) + expect(items[0]!.data.title).toBe("Team Standup") + }) + + test("uses context time as feed item timestamp", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + const now = new Date("2026-01-15T12:00:00Z") + const items = await source.fetchItems(createContext(now)) + expect(items[0]!.timestamp).toEqual(now) + }) + + test("assigns signals based on event proximity", async () => { + const objects: Record = { + "/cal/work": [ + { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, + { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }, + ], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + // 2 hours before the event at 14:00 + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + + const standup = items.find((i) => i.data.title === "Team Standup") + const holiday = items.find((i) => i.data.title === "Company Holiday") + + expect(standup!.signals!.urgency).toBe(0.7) // within 2 hours + expect(standup!.signals!.timeRelevance).toBe(TimeRelevance.Upcoming) + expect(holiday!.signals!.urgency).toBe(0.3) // all-day + expect(holiday!.signals!.timeRelevance).toBe(TimeRelevance.Ambient) + }) + + test("handles calendar with non-string displayName", async () => { + const objects: Record = { + "/cal/weird": [ + { + url: "/cal/weird/event1.ics", + data: loadFixture("minimal-event.ics"), + }, + ], + } + const client = new MockDAVClient( + [{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }], + objects, + ) + const source = createSource(client) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + expect(items[0]!.data.calendarName).toBeNull() + }) + + test("handles recurring events with exceptions", 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) + const source = createSource(client) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z"))) + + expect(items).toHaveLength(2) + + const base = items.find((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() + + expect(exception).toBeDefined() + expect(exception!.data.recurrenceId).not.toBeNull() + expect(exception!.id).toContain("-") + }) + + test("caches events within the same refresh cycle", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + const context = createContext(new Date("2026-01-15T12:00:00Z")) + + await source.fetchContext(context) + await source.fetchItems(context) + + // Same context.time reference — fetchEvents should only hit the client once + expect(client.fetchCalendarsCallCount).toBe(1) + }) + + test("uses timezone for time range when provided", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + + // 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11) + const source = new CalDavSource({ + serverUrl: "https://caldav.example.com", + authMethod: "basic", + username: "user", + password: "pass", + davClient: client, + timeZone: "Australia/Sydney", + }) + + await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z"))) + + // "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT) + expect(client.lastTimeRange).not.toBeNull() + expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z") + // End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window + expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z") + }) + + test("defaults to UTC midnight when no timezone provided", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z"))) + + expect(client.lastTimeRange).not.toBeNull() + expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z") + expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z") + }) + + test("refetches events for a different context time", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z"))) + + // Different context.time references — should fetch twice + expect(client.fetchCalendarsCallCount).toBe(2) + }) +}) + +describe("CalDavSource.fetchContext", () => { + test("returns empty context when no calendars exist", async () => { + const client = new MockDAVClient([], {}) + const source = createSource(client) + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalDavCalendarKey) + + expect(calendar).toBeDefined() + expect(calendar!.inProgress).toEqual([]) + expect(calendar!.nextEvent).toBeNull() + expect(calendar!.hasTodayEvents).toBe(false) + expect(calendar!.todayEventCount).toBe(0) + }) + + test("identifies in-progress events", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + // 14:30 is during the 14:00-15:00 event + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z"))) + const calendar = contextValue(ctx as Context, CalDavCalendarKey) + + expect(calendar!.inProgress).toHaveLength(1) + expect(calendar!.inProgress[0]!.title).toBe("Team Standup") + }) + + test("identifies next upcoming event", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + // 12:00 is before the 14:00 event + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalDavCalendarKey) + + expect(calendar!.inProgress).toHaveLength(0) + expect(calendar!.nextEvent).not.toBeNull() + expect(calendar!.nextEvent!.title).toBe("Team Standup") + }) + + test("excludes all-day events from inProgress and nextEvent", async () => { + const objects: Record = { + "/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalDavCalendarKey) + + expect(calendar!.inProgress).toHaveLength(0) + expect(calendar!.nextEvent).toBeNull() + expect(calendar!.hasTodayEvents).toBe(true) + expect(calendar!.todayEventCount).toBe(1) + }) + + test("counts all events including all-day in todayEventCount", async () => { + const objects: Record = { + "/cal/work": [ + { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, + { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }, + ], + } + const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) + const source = createSource(client) + + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalDavCalendarKey) + + expect(calendar!.todayEventCount).toBe(2) + expect(calendar!.hasTodayEvents).toBe(true) + }) +}) + +describe("computeSignals", () => { + const now = new Date("2026-01-15T12:00:00Z") + + function makeEvent(overrides: Partial): CalDavEventData { + return { + uid: "test-uid", + title: "Test", + startDate: new Date("2026-01-15T14:00:00Z"), + endDate: new Date("2026-01-15T15:00:00Z"), + isAllDay: false, + location: null, + description: null, + calendarName: null, + status: null, + url: null, + organizer: null, + attendees: [], + alarms: [], + recurrenceId: null, + ...overrides, + } + } + + test("all-day events get urgency 0.3 and ambient relevance", () => { + const event = makeEvent({ isAllDay: true }) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.3) + expect(signals.timeRelevance).toBe(TimeRelevance.Ambient) + }) + + test("events starting within 30 minutes get urgency 0.9 and imminent relevance", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T12:20:00Z"), + }) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.9) + expect(signals.timeRelevance).toBe(TimeRelevance.Imminent) + }) + + test("events starting exactly at 30 minutes get urgency 0.9", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T12:30:00Z"), + }) + expect(computeSignals(event, now).urgency).toBe(0.9) + }) + + test("events starting within 2 hours get urgency 0.7 and upcoming relevance", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T13:00:00Z"), + }) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.7) + expect(signals.timeRelevance).toBe(TimeRelevance.Upcoming) + }) + + test("events later today get urgency 0.5", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T20:00:00Z"), + }) + expect(computeSignals(event, now).urgency).toBe(0.5) + }) + + test("in-progress events get urgency 0.8 and imminent relevance", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T11:00:00Z"), + endDate: new Date("2026-01-15T13:00:00Z"), + }) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.8) + expect(signals.timeRelevance).toBe(TimeRelevance.Imminent) + }) + + test("fully past events get urgency 0.2 and ambient relevance", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T09:00:00Z"), + endDate: new Date("2026-01-15T10:00:00Z"), + }) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.2) + expect(signals.timeRelevance).toBe(TimeRelevance.Ambient) + }) + + test("events on future days get urgency 0.2", () => { + const event = makeEvent({ + startDate: new Date("2026-01-16T10:00:00Z"), + }) + expect(computeSignals(event, now).urgency).toBe(0.2) + }) + + test("urgency boundaries are correct", () => { + // 31 minutes from now should be 0.7 (within 2 hours, not within 30 min) + const event31min = makeEvent({ + startDate: new Date("2026-01-15T12:31:00Z"), + }) + expect(computeSignals(event31min, now).urgency).toBe(0.7) + + // 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours) + const event2h1m = makeEvent({ + startDate: new Date("2026-01-15T14:01:00Z"), + }) + expect(computeSignals(event2h1m, now).urgency).toBe(0.5) + }) + + test("cancelled events get urgency 0.1 regardless of timing", () => { + const event = makeEvent({ + status: "cancelled", + startDate: new Date("2026-01-15T12:20:00Z"), // would be 0.9 if not cancelled + }) + const signals = computeSignals(event, now) + expect(signals.urgency).toBe(0.1) + expect(signals.timeRelevance).toBe(TimeRelevance.Ambient) + }) + + test("uses timezone for 'later today' boundary", () => { + // now = 2026-01-15T12:00:00Z = 2026-01-15T21:00:00 JST (UTC+9) + // event at 2026-01-15T15:30:00Z = 2026-01-16T00:30:00 JST — next day in JST + const event = makeEvent({ + startDate: new Date("2026-01-15T15:30:00Z"), + }) + + // Without timezone: UTC day ends at 2026-01-16T00:00:00Z, event is before that → "later today" + expect(computeSignals(event, now).urgency).toBe(0.5) + + // With Asia/Tokyo: local day ends at 2026-01-15T15:00:00Z (midnight Jan 16 JST), + // event is after that → "future days" + 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 new file mode 100644 index 0000000..442db15 --- /dev/null +++ b/packages/aris-source-caldav/src/caldav-source.ts @@ -0,0 +1,348 @@ +import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core" + +import { TimeRelevance, UnknownActionError } from "@aris/core" +import { DAVClient } from "tsdav" + +import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts" +import { CalDavEventStatus } from "./types.ts" + +import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" +import { parseICalEvents } from "./ical-parser.ts" + +// -- Source options -- + +interface CalDavSourceBaseOptions { + serverUrl: string + /** Number of additional days beyond today to fetch. Default: 0 (today only). */ + lookAheadDays?: number + /** IANA timezone for determining "today" (e.g. "America/New_York"). Default: UTC. */ + timeZone?: string + /** Optional DAV client for testing. */ + davClient?: CalDavDAVClient +} + +interface CalDavSourceBasicAuthOptions extends CalDavSourceBaseOptions { + authMethod: "basic" + username: string + password: string +} + +interface CalDavSourceOAuthOptions extends CalDavSourceBaseOptions { + authMethod: "oauth" + accessToken: string + refreshToken: string + tokenUrl: string + expiration?: number + clientId?: string + clientSecret?: string +} + +export type CalDavSourceOptions = CalDavSourceBasicAuthOptions | CalDavSourceOAuthOptions + +const DEFAULT_LOOK_AHEAD_DAYS = 0 + +/** + * A FeedSource that fetches calendar events from any CalDAV server. + * + * Supports Basic auth (username/password) and OAuth (access token + refresh token). + * The server URL is provided at construction time. + * + * @example + * ```ts + * // Basic auth (self-hosted servers) + * const source = new CalDavSource({ + * serverUrl: "https://nextcloud.example.com/remote.php/dav", + * authMethod: "basic", + * username: "user", + * password: "pass", + * }) + * + * // OAuth (cloud providers) + * const source = new CalDavSource({ + * serverUrl: "https://caldav.provider.com", + * authMethod: "oauth", + * accessToken: "...", + * refreshToken: "...", + * tokenUrl: "https://provider.com/oauth/token", + * }) + * ``` + */ +export class CalDavSource implements FeedSource { + readonly id = "aris.caldav" + + private options: CalDavSourceOptions | null + private readonly lookAheadDays: number + private readonly timeZone: string | undefined + private readonly injectedClient: CalDavDAVClient | null + private clientPromise: Promise | null = null + private cachedEvents: { time: Date; events: CalDavEventData[] } | null = null + private pendingFetch: { time: Date; promise: Promise } | null = null + + constructor(options: CalDavSourceOptions) { + this.options = options + this.lookAheadDays = options.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS + this.timeZone = options.timeZone + this.injectedClient = options.davClient ?? null + } + + async listActions(): Promise> { + return {} + } + + async executeAction(actionId: string): Promise { + throw new UnknownActionError(actionId) + } + + async fetchContext(context: Context): Promise | null> { + const events = await this.fetchEvents(context) + if (events.length === 0) { + return { + [CalDavCalendarKey]: { + inProgress: [], + nextEvent: null, + hasTodayEvents: false, + todayEventCount: 0, + }, + } + } + + const now = context.time + const active = events.filter((e) => e.status !== CalDavEventStatus.Cancelled) + const inProgress = active.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now) + + const upcoming = active + .filter((e) => !e.isAllDay && e.startDate > now) + .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()) + + const calendarContext: CalendarContext = { + inProgress, + nextEvent: upcoming[0] ?? null, + hasTodayEvents: events.length > 0, + todayEventCount: events.length, + } + + return { [CalDavCalendarKey]: calendarContext } + } + + async fetchItems(context: Context): Promise { + const now = context.time + const events = await this.fetchEvents(context) + return events.map((event) => createFeedItem(event, now, this.timeZone)) + } + + private fetchEvents(context: Context): Promise { + if (this.cachedEvents && this.cachedEvents.time === context.time) { + return Promise.resolve(this.cachedEvents.events) + } + + // Deduplicate concurrent fetches for the same context.time reference + if (this.pendingFetch && this.pendingFetch.time === context.time) { + return this.pendingFetch.promise + } + + const promise = this.doFetchEvents(context).finally(() => { + if (this.pendingFetch?.promise === promise) { + this.pendingFetch = null + } + }) + + this.pendingFetch = { time: context.time, promise } + return promise + } + + private async doFetchEvents(context: Context): Promise { + const client = await this.connectClient() + const calendars = await client.fetchCalendars() + + const { start, end } = computeTimeRange(context.time, this.lookAheadDays, this.timeZone) + + const results = await Promise.allSettled( + calendars.map(async (calendar) => { + const objects = await client.fetchCalendarObjects({ + calendar, + timeRange: { + start: start.toISOString(), + end: end.toISOString(), + }, + }) + // tsdav types displayName as string | Record | undefined + const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null + return { objects, calendarName } + }), + ) + + const allEvents: CalDavEventData[] = [] + for (const result of results) { + if (result.status === "rejected") { + console.warn("[aris.caldav] Failed to fetch calendar:", result.reason) + continue + } + const { objects, calendarName } = result.value + for (const obj of objects) { + if (typeof obj.data !== "string") continue + + const events = parseICalEvents(obj.data, calendarName) + for (const event of events) { + allEvents.push(event) + } + } + } + + this.cachedEvents = { time: context.time, events: allEvents } + return allEvents + } + + private connectClient(): Promise { + if (this.injectedClient) { + return Promise.resolve(this.injectedClient) + } + + if (!this.clientPromise) { + this.clientPromise = this.createAndLoginClient().catch((err) => { + this.clientPromise = null + throw err + }) + } + + return this.clientPromise + } + + private async createAndLoginClient(): Promise { + const opts = this.options + if (!opts) { + throw new Error("CalDavSource options have already been consumed") + } + + let client: CalDavDAVClient + + if (opts.authMethod === "basic") { + client = new DAVClient({ + serverUrl: opts.serverUrl, + credentials: { + username: opts.username, + password: opts.password, + }, + authMethod: "Basic", + defaultAccountType: "caldav", + }) + } else { + client = new DAVClient({ + serverUrl: opts.serverUrl, + credentials: { + tokenUrl: opts.tokenUrl, + refreshToken: opts.refreshToken, + accessToken: opts.accessToken, + expiration: opts.expiration, + clientId: opts.clientId, + clientSecret: opts.clientSecret, + }, + authMethod: "Oauth", + defaultAccountType: "caldav", + }) + } + + await client.login() + this.options = null + return client + } +} + +function computeTimeRange( + now: Date, + lookAheadDays: number, + timeZone?: string, +): { start: Date; end: Date } { + const start = startOfDay(now, timeZone) + const end = new Date(start.getTime() + (1 + lookAheadDays) * 24 * 60 * 60 * 1000) + return { start, end } +} + +/** + * Returns midnight (start of day) as a UTC Date. + * When timeZone is provided, "midnight" is local midnight in that timezone + * converted to UTC. Otherwise, UTC midnight. + */ +function startOfDay(date: Date, timeZone?: string): Date { + if (!timeZone) { + const d = new Date(date) + d.setUTCHours(0, 0, 0, 0) + return d + } + + // Extract the local year/month/day in the target timezone + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date) + + const year = Number(parts.find((p) => p.type === "year")!.value) + const month = Number(parts.find((p) => p.type === "month")!.value) + const day = Number(parts.find((p) => p.type === "day")!.value) + + // Binary-search-free approach: construct a UTC date at the local date's noon, + // then use the timezone offset at that moment to find local midnight in UTC. + const noonUtc = Date.UTC(year, month - 1, day, 12, 0, 0) + const noonLocal = new Date(noonUtc).toLocaleString("sv-SE", { timeZone, hour12: false }) + // sv-SE locale formats as "YYYY-MM-DD HH:MM:SS" which Date can parse + const noonLocalMs = new Date(noonLocal + "Z").getTime() + const offsetMs = noonLocalMs - noonUtc + + return new Date(Date.UTC(year, month - 1, day) - offsetMs) +} + +export function computeSignals( + event: CalDavEventData, + now: Date, + timeZone?: string, +): FeedItemSignals { + if (event.status === CalDavEventStatus.Cancelled) { + return { urgency: 0.1, timeRelevance: TimeRelevance.Ambient } + } + + if (event.isAllDay) { + return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient } + } + + const msUntilStart = event.startDate.getTime() - now.getTime() + + // Event already started + if (msUntilStart < 0) { + const isInProgress = now.getTime() < event.endDate.getTime() + return isInProgress + ? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent } + : { urgency: 0.2, timeRelevance: TimeRelevance.Ambient } + } + + // Starting within 30 minutes + if (msUntilStart <= 30 * 60 * 1000) { + return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent } + } + + // Starting within 2 hours + if (msUntilStart <= 2 * 60 * 60 * 1000) { + return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming } + } + + // Later today (using local day boundary when timeZone is set) + const todayStart = startOfDay(now, timeZone) + const endOfDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000) + + if (event.startDate.getTime() < endOfDay.getTime()) { + return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming } + } + + // Future days + return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient } +} + +function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem { + return { + id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, + type: "caldav-event", + timestamp: now, + data: event, + signals: computeSignals(event, now, timeZone), + } +} diff --git a/packages/aris-source-caldav/src/calendar-context.ts b/packages/aris-source-caldav/src/calendar-context.ts new file mode 100644 index 0000000..b1245db --- /dev/null +++ b/packages/aris-source-caldav/src/calendar-context.ts @@ -0,0 +1,24 @@ +import type { ContextKey } from "@aris/core" + +import { contextKey } from "@aris/core" + +import type { CalDavEventData } from "./types.ts" + +/** + * Calendar context for downstream sources. + * + * Provides a snapshot of the user's upcoming CalDAV events so other sources + * can adapt (e.g. a commute source checking if there's a meeting soon). + */ +export interface CalendarContext { + /** Events happening right now */ + inProgress: CalDavEventData[] + /** Next upcoming event, if any */ + nextEvent: CalDavEventData | null + /** Whether the user has any events today */ + hasTodayEvents: boolean + /** Total number of events today */ + todayEventCount: number +} + +export const CalDavCalendarKey: ContextKey = contextKey("caldavCalendar") diff --git a/packages/aris-source-caldav/src/ical-parser.test.ts b/packages/aris-source-caldav/src/ical-parser.test.ts new file mode 100644 index 0000000..13d2b9e --- /dev/null +++ b/packages/aris-source-caldav/src/ical-parser.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import { parseICalEvents } from "./ical-parser.ts" + +function loadFixture(name: string): string { + return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") +} + +describe("parseICalEvents", () => { + test("parses a full event with all fields", () => { + const events = parseICalEvents(loadFixture("single-event.ics"), "Work") + + expect(events).toHaveLength(1) + const event = events[0]! + + expect(event.uid).toBe("single-event-001@test") + expect(event.title).toBe("Team Standup") + expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z")) + expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z")) + expect(event.isAllDay).toBe(false) + expect(event.location).toBe("Conference Room A") + expect(event.description).toBe("Daily standup meeting") + expect(event.calendarName).toBe("Work") + expect(event.status).toBe("confirmed") + expect(event.url).toBe("https://example.com/meeting/123") + expect(event.organizer).toBe("Alice Smith") + expect(event.recurrenceId).toBeNull() + + expect(event.attendees).toHaveLength(2) + expect(event.attendees[0]).toEqual({ + name: "Bob Jones", + email: "bob@example.com", + role: "required", + status: "accepted", + }) + expect(event.attendees[1]).toEqual({ + name: "Carol White", + email: "carol@example.com", + role: "optional", + status: "tentative", + }) + + expect(event.alarms).toHaveLength(2) + expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" }) + expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" }) + }) + + test("parses an all-day event with optional fields as null", () => { + const events = parseICalEvents(loadFixture("all-day-event.ics"), null) + + expect(events).toHaveLength(1) + const event = events[0]! + + expect(event.isAllDay).toBe(true) + expect(event.title).toBe("Company Holiday") + expect(event.calendarName).toBeNull() + expect(event.location).toBeNull() + expect(event.description).toBeNull() + expect(event.url).toBeNull() + expect(event.organizer).toBeNull() + expect(event.attendees).toEqual([]) + expect(event.alarms).toEqual([]) + }) + + test("parses recurring event with exception", () => { + const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team") + + expect(events).toHaveLength(2) + expect(events[0]!.uid).toBe("recurring-001@test") + expect(events[1]!.uid).toBe("recurring-001@test") + + const base = events.find((e) => e.title === "Weekly Sync") + expect(base).toBeDefined() + expect(base!.recurrenceId).toBeNull() + + const exception = events.find((e) => e.title === "Weekly Sync (moved)") + expect(exception).toBeDefined() + expect(exception!.recurrenceId).not.toBeNull() + }) + + test("parses minimal event with defaults", () => { + const events = parseICalEvents(loadFixture("minimal-event.ics"), null) + + expect(events).toHaveLength(1) + const event = events[0]! + + expect(event.uid).toBe("minimal-001@test") + expect(event.title).toBe("Quick Chat") + expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z")) + expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z")) + expect(event.location).toBeNull() + expect(event.description).toBeNull() + expect(event.status).toBeNull() + expect(event.url).toBeNull() + expect(event.organizer).toBeNull() + expect(event.attendees).toEqual([]) + expect(event.alarms).toEqual([]) + expect(event.recurrenceId).toBeNull() + }) + + test("parses cancelled status", () => { + const events = parseICalEvents(loadFixture("cancelled-event.ics"), null) + expect(events[0]!.status).toBe("cancelled") + }) +}) diff --git a/packages/aris-source-caldav/src/ical-parser.ts b/packages/aris-source-caldav/src/ical-parser.ts new file mode 100644 index 0000000..218301b --- /dev/null +++ b/packages/aris-source-caldav/src/ical-parser.ts @@ -0,0 +1,153 @@ +import ICAL from "ical.js" + +import { + AttendeeRole, + AttendeeStatus, + CalDavEventStatus, + type CalDavAlarm, + type CalDavAttendee, + type CalDavEventData, +} from "./types.ts" + +/** + * Parses a raw iCalendar string and extracts all VEVENT components + * into CalDavEventData objects. + * + * @param icsData - Raw iCalendar string from a CalDAV response + * @param calendarName - Display name of the calendar this event belongs to + */ +export function parseICalEvents(icsData: string, calendarName: string | null): 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), + ) +} + +function parseVEvent( + vevent: InstanceType, + calendarName: string | null, +): CalDavEventData { + const event = new ICAL.Event(vevent) + + return { + uid: event.uid ?? "", + title: event.summary ?? "", + startDate: event.startDate?.toJSDate() ?? new Date(0), + endDate: event.endDate?.toJSDate() ?? new Date(0), + 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: event.recurrenceId ? event.recurrenceId.toString() : null, + } +} + +function parseStatus(raw: string | null): CalDavEventStatus | null { + if (!raw) return null + switch (raw.toLowerCase()) { + case "confirmed": + return CalDavEventStatus.Confirmed + case "tentative": + return CalDavEventStatus.Tentative + case "cancelled": + return CalDavEventStatus.Cancelled + default: + return null + } +} + +function parseOrganizer( + value: string | null, + vevent: InstanceType, +): string | null { + if (!value) return null + + // Try CN parameter first + const prop = vevent.getFirstProperty("organizer") + if (prop) { + const cn = prop.getParameter("cn") as string | undefined + if (cn) return cn + } + + // Fall back to mailto: value + return value.replace(/^mailto:/i, "") +} + +function parseAttendees(properties: unknown[]): CalDavAttendee[] { + if (properties.length === 0) return [] + + return properties.flatMap((prop) => { + if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return [] + const p = prop as InstanceType + const value = asStringOrNull(p.getFirstValue()) + const cn = asStringOrNull(p.getParameter("cn")) + const role = asStringOrNull(p.getParameter("role")) + const partstat = asStringOrNull(p.getParameter("partstat")) + + return [ + { + name: cn, + email: value ? value.replace(/^mailto:/i, "") : null, + role: parseAttendeeRole(role), + status: parseAttendeeStatus(partstat), + }, + ] + }) +} + +function parseAttendeeRole(raw: string | null): AttendeeRole | null { + if (!raw) return null + switch (raw.toUpperCase()) { + case "CHAIR": + return AttendeeRole.Chair + case "REQ-PARTICIPANT": + return AttendeeRole.Required + case "OPT-PARTICIPANT": + return AttendeeRole.Optional + default: + return null + } +} + +function parseAttendeeStatus(raw: string | null): AttendeeStatus | null { + if (!raw) return null + switch (raw.toUpperCase()) { + case "ACCEPTED": + return AttendeeStatus.Accepted + case "DECLINED": + return AttendeeStatus.Declined + case "TENTATIVE": + return AttendeeStatus.Tentative + case "NEEDS-ACTION": + return AttendeeStatus.NeedsAction + default: + return null + } +} + +function parseAlarms(vevent: InstanceType): CalDavAlarm[] { + const valarms = vevent.getAllSubcomponents("valarm") + if (!valarms || valarms.length === 0) return [] + + return valarms.map((valarm: InstanceType) => { + const trigger = valarm.getFirstPropertyValue("trigger") + const action = asStringOrNull(valarm.getFirstPropertyValue("action")) + + return { + trigger: trigger ? trigger.toString() : "", + action: action ?? "DISPLAY", + } + }) +} + +function asStringOrNull(value: unknown): string | null { + return typeof value === "string" ? value : null +} diff --git a/packages/aris-source-caldav/src/index.ts b/packages/aris-source-caldav/src/index.ts new file mode 100644 index 0000000..e9a0f97 --- /dev/null +++ b/packages/aris-source-caldav/src/index.ts @@ -0,0 +1,15 @@ +export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts" +export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts" +export { parseICalEvents } from "./ical-parser.ts" +export { + AttendeeRole, + AttendeeStatus, + CalDavEventStatus, + type CalDavAlarm, + type CalDavAttendee, + type CalDavDAVCalendar, + type CalDavDAVClient, + type CalDavDAVObject, + type CalDavEventData, + type CalDavFeedItem, +} from "./types.ts" diff --git a/packages/aris-source-caldav/src/types.ts b/packages/aris-source-caldav/src/types.ts new file mode 100644 index 0000000..191253f --- /dev/null +++ b/packages/aris-source-caldav/src/types.ts @@ -0,0 +1,93 @@ +import type { FeedItem } from "@aris/core" + +// -- Event status -- + +export const CalDavEventStatus = { + Confirmed: "confirmed", + Tentative: "tentative", + Cancelled: "cancelled", +} as const + +export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus] + +// -- Attendee types -- + +export const AttendeeRole = { + Chair: "chair", + Required: "required", + Optional: "optional", +} as const + +export type AttendeeRole = (typeof AttendeeRole)[keyof typeof AttendeeRole] + +export const AttendeeStatus = { + Accepted: "accepted", + Declined: "declined", + Tentative: "tentative", + NeedsAction: "needs-action", +} as const + +export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus] + +export interface CalDavAttendee { + name: string | null + email: string | null + role: AttendeeRole | null + status: AttendeeStatus | null +} + +// -- Alarm -- + +export interface CalDavAlarm { + /** ISO 8601 duration relative to event start, e.g. "-PT15M" */ + trigger: string + /** e.g. "DISPLAY", "AUDIO" */ + action: string +} + +// -- Event data -- + +export interface CalDavEventData extends Record { + uid: string + title: string + startDate: Date + endDate: Date + isAllDay: boolean + location: string | null + description: string | null + calendarName: string | null + status: CalDavEventStatus | null + url: string | null + organizer: string | null + attendees: CalDavAttendee[] + alarms: CalDavAlarm[] + recurrenceId: string | null +} + +// -- Feed item -- + +export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData> + +// -- DAV client interface -- + +export interface CalDavDAVObject { + data?: unknown + etag?: string + url: string +} + +export interface CalDavDAVCalendar { + displayName?: string | Record + url: string +} + +/** Subset of tsdav's DAVClient used by CalDavSource. */ +export interface CalDavDAVClient { + login(): Promise + fetchCalendars(): Promise + fetchCalendarObjects(params: { + calendar: CalDavDAVCalendar + timeRange: { start: string; end: string } + }): Promise + credentials: Record +}