diff --git a/bun.lock b/bun.lock index d577642..093e0ed 100644 --- a/bun.lock +++ b/bun.lock @@ -89,15 +89,6 @@ "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-google-calendar": { "name": "@aris/source-google-calendar", "version": "0.0.0", @@ -144,8 +135,6 @@ "@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-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"], "@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"], @@ -910,8 +899,6 @@ "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=="], @@ -1024,7 +1011,7 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], - "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1402,8 +1389,6 @@ "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=="], @@ -2178,8 +2163,6 @@ "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=="], @@ -2302,8 +2285,6 @@ "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=="], @@ -2632,8 +2613,6 @@ "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=="], @@ -2960,8 +2939,6 @@ "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-apple-calendar/fixtures/all-day-event.ics b/packages/aris-source-apple-calendar/fixtures/all-day-event.ics deleted file mode 100644 index 6d99fce..0000000 --- a/packages/aris-source-apple-calendar/fixtures/all-day-event.ics +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 2b099ae..0000000 --- a/packages/aris-source-apple-calendar/fixtures/cancelled-event.ics +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 20605f2..0000000 --- a/packages/aris-source-apple-calendar/fixtures/minimal-event.ics +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 98d67e6..0000000 --- a/packages/aris-source-apple-calendar/fixtures/recurring-event.ics +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 01d3546..0000000 --- a/packages/aris-source-apple-calendar/fixtures/single-event.ics +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index ea75f76..0000000 --- a/packages/aris-source-apple-calendar/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index ecc041f..0000000 --- a/packages/aris-source-apple-calendar/src/calendar-context.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index f55c2da..0000000 --- a/packages/aris-source-apple-calendar/src/calendar-source.test.ts +++ /dev/null @@ -1,485 +0,0 @@ -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 { - CalendarCredentialProvider, - CalendarCredentials, - CalendarDAVCalendar, - CalendarDAVClient, - CalendarDAVObject, - CalendarEventData, -} from "./types.ts" - -import { CalendarKey } from "./calendar-context.ts" -import { CalendarSource, computeSignals } 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("aris.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 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 = 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!.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 = 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("computeSignals", () => { - 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 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) - }) -}) diff --git a/packages/aris-source-apple-calendar/src/calendar-source.ts b/packages/aris-source-apple-calendar/src/calendar-source.ts deleted file mode 100644 index 14275d8..0000000 --- a/packages/aris-source-apple-calendar/src/calendar-source.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core" - -import { TimeRelevance, UnknownActionError } 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 = "aris.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 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 { - [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 computeSignals(event: CalendarEventData, now: Date): FeedItemSignals { - 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 - 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 { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming } - } - - // Future days - return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient } -} - -function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem { - return { - id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`, - type: "calendar-event", - timestamp: now, - data: event, - signals: computeSignals(event, now), - } -} diff --git a/packages/aris-source-apple-calendar/src/ical-parser.test.ts b/packages/aris-source-apple-calendar/src/ical-parser.test.ts deleted file mode 100644 index 13d2b9e..0000000 --- a/packages/aris-source-apple-calendar/src/ical-parser.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 36246ab..0000000 --- a/packages/aris-source-apple-calendar/src/ical-parser.ts +++ /dev/null @@ -1,150 +0,0 @@ -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 deleted file mode 100644 index 8da176e..0000000 --- a/packages/aris-source-apple-calendar/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 769581e..0000000 --- a/packages/aris-source-apple-calendar/src/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -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 -}