feat: add Apple Calendar source package

Add @aris/source-apple-calendar for fetching iCloud
calendar events via CalDAV using tsdav and ical.js.

- CalendarSource implements FeedSource with fetchItems
  and fetchContext for downstream context
- CalendarCredentialProvider interface for token injection
- CalendarDAVClient interface for testability
- iCal parser extracts full event data including
  attendees, alarms, organizer, and recurrence
- Priority based on event proximity to current time

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-02-13 22:08:58 +00:00
parent 1b2929c2b6
commit f557a0f967
14 changed files with 1182 additions and 0 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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<CalendarContext> = contextKey("calendar")

View File

@@ -0,0 +1,434 @@
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<CalendarCredentials | null> {
return null
}
}
class MockCredentialProvider implements CalendarCredentialProvider {
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
return mockCredentials
}
}
class MockDAVClient implements CalendarDAVClient {
credentials: Record<string, unknown> = {}
private calendars: CalendarDAVCalendar[]
private objectsByCalendarUrl: Map<string, CalendarDAVObject[]>
constructor(
calendars: CalendarDAVCalendar[],
objectsByCalendarUrl: Map<string, CalendarDAVObject[]>,
) {
this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl
}
async login(): Promise<void> {}
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
return this.calendars
}
async fetchCalendarObjects(params: {
calendar: CalendarDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]> {
return this.objectsByCalendarUrl.get(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([], new Map())
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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
[
"/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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
[
"/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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
["/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("-")
})
})
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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
["/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 = new Map<string, CalendarDAVObject[]>([
[
"/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>): 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)
})
})

View File

@@ -0,0 +1,228 @@
import type { Context, FeedSource } from "@aris/core"
import { DAVClient } from "tsdav"
import type {
CalendarCredentialProvider,
CalendarCredentials,
CalendarDAVClient,
CalendarEventData,
CalendarFeedItem,
CalendarSourceOptions,
} from "./types.ts"
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<CalendarFeedItem> {
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
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<Partial<Context>> {
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<CalendarFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now))
}
private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
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.all(
calendars.map(async (calendar) => {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: {
start: start.toISOString(),
end: end.toISOString(),
},
})
// tsdav types displayName as string | Record<string, unknown> | 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 { objects, calendarName } of results) {
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)
}
}
}
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<CalendarDAVClient> {
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,
}
}

View File

@@ -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")
})
})

View File

@@ -0,0 +1,134 @@
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<typeof ICAL.Component>) =>
parseVEvent(vevent, calendarName),
)
}
function parseVEvent(
vevent: InstanceType<typeof ICAL.Component>,
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
const lower = raw.toLowerCase()
if (lower === "confirmed") return CalendarEventStatus.Confirmed
if (lower === "tentative") return CalendarEventStatus.Tentative
if (lower === "cancelled") return CalendarEventStatus.Cancelled
return null
}
function parseOrganizer(
value: string | null,
vevent: InstanceType<typeof ICAL.Component>,
): 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<typeof ICAL.Property>
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
const upper = raw.toUpperCase()
if (upper === "CHAIR") return AttendeeRole.Chair
if (upper === "REQ-PARTICIPANT") return AttendeeRole.Required
if (upper === "OPT-PARTICIPANT") return AttendeeRole.Optional
return null
}
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
if (!raw) return null
const upper = raw.toUpperCase()
if (upper === "ACCEPTED") return AttendeeStatus.Accepted
if (upper === "DECLINED") return AttendeeStatus.Declined
if (upper === "TENTATIVE") return AttendeeStatus.Tentative
if (upper === "NEEDS-ACTION") return AttendeeStatus.NeedsAction
return null
}
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {
const valarms = vevent.getAllSubcomponents("valarm")
if (!valarms || valarms.length === 0) return []
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
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
}

View File

@@ -0,0 +1,17 @@
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalendarSource } 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,
type CalendarSourceOptions,
} from "./types.ts"

View File

@@ -0,0 +1,110 @@
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<CalendarCredentials | null>
}
// -- 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<string, unknown> {
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<string, unknown>
url: string
}
/** Subset of DAVClient used by CalendarSource. */
export interface CalendarDAVClient {
login(): Promise<void>
fetchCalendars(): Promise<CalendarDAVCalendar[]>
fetchCalendarObjects(params: {
calendar: CalendarDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]>
credentials: Record<string, unknown>
}
// -- Source options --
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
}