diff --git a/bun.lock b/bun.lock index 9f3ee3f..ad5be65 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,15 @@ "arktype": "^2.1.0", }, }, + "packages/aris-source-apple-calendar": { + "name": "@aris/source-apple-calendar", + "version": "0.0.0", + "dependencies": { + "@aris/core": "workspace:*", + "ical.js": "^2.1.0", + "tsdav": "^2.1.7", + }, + }, "packages/aris-source-location": { "name": "@aris/source-location", "version": "0.0.0", @@ -77,6 +86,8 @@ "@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"], + "@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"], + "@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"], "@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"], @@ -147,22 +158,34 @@ "arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="], + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + "better-auth": ["better-auth@1.4.17", "", { "dependencies": { "@better-auth/core": "1.4.17", "@better-auth/telemetry": "1.4.17", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew=="], "better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], + "ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="], + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], "kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="], + "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=="], + "oxfmt": ["oxfmt@0.24.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.24.0", "@oxfmt/darwin-x64": "0.24.0", "@oxfmt/linux-arm64-gnu": "0.24.0", "@oxfmt/linux-arm64-musl": "0.24.0", "@oxfmt/linux-x64-gnu": "0.24.0", "@oxfmt/linux-x64-musl": "0.24.0", "@oxfmt/win32-arm64": "0.24.0", "@oxfmt/win32-x64": "0.24.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw=="], "oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="], @@ -193,16 +216,28 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tsdav": ["tsdav@2.1.7", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-/qjLC9iMox/ntV9L1YkGws9Q6sQ3JknYYSgB0sbq6fH7bqrx9pa9Y3CcTC+q8lnY6v5dud3Un/zsnGvyngh4Xg=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/packages/aris-source-apple-calendar/fixtures/all-day-event.ics b/packages/aris-source-apple-calendar/fixtures/all-day-event.ics new file mode 100644 index 0000000..6d99fce --- /dev/null +++ b/packages/aris-source-apple-calendar/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-apple-calendar/fixtures/cancelled-event.ics b/packages/aris-source-apple-calendar/fixtures/cancelled-event.ics new file mode 100644 index 0000000..2b099ae --- /dev/null +++ b/packages/aris-source-apple-calendar/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-apple-calendar/fixtures/minimal-event.ics b/packages/aris-source-apple-calendar/fixtures/minimal-event.ics new file mode 100644 index 0000000..20605f2 --- /dev/null +++ b/packages/aris-source-apple-calendar/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-apple-calendar/fixtures/recurring-event.ics b/packages/aris-source-apple-calendar/fixtures/recurring-event.ics new file mode 100644 index 0000000..98d67e6 --- /dev/null +++ b/packages/aris-source-apple-calendar/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-apple-calendar/fixtures/single-event.ics b/packages/aris-source-apple-calendar/fixtures/single-event.ics new file mode 100644 index 0000000..01d3546 --- /dev/null +++ b/packages/aris-source-apple-calendar/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-apple-calendar/package.json b/packages/aris-source-apple-calendar/package.json new file mode 100644 index 0000000..ea75f76 --- /dev/null +++ b/packages/aris-source-apple-calendar/package.json @@ -0,0 +1,15 @@ +{ + "name": "@aris/source-apple-calendar", + "version": "0.0.0", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "bun test ." + }, + "dependencies": { + "@aris/core": "workspace:*", + "ical.js": "^2.1.0", + "tsdav": "^2.1.7" + } +} diff --git a/packages/aris-source-apple-calendar/src/calendar-context.ts b/packages/aris-source-apple-calendar/src/calendar-context.ts new file mode 100644 index 0000000..ecc041f --- /dev/null +++ b/packages/aris-source-apple-calendar/src/calendar-context.ts @@ -0,0 +1,24 @@ +import type { ContextKey } from "@aris/core" + +import { contextKey } from "@aris/core" + +import type { CalendarEventData } from "./types.ts" + +/** + * Calendar context for downstream sources. + * + * Provides a snapshot of the user's upcoming 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: CalendarEventData[] + /** Next upcoming event, if any */ + nextEvent: CalendarEventData | null + /** Whether the user has any events today */ + hasTodayEvents: boolean + /** Total number of events today */ + todayEventCount: number +} + +export const CalendarKey: ContextKey = contextKey("calendar") diff --git a/packages/aris-source-apple-calendar/src/calendar-source.test.ts b/packages/aris-source-apple-calendar/src/calendar-source.test.ts new file mode 100644 index 0000000..4544a4f --- /dev/null +++ b/packages/aris-source-apple-calendar/src/calendar-source.test.ts @@ -0,0 +1,460 @@ +import type { Context } from "@aris/core" + +import { contextValue } from "@aris/core" +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +import type { + CalendarCredentialProvider, + CalendarCredentials, + CalendarDAVCalendar, + CalendarDAVClient, + CalendarDAVObject, + CalendarEventData, +} from "./types.ts" + +import { CalendarKey } from "./calendar-context.ts" +import { CalendarSource, computePriority } from "./calendar-source.ts" + +function loadFixture(name: string): string { + return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") +} + +function createContext(time: Date): Context { + return { time } +} + +const mockCredentials: CalendarCredentials = { + accessToken: "mock-access-token", + refreshToken: "mock-refresh-token", + expiresAt: Date.now() + 3600000, + tokenUrl: "https://appleid.apple.com/auth/token", + clientId: "com.example.aris", + clientSecret: "mock-secret", +} + +class NullCredentialProvider implements CalendarCredentialProvider { + async fetchCredentials(_userId: string): Promise { + return null + } +} + +class MockCredentialProvider implements CalendarCredentialProvider { + async fetchCredentials(_userId: string): Promise { + return mockCredentials + } +} + +class MockDAVClient implements CalendarDAVClient { + credentials: Record = {} + fetchCalendarsCallCount = 0 + private calendars: CalendarDAVCalendar[] + private objectsByCalendarUrl: Record + + constructor( + calendars: CalendarDAVCalendar[], + objectsByCalendarUrl: Record, + ) { + this.calendars = calendars + this.objectsByCalendarUrl = objectsByCalendarUrl + } + + async login(): Promise {} + + async fetchCalendars(): Promise { + this.fetchCalendarsCallCount++ + return this.calendars + } + + async fetchCalendarObjects(params: { + calendar: CalendarDAVCalendar + timeRange: { start: string; end: string } + }): Promise { + return this.objectsByCalendarUrl[params.calendar.url] ?? [] + } +} + +describe("CalendarSource", () => { + test("has correct id", () => { + const source = new CalendarSource(new NullCredentialProvider(), "user-1") + expect(source.id).toBe("apple-calendar") + }) + + test("returns empty array when credentials are null", async () => { + const source = new CalendarSource(new NullCredentialProvider(), "user-1") + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + expect(items).toEqual([]) + }) + + test("returns empty array when no calendars exist", async () => { + const client = new MockDAVClient([], {}) + const source = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: client, + }) + + const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) + + expect(items).toHaveLength(1) + expect(items[0]!.type).toBe("calendar-event") + expect(items[0]!.id).toBe("calendar-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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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 priority 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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!.priority).toBe(0.7) // within 2 hours + expect(holiday!.priority).toBe(0.3) // all-day + }) + + 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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("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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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("CalendarSource.fetchContext", () => { + test("returns empty context when credentials are null", async () => { + const source = new CalendarSource(new NullCredentialProvider(), "user-1") + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalendarKey) + + 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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, CalendarKey) + + 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: 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, CalendarKey) + + 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: client, + }) + + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalendarKey) + + 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 = new CalendarSource(new MockCredentialProvider(), "user-1", { + davClient: client, + }) + + const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) + const calendar = contextValue(ctx as Context, CalendarKey) + + expect(calendar!.todayEventCount).toBe(2) + expect(calendar!.hasTodayEvents).toBe(true) + }) +}) + +describe("computePriority", () => { + const now = new Date("2026-01-15T12:00:00Z") + + function makeEvent(overrides: Partial): CalendarEventData { + 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 priority 0.3", () => { + const event = makeEvent({ isAllDay: true }) + expect(computePriority(event, now)).toBe(0.3) + }) + + test("events starting within 30 minutes get priority 0.9", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T12:20:00Z"), + }) + expect(computePriority(event, now)).toBe(0.9) + }) + + test("events starting exactly at 30 minutes get priority 0.9", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T12:30:00Z"), + }) + expect(computePriority(event, now)).toBe(0.9) + }) + + test("events starting within 2 hours get priority 0.7", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T13:00:00Z"), + }) + expect(computePriority(event, now)).toBe(0.7) + }) + + test("events later today get priority 0.5", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T20:00:00Z"), + }) + expect(computePriority(event, now)).toBe(0.5) + }) + + test("in-progress events get priority 0.8", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T11:00:00Z"), + endDate: new Date("2026-01-15T13:00:00Z"), + }) + expect(computePriority(event, now)).toBe(0.8) + }) + + test("fully past events get priority 0.2", () => { + const event = makeEvent({ + startDate: new Date("2026-01-15T09:00:00Z"), + endDate: new Date("2026-01-15T10:00:00Z"), + }) + expect(computePriority(event, now)).toBe(0.2) + }) + + test("events on future days get priority 0.2", () => { + const event = makeEvent({ + startDate: new Date("2026-01-16T10:00:00Z"), + }) + expect(computePriority(event, now)).toBe(0.2) + }) + + test("priority 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(computePriority(event31min, now)).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(computePriority(event2h1m, now)).toBe(0.5) + }) +}) diff --git a/packages/aris-source-apple-calendar/src/calendar-source.ts b/packages/aris-source-apple-calendar/src/calendar-source.ts new file mode 100644 index 0000000..5ae1425 --- /dev/null +++ b/packages/aris-source-apple-calendar/src/calendar-source.ts @@ -0,0 +1,242 @@ +import type { Context, FeedSource } from "@aris/core" + +import { DAVClient } from "tsdav" + +import type { + CalendarCredentialProvider, + CalendarCredentials, + CalendarDAVClient, + CalendarEventData, + CalendarFeedItem, +} from "./types.ts" + +export interface CalendarSourceOptions { + /** Number of additional days beyond today to fetch. Default: 0 (today only). */ + lookAheadDays?: number + /** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */ + davClient?: CalendarDAVClient +} + +import { CalendarKey, type CalendarContext } from "./calendar-context.ts" +import { parseICalEvents } from "./ical-parser.ts" + +const ICLOUD_CALDAV_URL = "https://caldav.icloud.com" +const DEFAULT_LOOK_AHEAD_DAYS = 0 + +/** + * A FeedSource that fetches Apple Calendar events via CalDAV. + * + * Credentials are provided by an injected CalendarCredentialProvider. + * The server is responsible for managing OAuth tokens and storage. + * + * @example + * ```ts + * const source = new CalendarSource(credentialProvider, "user-123") + * const engine = new FeedEngine() + * engine.register(source) + * ``` + */ +export class CalendarSource implements FeedSource { + readonly id = "apple-calendar" + + private readonly credentialProvider: CalendarCredentialProvider + private readonly userId: string + private readonly lookAheadDays: number + private readonly injectedClient: CalendarDAVClient | null + private davClient: CalendarDAVClient | null = null + private lastAccessToken: string | null = null + private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null + + constructor( + credentialProvider: CalendarCredentialProvider, + userId: string, + options?: CalendarSourceOptions, + ) { + this.credentialProvider = credentialProvider + this.userId = userId + this.lookAheadDays = options?.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS + this.injectedClient = options?.davClient ?? null + } + + async fetchContext(context: Context): Promise> { + const events = await this.fetchEvents(context) + if (events.length === 0) { + return { + [CalendarKey]: { + inProgress: [], + nextEvent: null, + hasTodayEvents: false, + todayEventCount: 0, + }, + } + } + + const now = context.time + const inProgress = events.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now) + + const upcoming = events + .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 { [CalendarKey]: calendarContext } + } + + async fetchItems(context: Context): Promise { + const now = context.time + const events = await this.fetchEvents(context) + return events.map((event) => createFeedItem(event, now)) + } + + private async fetchEvents(context: Context): Promise { + if (this.cachedEvents && this.cachedEvents.time === context.time) { + return this.cachedEvents.events + } + + const credentials = await this.credentialProvider.fetchCredentials(this.userId) + if (!credentials) { + return [] + } + + const client = await this.connectClient(credentials) + const calendars = await client.fetchCalendars() + + const { start, end } = computeTimeRange(context.time, this.lookAheadDays) + + 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 + // because the XML parser can return an object for some responses + const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null + return { objects, calendarName } + }), + ) + + const allEvents: CalendarEventData[] = [] + for (const result of results) { + if (result.status !== "fulfilled") 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 + } + + /** + * Returns a ready-to-use DAVClient. Creates and logs in a new client + * on first call; reuses the existing one on subsequent calls, updating + * credentials if the access token has changed. + */ + private async connectClient(credentials: CalendarCredentials): Promise { + if (this.injectedClient) { + return this.injectedClient + } + + const davCredentials = { + tokenUrl: credentials.tokenUrl, + refreshToken: credentials.refreshToken, + accessToken: credentials.accessToken, + expiration: credentials.expiresAt, + clientId: credentials.clientId, + clientSecret: credentials.clientSecret, + } + + if (!this.davClient) { + this.davClient = new DAVClient({ + serverUrl: ICLOUD_CALDAV_URL, + credentials: davCredentials, + authMethod: "Oauth", + defaultAccountType: "caldav", + }) + await this.davClient.login() + this.lastAccessToken = credentials.accessToken + return this.davClient + } + + if (credentials.accessToken !== this.lastAccessToken) { + this.davClient.credentials = davCredentials + this.lastAccessToken = credentials.accessToken + } + + return this.davClient + } +} + +function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: Date } { + const start = new Date(now) + start.setUTCHours(0, 0, 0, 0) + + const end = new Date(start) + end.setUTCDate(end.getUTCDate() + 1 + lookAheadDays) + + return { start, end } +} + +export function computePriority(event: CalendarEventData, now: Date): number { + if (event.isAllDay) { + return 0.3 + } + + const msUntilStart = event.startDate.getTime() - now.getTime() + + // Event already started + if (msUntilStart < 0) { + const isInProgress = now.getTime() < event.endDate.getTime() + // Currently happening events are high priority; fully past events are low + return isInProgress ? 0.8 : 0.2 + } + + // Starting within 30 minutes + if (msUntilStart <= 30 * 60 * 1000) { + return 0.9 + } + + // Starting within 2 hours + if (msUntilStart <= 2 * 60 * 60 * 1000) { + return 0.7 + } + + // Later today (within 24 hours from start of day) + const startOfDay = new Date(now) + startOfDay.setUTCHours(0, 0, 0, 0) + const endOfDay = new Date(startOfDay) + endOfDay.setUTCDate(endOfDay.getUTCDate() + 1) + + if (event.startDate.getTime() < endOfDay.getTime()) { + return 0.5 + } + + // Future days + return 0.2 +} + +function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem { + return { + id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, + type: "calendar-event", + priority: computePriority(event, now), + timestamp: now, + data: event, + } +} diff --git a/packages/aris-source-apple-calendar/src/ical-parser.test.ts b/packages/aris-source-apple-calendar/src/ical-parser.test.ts new file mode 100644 index 0000000..13d2b9e --- /dev/null +++ b/packages/aris-source-apple-calendar/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-apple-calendar/src/ical-parser.ts b/packages/aris-source-apple-calendar/src/ical-parser.ts new file mode 100644 index 0000000..36246ab --- /dev/null +++ b/packages/aris-source-apple-calendar/src/ical-parser.ts @@ -0,0 +1,150 @@ +import ICAL from "ical.js" + +import { + AttendeeRole, + AttendeeStatus, + CalendarEventStatus, + type CalendarAlarm, + type CalendarAttendee, + type CalendarEventData, +} from "./types.ts" + +/** + * Parses a raw iCalendar string and extracts all VEVENT components + * into CalendarEventData 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): CalendarEventData[] { + 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, +): CalendarEventData { + 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): CalendarEventStatus | null { + if (!raw) return null + switch (raw.toLowerCase()) { + case "confirmed": + return CalendarEventStatus.Confirmed + case "tentative": + return CalendarEventStatus.Tentative + case "cancelled": + return CalendarEventStatus.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[]): CalendarAttendee[] { + if (properties.length === 0) return [] + + return properties.map((prop) => { + 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): CalendarAlarm[] { + 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-apple-calendar/src/index.ts b/packages/aris-source-apple-calendar/src/index.ts new file mode 100644 index 0000000..8da176e --- /dev/null +++ b/packages/aris-source-apple-calendar/src/index.ts @@ -0,0 +1,16 @@ +export { CalendarKey, type CalendarContext } from "./calendar-context.ts" +export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts" +export { + CalendarEventStatus, + AttendeeRole, + AttendeeStatus, + type CalendarCredentials, + type CalendarCredentialProvider, + type CalendarDAVClient, + type CalendarDAVCalendar, + type CalendarDAVObject, + type CalendarAttendee, + type CalendarAlarm, + type CalendarEventData, + type CalendarFeedItem, +} from "./types.ts" diff --git a/packages/aris-source-apple-calendar/src/types.ts b/packages/aris-source-apple-calendar/src/types.ts new file mode 100644 index 0000000..769581e --- /dev/null +++ b/packages/aris-source-apple-calendar/src/types.ts @@ -0,0 +1,101 @@ +import type { FeedItem } from "@aris/core" + +// -- Credential provider -- + +export interface CalendarCredentials { + accessToken: string + refreshToken: string + /** Unix timestamp in milliseconds when the access token expires */ + expiresAt: number + tokenUrl: string + clientId: string + clientSecret: string +} + +export interface CalendarCredentialProvider { + fetchCredentials(userId: string): Promise +} + +// -- Feed item types -- + +export const CalendarEventStatus = { + Confirmed: "confirmed", + Tentative: "tentative", + Cancelled: "cancelled", +} as const + +export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus] + +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 CalendarAttendee { + name: string | null + email: string | null + role: AttendeeRole | null + status: AttendeeStatus | null +} + +export interface CalendarAlarm { + /** ISO 8601 duration relative to event start, e.g. "-PT15M" */ + trigger: string + /** e.g. "DISPLAY", "AUDIO" */ + action: string +} + +export interface CalendarEventData extends Record { + uid: string + title: string + startDate: Date + endDate: Date + isAllDay: boolean + location: string | null + description: string | null + calendarName: string | null + status: CalendarEventStatus | null + url: string | null + organizer: string | null + attendees: CalendarAttendee[] + alarms: CalendarAlarm[] + recurrenceId: string | null +} + +export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData> + +// -- DAV client interface -- + +export interface CalendarDAVObject { + data?: unknown + etag?: string + url: string +} + +export interface CalendarDAVCalendar { + displayName?: string | Record + url: string +} + +/** Subset of DAVClient used by CalendarSource. */ +export interface CalendarDAVClient { + login(): Promise + fetchCalendars(): Promise + fetchCalendarObjects(params: { + calendar: CalendarDAVCalendar + timeRange: { start: string; end: string } + }): Promise + credentials: Record +}