Compare commits

..

4 Commits

Author SHA1 Message Date
40ad90aa2d feat: add generic CalDAV calendar data source (#42)
* feat: add generic CalDAV calendar data source

Add @aris/source-caldav package that fetches calendar events from any
CalDAV server via tsdav + ical.js.

- Supports Basic auth and OAuth via explicit authMethod discriminant
- serverUrl provided at construction time, not hardcoded
- Optional timeZone for correct local day boundaries
- Credentials cleared from memory after client login
- Failed calendar fetches logged, not silently dropped
- Login promise cached with retry on failure

Co-authored-by: Ona <no-reply@ona.com>

* fix: deduplicate concurrent fetchEvents calls

Co-authored-by: Ona <no-reply@ona.com>

* fix: timezone-aware signals, low-priority cancelled events

- computeSignals uses startOfDay(timeZone) for 'later today' boundary
- Cancelled events get urgency 0.1, excluded from context inProgress/nextEvent

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 16:09:11 +00:00
82ac2b577d feat: add post-processor pipeline to FeedEngine (#41)
* feat: add post-processor pipeline to FeedEngine

Add FeedPostProcessor type and FeedEnhancement interface.
Post-processors run after item collection on all update
paths (refresh, reactive context, reactive items).

Pipeline is chained — each processor sees items as modified
by the previous one. Enhancement merging handles additional
items, suppression, and grouped items. Throwing processors
are caught and recorded in FeedResult.errors.

Co-authored-by: Ona <no-reply@ona.com>

* docs: document intentional TItems cast in post-processor merge

Co-authored-by: Ona <no-reply@ona.com>

* fix: filter stale item IDs from groups after pipeline

Groups accumulated during the pipeline can reference items
that a later processor suppressed. The engine now strips
stale IDs and drops empty groups before returning.

Co-authored-by: Ona <no-reply@ona.com>

* refactor: use reduce for stale group filtering

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 15:57:01 +00:00
ffea38b986 fix: remove apple calendar data source (#40)
The CalDAV-based approach doesn't work as expected for
Apple Calendar integration.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:30:23 +00:00
28d26b3c87 Replace FeedItem.priority with signals (#39)
* feat: replace FeedItem.priority with signals

Remove priority field from FeedItem and engine-level sorting.
Add FeedItemSignals with urgency and timeRelevance fields.
Update all source packages to emit signals instead of priority.

Ranking is now the post-processing layer's responsibility.
Urgency values are unchanged from the old priority values.

Co-authored-by: Ona <no-reply@ona.com>

* fix: use TimeRelevance enum in all tests

Co-authored-by: Ona <no-reply@ona.com>

---------

Co-authored-by: Ona <no-reply@ona.com>
2026-02-28 12:02:57 +00:00
22 changed files with 1237 additions and 444 deletions

View File

@@ -89,8 +89,8 @@
"arktype": "^2.1.0", "arktype": "^2.1.0",
}, },
}, },
"packages/aris-source-apple-calendar": { "packages/aris-source-caldav": {
"name": "@aris/source-apple-calendar", "name": "@aris/source-caldav",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@aris/core": "workspace:*", "@aris/core": "workspace:*",
@@ -144,7 +144,7 @@
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"], "@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-caldav": ["@aris/source-caldav@workspace:packages/aris-source-caldav"],
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"], "@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],

View File

@@ -1,6 +1,7 @@
import type { ActionDefinition } from "./action" import type { ActionDefinition } from "./action"
import type { Context } from "./context" import type { Context } from "./context"
import type { FeedItem } from "./feed" import type { FeedItem } from "./feed"
import type { FeedPostProcessor, ItemGroup } from "./feed-post-processor"
import type { FeedSource } from "./feed-source" import type { FeedSource } from "./feed-source"
export interface SourceError { export interface SourceError {
@@ -12,6 +13,8 @@ export interface FeedResult<TItem extends FeedItem = FeedItem> {
context: Context context: Context
items: TItem[] items: TItem[]
errors: SourceError[] errors: SourceError[]
/** Item groups produced by post-processors */
groupedItems?: ItemGroup[]
} }
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
@@ -66,6 +69,7 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
private subscribers = new Set<FeedSubscriber<TItems>>() private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = [] private cleanups: Array<() => void> = []
private started = false private started = false
private postProcessors: FeedPostProcessor[] = []
private readonly cacheTtlMs: number private readonly cacheTtlMs: number
private cachedResult: FeedResult<TItems> | null = null private cachedResult: FeedResult<TItems> | null = null
@@ -108,6 +112,23 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return this return this
} }
/**
* Registers a post-processor. Processors run in registration order
* after items are collected, on every update path.
*/
registerPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors.push(processor)
return this
}
/**
* Unregisters a post-processor by reference.
*/
unregisterPostProcessor(processor: FeedPostProcessor): this {
this.postProcessors = this.postProcessors.filter((p) => p !== processor)
return this
}
/** /**
* Refreshes the feed by running all sources in dependency order. * Refreshes the feed by running all sources in dependency order.
* Calls fetchContext() then fetchItems() on each source. * Calls fetchContext() then fetchItems() on each source.
@@ -152,7 +173,18 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
this.context = context this.context = context
const result: FeedResult<TItems> = { context, items: items as TItems[], errors } const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = {
context,
items: processedItems,
errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
}
this.updateCache(result) this.updateCache(result)
return result return result
@@ -260,6 +292,58 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
return actions return actions
} }
private async applyPostProcessors(
items: TItems[],
errors: SourceError[],
): Promise<{ items: TItems[]; groupedItems: ItemGroup[]; errors: SourceError[] }> {
let currentItems = items
const allGroupedItems: ItemGroup[] = []
const allErrors = [...errors]
for (const processor of this.postProcessors) {
const snapshot = currentItems
try {
const enhancement = await processor(currentItems)
if (enhancement.additionalItems?.length) {
// Post-processors operate on FeedItem[] without knowledge of TItems.
// Additional items are merged untyped — this is intentional. The
// processor contract is "FeedItem in, FeedItem out"; type narrowing
// is the caller's responsibility when consuming FeedResult.
currentItems = [...currentItems, ...(enhancement.additionalItems as TItems[])]
}
if (enhancement.suppress?.length) {
const suppressSet = new Set(enhancement.suppress)
currentItems = currentItems.filter((item) => !suppressSet.has(item.id))
}
if (enhancement.groupedItems?.length) {
allGroupedItems.push(...enhancement.groupedItems)
}
} catch (err) {
const sourceId = processor.name || "anonymous"
allErrors.push({
sourceId,
error: err instanceof Error ? err : new Error(String(err)),
})
currentItems = snapshot
}
}
// Remove stale item IDs from groups and drop empty groups
const itemIds = new Set(currentItems.map((item) => item.id))
const validGroups = allGroupedItems.reduce<ItemGroup[]>((acc, group) => {
const ids = group.itemIds.filter((id) => itemIds.has(id))
if (ids.length > 0) {
acc.push({ ...group, itemIds: ids })
}
return acc
}, [])
return { items: currentItems, groupedItems: validGroups, errors: allErrors }
}
private ensureGraph(): SourceGraph { private ensureGraph(): SourceGraph {
if (!this.graph) { if (!this.graph) {
this.graph = buildGraph(Array.from(this.sources.values())) this.graph = buildGraph(Array.from(this.sources.values()))
@@ -311,10 +395,17 @@ export class FeedEngine<TItems extends FeedItem = FeedItem> {
} }
} }
const {
items: processedItems,
groupedItems,
errors: postProcessorErrors,
} = await this.applyPostProcessors(items as TItems[], errors)
const result: FeedResult<TItems> = { const result: FeedResult<TItems> = {
context: this.context, context: this.context,
items: items as TItems[], items: processedItems,
errors, errors: postProcessorErrors,
...(groupedItems.length > 0 ? { groupedItems } : {}),
} }
this.updateCache(result) this.updateCache(result)

View File

@@ -0,0 +1,443 @@
import { describe, expect, mock, test } from "bun:test"
import type { ActionDefinition, FeedItem, FeedPostProcessor, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherItem = FeedItem<"weather", { temp: number }>
type CalendarItem = FeedItem<"calendar", { title: string }>
function weatherItem(id: string, temp: number): WeatherItem {
return { id, type: "weather", timestamp: new Date(), data: { temp } }
}
function calendarItem(id: string, title: string): CalendarItem {
return { id, type: "calendar", timestamp: new Date(), data: { title } }
}
// =============================================================================
// TEST SOURCES
// =============================================================================
function createWeatherSource(items: WeatherItem[]) {
return {
id: "aris.weather",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<WeatherItem[]> {
return items
},
}
}
function createCalendarSource(items: CalendarItem[]) {
return {
id: "aris.calendar",
...noActions,
async fetchContext() {
return null
},
async fetchItems(): Promise<CalendarItem[]> {
return items
},
}
}
// =============================================================================
// REGISTRATION
// =============================================================================
describe("FeedPostProcessor", () => {
describe("registration", () => {
test("registerPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.registerPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregisterPostProcessor is chainable", () => {
const engine = new FeedEngine()
const processor: FeedPostProcessor = async () => ({})
const result = engine.unregisterPostProcessor(processor)
expect(result).toBe(engine)
})
test("unregistered processor does not run", async () => {
const processor = mock(async () => ({}))
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(processor)
.unregisterPostProcessor(processor)
await engine.refresh()
expect(processor).not.toHaveBeenCalled()
})
})
// =============================================================================
// ADDITIONAL ITEMS
// =============================================================================
describe("additionalItems", () => {
test("injects additional items into the feed", async () => {
const extra = calendarItem("c1", "Meeting")
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ additionalItems: [extra] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items.find((i) => i.id === "c1")).toBeDefined()
})
})
// =============================================================================
// SUPPRESS
// =============================================================================
describe("suppress", () => {
test("removes suppressed items from the feed", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
expect(result.items[0].id).toBe("w2")
})
test("suppressing nonexistent ID is a no-op", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({ suppress: ["nonexistent"] }))
const result = await engine.refresh()
expect(result.items).toHaveLength(1)
})
})
// =============================================================================
// GROUPED ITEMS
// =============================================================================
describe("groupedItems", () => {
test("accumulates grouped items on FeedResult", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c1", "c2"], summary: "Busy afternoon" }])
})
test("multiple processors accumulate groups", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Group A" }],
}))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c2"], summary: "Group B" }],
}))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([
{ itemIds: ["c1"], summary: "Group A" },
{ itemIds: ["c2"], summary: "Group B" },
])
})
test("stale item IDs are removed from groups after suppression", async () => {
const engine = new FeedEngine()
.register(
createCalendarSource([calendarItem("c1", "Meeting A"), calendarItem("c2", "Meeting B")]),
)
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1", "c2"], summary: "Afternoon" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toEqual([{ itemIds: ["c2"], summary: "Afternoon" }])
})
test("groups with all items suppressed are dropped", async () => {
const engine = new FeedEngine()
.register(createCalendarSource([calendarItem("c1", "Meeting A")]))
.registerPostProcessor(async () => ({
groupedItems: [{ itemIds: ["c1"], summary: "Solo" }],
}))
.registerPostProcessor(async () => ({ suppress: ["c1"] }))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
test("groupedItems is omitted when no processors produce groups", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({}))
const result = await engine.refresh()
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// PIPELINE ORDERING
// =============================================================================
describe("pipeline ordering", () => {
test("each processor sees items as modified by the previous processor", async () => {
const seen: string[] = []
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
}))
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
await engine.refresh()
expect(seen).toEqual(["w1", "c1"])
})
test("suppression in first processor affects second processor", async () => {
const seen: string[] = []
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({ suppress: ["w1"] }))
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
await engine.refresh()
expect(seen).toEqual(["w2"])
})
})
// =============================================================================
// ERROR HANDLING
// =============================================================================
describe("error handling", () => {
test("throwing processor is recorded in errors and pipeline continues", async () => {
const seen: string[] = []
async function failingProcessor(): Promise<never> {
throw new Error("processor failed")
}
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(failingProcessor)
.registerPostProcessor(async (items) => {
seen.push(...items.map((i) => i.id))
return {}
})
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
expect(ppError).toBeDefined()
expect(ppError!.error.message).toBe("processor failed")
// Pipeline continued — observer still saw the original item
expect(seen).toEqual(["w1"])
expect(result.items).toHaveLength(1)
})
test("anonymous throwing processor uses 'anonymous' as sourceId", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(async () => {
throw new Error("anon failed")
})
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "anonymous")
expect(ppError).toBeDefined()
})
test("non-Error throw is wrapped", async () => {
async function failingProcessor(): Promise<never> {
throw "string error"
}
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20)]))
.registerPostProcessor(failingProcessor)
const result = await engine.refresh()
const ppError = result.errors.find((e) => e.sourceId === "failingProcessor")
expect(ppError).toBeDefined()
expect(ppError!.error).toBeInstanceOf(Error)
})
})
// =============================================================================
// REACTIVE PATHS
// =============================================================================
describe("reactive updates", () => {
test("post-processors run during reactive context updates", async () => {
let callCount = 0
let triggerUpdate: ((update: Record<string, unknown>) => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onContextUpdate(callback, _getContext) {
triggerUpdate = callback
return () => {
triggerUpdate = null
}
},
}
const engine = new FeedEngine()
.register(source)
.registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
// Wait for initial periodic refresh
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive context update
triggerUpdate!({ foo: "bar" })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
test("post-processors run during reactive item updates", async () => {
let callCount = 0
let triggerItemsUpdate: (() => void) | null = null
const source: FeedSource = {
id: "aris.reactive",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [weatherItem("w1", 20)]
},
onItemsUpdate(callback, _getContext) {
triggerItemsUpdate = callback
return () => {
triggerItemsUpdate = null
}
},
}
const engine = new FeedEngine()
.register(source)
.registerPostProcessor(async () => {
callCount++
return {}
})
engine.start()
await new Promise((resolve) => setTimeout(resolve, 50))
const countAfterStart = callCount
// Trigger a reactive items update
triggerItemsUpdate!()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBeGreaterThan(countAfterStart)
engine.stop()
})
})
// =============================================================================
// NO PROCESSORS = NO CHANGE
// =============================================================================
describe("no processors", () => {
test("engine without post-processors returns raw items unchanged", async () => {
const items = [weatherItem("w1", 20), weatherItem("w2", 25)]
const engine = new FeedEngine().register(createWeatherSource(items))
const result = await engine.refresh()
expect(result.items).toHaveLength(2)
expect(result.items[0].id).toBe("w1")
expect(result.items[1].id).toBe("w2")
expect(result.groupedItems).toBeUndefined()
})
})
// =============================================================================
// COMBINED ENHANCEMENT
// =============================================================================
describe("combined enhancement", () => {
test("single processor can use all enhancement fields at once", async () => {
const engine = new FeedEngine()
.register(createWeatherSource([weatherItem("w1", 20), weatherItem("w2", 25)]))
.registerPostProcessor(async () => ({
additionalItems: [calendarItem("c1", "Injected")],
suppress: ["w2"],
groupedItems: [{ itemIds: ["w1", "c1"], summary: "Related" }],
}))
const result = await engine.refresh()
// w2 suppressed, c1 injected → w1 + c1
expect(result.items).toHaveLength(2)
expect(result.items.map((i) => i.id)).toEqual(["w1", "c1"])
// Groups on result
expect(result.groupedItems).toEqual([{ itemIds: ["w1", "c1"], summary: "Related" }])
})
})
})

View File

@@ -0,0 +1,23 @@
import type { FeedItem } from "./feed"
export interface ItemGroup {
/** IDs of items to present together */
itemIds: string[]
/** Summary text for the group */
summary: string
}
export interface FeedEnhancement {
/** New items to inject into the feed */
additionalItems?: FeedItem[]
/** Groups of items to present together with a summary */
groupedItems?: ItemGroup[]
/** Item IDs to remove from the feed */
suppress?: string[]
}
/**
* A function that transforms feed items and produces enhancement directives.
* Use named functions for meaningful error attribution.
*/
export type FeedPostProcessor = (items: FeedItem[]) => Promise<FeedEnhancement>

View File

@@ -13,6 +13,9 @@ export { TimeRelevance } from "./feed"
// Feed Source // Feed Source
export type { FeedSource } from "./feed-source" export type { FeedSource } from "./feed-source"
// Feed Post-Processor
export type { FeedEnhancement, FeedPostProcessor, ItemGroup } from "./feed-post-processor"
// Feed Engine // Feed Engine
export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine" export type { FeedEngineConfig, FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine" export { FeedEngine } from "./feed-engine"

View File

@@ -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<CalendarFeedItem> {
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<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | 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<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[]> {
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<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 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<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 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),
}
}

View File

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

View File

@@ -0,0 +1,58 @@
# @aris/source-caldav
A FeedSource that fetches calendar events from any CalDAV server.
## Usage
```ts
import { CalDavSource } from "@aris/source-caldav"
// Basic auth (Nextcloud, Radicale, Baikal, iCloud, etc.)
const source = new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
lookAheadDays: 7, // optional, default: 0 (today only)
timeZone: "America/New_York", // optional, default: UTC
})
// OAuth
const source = new CalDavSource({
serverUrl: "https://caldav.provider.com",
authMethod: "oauth",
accessToken: "...",
refreshToken: "...",
tokenUrl: "https://provider.com/oauth/token",
})
```
### iCloud
Use your Apple ID email as the username and an [app-specific password](https://support.apple.com/en-us/102654):
```ts
const source = new CalDavSource({
serverUrl: "https://caldav.icloud.com",
authMethod: "basic",
username: "you@icloud.com",
password: "<app-specific-password>",
})
```
## Testing
```bash
bun test
```
### Live test
`bun run test:live` connects to a real CalDAV server and prints all events to the console. It prompts for:
- **CalDAV server URL** — e.g. `https://caldav.icloud.com`
- **Username** — your account email
- **Password** — your password (or app-specific password for iCloud)
- **Look-ahead days** — how many days beyond today to fetch (default: 0)
The script runs both `fetchContext` and `fetchItems`, printing the calendar context (in-progress events, next event, today's count) followed by each event with its title, time, location, signals, and attendees.

View File

@@ -1,11 +1,12 @@
{ {
"name": "@aris/source-apple-calendar", "name": "@aris/source-caldav",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
"scripts": { "scripts": {
"test": "bun test ." "test": "bun test .",
"test:live": "bun run scripts/test-live.ts"
}, },
"dependencies": { "dependencies": {
"@aris/core": "workspace:*", "@aris/core": "workspace:*",

View File

@@ -0,0 +1,62 @@
/**
* Live test script for CalDavSource.
*
* Usage:
* bun run test-live.ts
*/
import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:")
const username = prompt("Username:")
const password = prompt("Password:")
const lookAheadRaw = prompt("Look-ahead days (default 0):")
if (!serverUrl || !username || !password) {
console.error("Server URL, username, and password are required.")
process.exit(1)
}
const lookAheadDays = Number(lookAheadRaw) || 0
const source = new CalDavSource({
serverUrl,
authMethod: "basic",
username,
password,
lookAheadDays,
})
const context = { time: new Date() }
console.log(`\nFetching from ${serverUrl} as ${username} (lookAheadDays=${lookAheadDays})...\n`)
const contextResult = await source.fetchContext(context)
const items = await source.fetchItems(context)
console.log("=== Context ===")
console.log(JSON.stringify(contextResult, null, 2))
console.log(`\n=== Feed Items (${items.length}) ===`)
for (const item of items) {
console.log(`\n--- ${item.data.title} ---`)
console.log(` ID: ${item.id}`)
console.log(` Calendar: ${item.data.calendarName ?? "(unknown)"}`)
console.log(` Start: ${item.data.startDate.toISOString()}`)
console.log(` End: ${item.data.endDate.toISOString()}`)
console.log(` All-day: ${item.data.isAllDay}`)
console.log(` Location: ${item.data.location ?? "(none)"}`)
console.log(` Status: ${item.data.status ?? "(none)"}`)
console.log(` Urgency: ${item.signals?.urgency}`)
console.log(` Relevance: ${item.signals?.timeRelevance}`)
if (item.data.attendees.length > 0) {
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
}
if (item.data.description) {
console.log(` Desc: ${item.data.description.slice(0, 100)}`)
}
}
if (items.length === 0) {
console.log("(no events found in the time window)")
}

View File

@@ -6,16 +6,14 @@ import { readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import type { import type {
CalendarCredentialProvider, CalDavDAVCalendar,
CalendarCredentials, CalDavDAVClient,
CalendarDAVCalendar, CalDavDAVObject,
CalendarDAVClient, CalDavEventData,
CalendarDAVObject,
CalendarEventData,
} from "./types.ts" } from "./types.ts"
import { CalendarKey } from "./calendar-context.ts" import { CalDavSource, computeSignals } from "./caldav-source.ts"
import { CalendarSource, computeSignals } from "./calendar-source.ts" import { CalDavCalendarKey } from "./calendar-context.ts"
function loadFixture(name: string): string { function loadFixture(name: string): string {
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8") return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
@@ -25,36 +23,16 @@ function createContext(time: Date): Context {
return { time } return { time }
} }
const mockCredentials: CalendarCredentials = { class MockDAVClient implements CalDavDAVClient {
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> = {} credentials: Record<string, unknown> = {}
fetchCalendarsCallCount = 0 fetchCalendarsCallCount = 0
private calendars: CalendarDAVCalendar[] lastTimeRange: { start: string; end: string } | null = null
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]> private calendars: CalDavDAVCalendar[]
private objectsByCalendarUrl: Record<string, CalDavDAVObject[]>
constructor( constructor(
calendars: CalendarDAVCalendar[], calendars: CalDavDAVCalendar[],
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>, objectsByCalendarUrl: Record<string, CalDavDAVObject[]>,
) { ) {
this.calendars = calendars this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl this.objectsByCalendarUrl = objectsByCalendarUrl
@@ -62,54 +40,57 @@ class MockDAVClient implements CalendarDAVClient {
async login(): Promise<void> {} async login(): Promise<void> {}
async fetchCalendars(): Promise<CalendarDAVCalendar[]> { async fetchCalendars(): Promise<CalDavDAVCalendar[]> {
this.fetchCalendarsCallCount++ this.fetchCalendarsCallCount++
return this.calendars return this.calendars
} }
async fetchCalendarObjects(params: { async fetchCalendarObjects(params: {
calendar: CalendarDAVCalendar calendar: CalDavDAVCalendar
timeRange: { start: string; end: string } timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]> { }): Promise<CalDavDAVObject[]> {
this.lastTimeRange = params.timeRange
return this.objectsByCalendarUrl[params.calendar.url] ?? [] return this.objectsByCalendarUrl[params.calendar.url] ?? []
} }
} }
describe("CalendarSource", () => { function createSource(client: MockDAVClient, lookAheadDays?: number): CalDavSource {
test("has correct id", () => { return new CalDavSource({
const source = new CalendarSource(new NullCredentialProvider(), "user-1") serverUrl: "https://caldav.example.com",
expect(source.id).toBe("aris.apple-calendar") authMethod: "basic",
username: "user",
password: "pass",
davClient: client,
lookAheadDays,
}) })
}
test("returns empty array when credentials are null", async () => { describe("CalDavSource", () => {
const source = new CalendarSource(new NullCredentialProvider(), "user-1") test("has correct id", () => {
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const client = new MockDAVClient([], {})
expect(items).toEqual([]) const source = createSource(client)
expect(source.id).toBe("aris.caldav")
}) })
test("returns empty array when no calendars exist", async () => { test("returns empty array when no calendars exist", async () => {
const client = new MockDAVClient([], {}) const client = new MockDAVClient([], {})
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toEqual([]) expect(items).toEqual([])
}) })
test("returns feed items from a single calendar", async () => { test("returns feed items from a single calendar", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1) expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("calendar-event") expect(items[0]!.type).toBe("caldav-event")
expect(items[0]!.id).toBe("calendar-event-single-event-001@test") expect(items[0]!.id).toBe("caldav-event-single-event-001@test")
expect(items[0]!.data.title).toBe("Team Standup") expect(items[0]!.data.title).toBe("Team Standup")
expect(items[0]!.data.location).toBe("Conference Room A") expect(items[0]!.data.location).toBe("Conference Room A")
expect(items[0]!.data.calendarName).toBe("Work") expect(items[0]!.data.calendarName).toBe("Work")
@@ -118,7 +99,7 @@ describe("CalendarSource", () => {
}) })
test("returns feed items from multiple calendars", async () => { test("returns feed items from multiple calendars", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
"/cal/personal": [ "/cal/personal": [
{ {
@@ -134,9 +115,7 @@ describe("CalendarSource", () => {
], ],
objects, objects,
) )
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
@@ -154,7 +133,7 @@ describe("CalendarSource", () => {
}) })
test("skips objects with non-string data", async () => { test("skips objects with non-string data", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/bad.ics", data: 12345 }, { url: "/cal/work/bad.ics", data: 12345 },
@@ -162,9 +141,7 @@ describe("CalendarSource", () => {
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items).toHaveLength(1) expect(items).toHaveLength(1)
@@ -172,13 +149,11 @@ describe("CalendarSource", () => {
}) })
test("uses context time as feed item timestamp", async () => { test("uses context time as feed item timestamp", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const now = new Date("2026-01-15T12:00:00Z") const now = new Date("2026-01-15T12:00:00Z")
const items = await source.fetchItems(createContext(now)) const items = await source.fetchItems(createContext(now))
@@ -186,16 +161,14 @@ describe("CalendarSource", () => {
}) })
test("assigns signals based on event proximity", async () => { test("assigns signals based on event proximity", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }, { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
// 2 hours before the event at 14:00 // 2 hours before the event at 14:00
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
@@ -210,7 +183,7 @@ describe("CalendarSource", () => {
}) })
test("handles calendar with non-string displayName", async () => { test("handles calendar with non-string displayName", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/weird": [ "/cal/weird": [
{ {
url: "/cal/weird/event1.ics", url: "/cal/weird/event1.ics",
@@ -222,16 +195,14 @@ describe("CalendarSource", () => {
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }], [{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
objects, objects,
) )
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
expect(items[0]!.data.calendarName).toBeNull() expect(items[0]!.data.calendarName).toBeNull()
}) })
test("handles recurring events with exceptions", async () => { test("handles recurring events with exceptions", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ {
url: "/cal/work/recurring.ics", url: "/cal/work/recurring.ics",
@@ -240,9 +211,7 @@ describe("CalendarSource", () => {
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z"))) const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
@@ -260,13 +229,11 @@ describe("CalendarSource", () => {
}) })
test("caches events within the same refresh cycle", async () => { test("caches events within the same refresh cycle", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const context = createContext(new Date("2026-01-15T12:00:00Z")) const context = createContext(new Date("2026-01-15T12:00:00Z"))
@@ -277,15 +244,52 @@ describe("CalendarSource", () => {
expect(client.fetchCalendarsCallCount).toBe(1) expect(client.fetchCalendarsCallCount).toBe(1)
}) })
test("refetches events for a different context time", async () => { test("uses timezone for time range when provided", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
// 2026-01-15T22:00:00Z = 2026-01-16T09:00:00 in Australia/Sydney (AEDT, UTC+11)
const source = new CalDavSource({
serverUrl: "https://caldav.example.com",
authMethod: "basic",
username: "user",
password: "pass",
davClient: client, davClient: client,
timeZone: "Australia/Sydney",
}) })
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
// "Today" in Sydney is Jan 16, so start should be Jan 15 13:00 UTC (midnight Jan 16 AEDT)
expect(client.lastTimeRange).not.toBeNull()
expect(client.lastTimeRange!.start).toBe("2026-01-15T13:00:00.000Z")
// End should be Jan 16 13:00 UTC (midnight Jan 17 AEDT) — 1 day window
expect(client.lastTimeRange!.end).toBe("2026-01-16T13:00:00.000Z")
})
test("defaults to UTC midnight when no timezone provided", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
await source.fetchItems(createContext(new Date("2026-01-15T22:00:00Z")))
expect(client.lastTimeRange).not.toBeNull()
expect(client.lastTimeRange!.start).toBe("2026-01-15T00:00:00.000Z")
expect(client.lastTimeRange!.end).toBe("2026-01-16T00:00:00.000Z")
})
test("refetches events for a different context time", async () => {
const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = createSource(client)
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z"))) await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z"))) await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
@@ -294,11 +298,12 @@ describe("CalendarSource", () => {
}) })
}) })
describe("CalendarSource.fetchContext", () => { describe("CalDavSource.fetchContext", () => {
test("returns empty context when credentials are null", async () => { test("returns empty context when no calendars exist", async () => {
const source = new CalendarSource(new NullCredentialProvider(), "user-1") const client = new MockDAVClient([], {})
const source = createSource(client)
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar).toBeDefined() expect(calendar).toBeDefined()
expect(calendar!.inProgress).toEqual([]) expect(calendar!.inProgress).toEqual([])
@@ -308,34 +313,30 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("identifies in-progress events", async () => { test("identifies in-progress events", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
// 14:30 is during the 14:00-15:00 event // 14:30 is during the 14:00-15:00 event
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(1) expect(calendar!.inProgress).toHaveLength(1)
expect(calendar!.inProgress[0]!.title).toBe("Team Standup") expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
}) })
test("identifies next upcoming event", async () => { test("identifies next upcoming event", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }], "/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
// 12:00 is before the 14:00 event // 12:00 is before the 14:00 event
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(0) expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).not.toBeNull() expect(calendar!.nextEvent).not.toBeNull()
@@ -343,16 +344,14 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("excludes all-day events from inProgress and nextEvent", async () => { test("excludes all-day events from inProgress and nextEvent", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }], "/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.inProgress).toHaveLength(0) expect(calendar!.inProgress).toHaveLength(0)
expect(calendar!.nextEvent).toBeNull() expect(calendar!.nextEvent).toBeNull()
@@ -361,19 +360,17 @@ describe("CalendarSource.fetchContext", () => {
}) })
test("counts all events including all-day in todayEventCount", async () => { test("counts all events including all-day in todayEventCount", async () => {
const objects: Record<string, CalendarDAVObject[]> = { const objects: Record<string, CalDavDAVObject[]> = {
"/cal/work": [ "/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }, { url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }, { url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
], ],
} }
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects) const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", { const source = createSource(client)
davClient: client,
})
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z"))) const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
const calendar = contextValue(ctx as Context, CalendarKey) const calendar = contextValue(ctx as Context, CalDavCalendarKey)
expect(calendar!.todayEventCount).toBe(2) expect(calendar!.todayEventCount).toBe(2)
expect(calendar!.hasTodayEvents).toBe(true) expect(calendar!.hasTodayEvents).toBe(true)
@@ -383,7 +380,7 @@ describe("CalendarSource.fetchContext", () => {
describe("computeSignals", () => { describe("computeSignals", () => {
const now = new Date("2026-01-15T12:00:00Z") const now = new Date("2026-01-15T12:00:00Z")
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData { function makeEvent(overrides: Partial<CalDavEventData>): CalDavEventData {
return { return {
uid: "test-uid", uid: "test-uid",
title: "Test", title: "Test",
@@ -482,4 +479,29 @@ describe("computeSignals", () => {
}) })
expect(computeSignals(event2h1m, now).urgency).toBe(0.5) expect(computeSignals(event2h1m, now).urgency).toBe(0.5)
}) })
test("cancelled events get urgency 0.1 regardless of timing", () => {
const event = makeEvent({
status: "cancelled",
startDate: new Date("2026-01-15T12:20:00Z"), // would be 0.9 if not cancelled
})
const signals = computeSignals(event, now)
expect(signals.urgency).toBe(0.1)
expect(signals.timeRelevance).toBe(TimeRelevance.Ambient)
})
test("uses timezone for 'later today' boundary", () => {
// now = 2026-01-15T12:00:00Z = 2026-01-15T21:00:00 JST (UTC+9)
// event at 2026-01-15T15:30:00Z = 2026-01-16T00:30:00 JST — next day in JST
const event = makeEvent({
startDate: new Date("2026-01-15T15:30:00Z"),
})
// Without timezone: UTC day ends at 2026-01-16T00:00:00Z, event is before that → "later today"
expect(computeSignals(event, now).urgency).toBe(0.5)
// With Asia/Tokyo: local day ends at 2026-01-15T15:00:00Z (midnight Jan 16 JST),
// event is after that → "future days"
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
})
}) })

View File

@@ -0,0 +1,348 @@
import type { ActionDefinition, Context, FeedItemSignals, FeedSource } from "@aris/core"
import { TimeRelevance, UnknownActionError } from "@aris/core"
import { DAVClient } from "tsdav"
import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.ts"
import { CalDavEventStatus } from "./types.ts"
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
// -- Source options --
interface CalDavSourceBaseOptions {
serverUrl: string
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** IANA timezone for determining "today" (e.g. "America/New_York"). Default: UTC. */
timeZone?: string
/** Optional DAV client for testing. */
davClient?: CalDavDAVClient
}
interface CalDavSourceBasicAuthOptions extends CalDavSourceBaseOptions {
authMethod: "basic"
username: string
password: string
}
interface CalDavSourceOAuthOptions extends CalDavSourceBaseOptions {
authMethod: "oauth"
accessToken: string
refreshToken: string
tokenUrl: string
expiration?: number
clientId?: string
clientSecret?: string
}
export type CalDavSourceOptions = CalDavSourceBasicAuthOptions | CalDavSourceOAuthOptions
const DEFAULT_LOOK_AHEAD_DAYS = 0
/**
* A FeedSource that fetches calendar events from any CalDAV server.
*
* Supports Basic auth (username/password) and OAuth (access token + refresh token).
* The server URL is provided at construction time.
*
* @example
* ```ts
* // Basic auth (self-hosted servers)
* const source = new CalDavSource({
* serverUrl: "https://nextcloud.example.com/remote.php/dav",
* authMethod: "basic",
* username: "user",
* password: "pass",
* })
*
* // OAuth (cloud providers)
* const source = new CalDavSource({
* serverUrl: "https://caldav.provider.com",
* authMethod: "oauth",
* accessToken: "...",
* refreshToken: "...",
* tokenUrl: "https://provider.com/oauth/token",
* })
* ```
*/
export class CalDavSource implements FeedSource<CalDavFeedItem> {
readonly id = "aris.caldav"
private options: CalDavSourceOptions | null
private readonly lookAheadDays: number
private readonly timeZone: string | undefined
private readonly injectedClient: CalDavDAVClient | null
private clientPromise: Promise<CalDavDAVClient> | null = null
private cachedEvents: { time: Date; events: CalDavEventData[] } | null = null
private pendingFetch: { time: Date; promise: Promise<CalDavEventData[]> } | null = null
constructor(options: CalDavSourceOptions) {
this.options = options
this.lookAheadDays = options.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
this.timeZone = options.timeZone
this.injectedClient = options.davClient ?? null
}
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
}
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
}
async fetchContext(context: Context): Promise<Partial<Context> | null> {
const events = await this.fetchEvents(context)
if (events.length === 0) {
return {
[CalDavCalendarKey]: {
inProgress: [],
nextEvent: null,
hasTodayEvents: false,
todayEventCount: 0,
},
}
}
const now = context.time
const active = events.filter((e) => e.status !== CalDavEventStatus.Cancelled)
const inProgress = active.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
const upcoming = active
.filter((e) => !e.isAllDay && e.startDate > now)
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
const calendarContext: CalendarContext = {
inProgress,
nextEvent: upcoming[0] ?? null,
hasTodayEvents: events.length > 0,
todayEventCount: events.length,
}
return { [CalDavCalendarKey]: calendarContext }
}
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
const now = context.time
const events = await this.fetchEvents(context)
return events.map((event) => createFeedItem(event, now, this.timeZone))
}
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return Promise.resolve(this.cachedEvents.events)
}
// Deduplicate concurrent fetches for the same context.time reference
if (this.pendingFetch && this.pendingFetch.time === context.time) {
return this.pendingFetch.promise
}
const promise = this.doFetchEvents(context).finally(() => {
if (this.pendingFetch?.promise === promise) {
this.pendingFetch = null
}
})
this.pendingFetch = { time: context.time, promise }
return promise
}
private async doFetchEvents(context: Context): Promise<CalDavEventData[]> {
const client = await this.connectClient()
const calendars = await client.fetchCalendars()
const { start, end } = computeTimeRange(context.time, this.lookAheadDays, this.timeZone)
const results = await Promise.allSettled(
calendars.map(async (calendar) => {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: {
start: start.toISOString(),
end: end.toISOString(),
},
})
// tsdav types displayName as string | Record<string, unknown> | undefined
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
return { objects, calendarName }
}),
)
const allEvents: CalDavEventData[] = []
for (const result of results) {
if (result.status === "rejected") {
console.warn("[aris.caldav] Failed to fetch calendar:", result.reason)
continue
}
const { objects, calendarName } = result.value
for (const obj of objects) {
if (typeof obj.data !== "string") continue
const events = parseICalEvents(obj.data, calendarName)
for (const event of events) {
allEvents.push(event)
}
}
}
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents
}
private connectClient(): Promise<CalDavDAVClient> {
if (this.injectedClient) {
return Promise.resolve(this.injectedClient)
}
if (!this.clientPromise) {
this.clientPromise = this.createAndLoginClient().catch((err) => {
this.clientPromise = null
throw err
})
}
return this.clientPromise
}
private async createAndLoginClient(): Promise<CalDavDAVClient> {
const opts = this.options
if (!opts) {
throw new Error("CalDavSource options have already been consumed")
}
let client: CalDavDAVClient
if (opts.authMethod === "basic") {
client = new DAVClient({
serverUrl: opts.serverUrl,
credentials: {
username: opts.username,
password: opts.password,
},
authMethod: "Basic",
defaultAccountType: "caldav",
})
} else {
client = new DAVClient({
serverUrl: opts.serverUrl,
credentials: {
tokenUrl: opts.tokenUrl,
refreshToken: opts.refreshToken,
accessToken: opts.accessToken,
expiration: opts.expiration,
clientId: opts.clientId,
clientSecret: opts.clientSecret,
},
authMethod: "Oauth",
defaultAccountType: "caldav",
})
}
await client.login()
this.options = null
return client
}
}
function computeTimeRange(
now: Date,
lookAheadDays: number,
timeZone?: string,
): { start: Date; end: Date } {
const start = startOfDay(now, timeZone)
const end = new Date(start.getTime() + (1 + lookAheadDays) * 24 * 60 * 60 * 1000)
return { start, end }
}
/**
* Returns midnight (start of day) as a UTC Date.
* When timeZone is provided, "midnight" is local midnight in that timezone
* converted to UTC. Otherwise, UTC midnight.
*/
function startOfDay(date: Date, timeZone?: string): Date {
if (!timeZone) {
const d = new Date(date)
d.setUTCHours(0, 0, 0, 0)
return d
}
// Extract the local year/month/day in the target timezone
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date)
const year = Number(parts.find((p) => p.type === "year")!.value)
const month = Number(parts.find((p) => p.type === "month")!.value)
const day = Number(parts.find((p) => p.type === "day")!.value)
// Binary-search-free approach: construct a UTC date at the local date's noon,
// then use the timezone offset at that moment to find local midnight in UTC.
const noonUtc = Date.UTC(year, month - 1, day, 12, 0, 0)
const noonLocal = new Date(noonUtc).toLocaleString("sv-SE", { timeZone, hour12: false })
// sv-SE locale formats as "YYYY-MM-DD HH:MM:SS" which Date can parse
const noonLocalMs = new Date(noonLocal + "Z").getTime()
const offsetMs = noonLocalMs - noonUtc
return new Date(Date.UTC(year, month - 1, day) - offsetMs)
}
export function computeSignals(
event: CalDavEventData,
now: Date,
timeZone?: string,
): FeedItemSignals {
if (event.status === CalDavEventStatus.Cancelled) {
return { urgency: 0.1, timeRelevance: TimeRelevance.Ambient }
}
if (event.isAllDay) {
return { urgency: 0.3, timeRelevance: TimeRelevance.Ambient }
}
const msUntilStart = event.startDate.getTime() - now.getTime()
// Event already started
if (msUntilStart < 0) {
const isInProgress = now.getTime() < event.endDate.getTime()
return isInProgress
? { urgency: 0.8, timeRelevance: TimeRelevance.Imminent }
: { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
// Starting within 30 minutes
if (msUntilStart <= 30 * 60 * 1000) {
return { urgency: 0.9, timeRelevance: TimeRelevance.Imminent }
}
// Starting within 2 hours
if (msUntilStart <= 2 * 60 * 60 * 1000) {
return { urgency: 0.7, timeRelevance: TimeRelevance.Upcoming }
}
// Later today (using local day boundary when timeZone is set)
const todayStart = startOfDay(now, timeZone)
const endOfDay = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000)
if (event.startDate.getTime() < endOfDay.getTime()) {
return { urgency: 0.5, timeRelevance: TimeRelevance.Upcoming }
}
// Future days
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
}
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
return {
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
type: "caldav-event",
timestamp: now,
data: event,
signals: computeSignals(event, now, timeZone),
}
}

View File

@@ -2,23 +2,23 @@ import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core" import { contextKey } from "@aris/core"
import type { CalendarEventData } from "./types.ts" import type { CalDavEventData } from "./types.ts"
/** /**
* Calendar context for downstream sources. * Calendar context for downstream sources.
* *
* Provides a snapshot of the user's upcoming events so other sources * Provides a snapshot of the user's upcoming CalDAV events so other sources
* can adapt (e.g. a commute source checking if there's a meeting soon). * can adapt (e.g. a commute source checking if there's a meeting soon).
*/ */
export interface CalendarContext { export interface CalendarContext {
/** Events happening right now */ /** Events happening right now */
inProgress: CalendarEventData[] inProgress: CalDavEventData[]
/** Next upcoming event, if any */ /** Next upcoming event, if any */
nextEvent: CalendarEventData | null nextEvent: CalDavEventData | null
/** Whether the user has any events today */ /** Whether the user has any events today */
hasTodayEvents: boolean hasTodayEvents: boolean
/** Total number of events today */ /** Total number of events today */
todayEventCount: number todayEventCount: number
} }
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar") export const CalDavCalendarKey: ContextKey<CalendarContext> = contextKey("caldavCalendar")

View File

@@ -3,20 +3,20 @@ import ICAL from "ical.js"
import { import {
AttendeeRole, AttendeeRole,
AttendeeStatus, AttendeeStatus,
CalendarEventStatus, CalDavEventStatus,
type CalendarAlarm, type CalDavAlarm,
type CalendarAttendee, type CalDavAttendee,
type CalendarEventData, type CalDavEventData,
} from "./types.ts" } from "./types.ts"
/** /**
* Parses a raw iCalendar string and extracts all VEVENT components * Parses a raw iCalendar string and extracts all VEVENT components
* into CalendarEventData objects. * into CalDavEventData objects.
* *
* @param icsData - Raw iCalendar string from a CalDAV response * @param icsData - Raw iCalendar string from a CalDAV response
* @param calendarName - Display name of the calendar this event belongs to * @param calendarName - Display name of the calendar this event belongs to
*/ */
export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] { export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
const jcal = ICAL.parse(icsData) const jcal = ICAL.parse(icsData)
const comp = new ICAL.Component(jcal) const comp = new ICAL.Component(jcal)
const vevents = comp.getAllSubcomponents("vevent") const vevents = comp.getAllSubcomponents("vevent")
@@ -29,7 +29,7 @@ export function parseICalEvents(icsData: string, calendarName: string | null): C
function parseVEvent( function parseVEvent(
vevent: InstanceType<typeof ICAL.Component>, vevent: InstanceType<typeof ICAL.Component>,
calendarName: string | null, calendarName: string | null,
): CalendarEventData { ): CalDavEventData {
const event = new ICAL.Event(vevent) const event = new ICAL.Event(vevent)
return { return {
@@ -50,15 +50,15 @@ function parseVEvent(
} }
} }
function parseStatus(raw: string | null): CalendarEventStatus | null { function parseStatus(raw: string | null): CalDavEventStatus | null {
if (!raw) return null if (!raw) return null
switch (raw.toLowerCase()) { switch (raw.toLowerCase()) {
case "confirmed": case "confirmed":
return CalendarEventStatus.Confirmed return CalDavEventStatus.Confirmed
case "tentative": case "tentative":
return CalendarEventStatus.Tentative return CalDavEventStatus.Tentative
case "cancelled": case "cancelled":
return CalendarEventStatus.Cancelled return CalDavEventStatus.Cancelled
default: default:
return null return null
} }
@@ -81,22 +81,25 @@ function parseOrganizer(
return value.replace(/^mailto:/i, "") return value.replace(/^mailto:/i, "")
} }
function parseAttendees(properties: unknown[]): CalendarAttendee[] { function parseAttendees(properties: unknown[]): CalDavAttendee[] {
if (properties.length === 0) return [] if (properties.length === 0) return []
return properties.map((prop) => { return properties.flatMap((prop) => {
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
const p = prop as InstanceType<typeof ICAL.Property> const p = prop as InstanceType<typeof ICAL.Property>
const value = asStringOrNull(p.getFirstValue()) const value = asStringOrNull(p.getFirstValue())
const cn = asStringOrNull(p.getParameter("cn")) const cn = asStringOrNull(p.getParameter("cn"))
const role = asStringOrNull(p.getParameter("role")) const role = asStringOrNull(p.getParameter("role"))
const partstat = asStringOrNull(p.getParameter("partstat")) const partstat = asStringOrNull(p.getParameter("partstat"))
return { return [
name: cn, {
email: value ? value.replace(/^mailto:/i, "") : null, name: cn,
role: parseAttendeeRole(role), email: value ? value.replace(/^mailto:/i, "") : null,
status: parseAttendeeStatus(partstat), role: parseAttendeeRole(role),
} status: parseAttendeeStatus(partstat),
},
]
}) })
} }
@@ -130,7 +133,7 @@ function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
} }
} }
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] { function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
const valarms = vevent.getAllSubcomponents("valarm") const valarms = vevent.getAllSubcomponents("valarm")
if (!valarms || valarms.length === 0) return [] if (!valarms || valarms.length === 0) return []

View File

@@ -0,0 +1,15 @@
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
export { parseICalEvents } from "./ical-parser.ts"
export {
AttendeeRole,
AttendeeStatus,
CalDavEventStatus,
type CalDavAlarm,
type CalDavAttendee,
type CalDavDAVCalendar,
type CalDavDAVClient,
type CalDavDAVObject,
type CalDavEventData,
type CalDavFeedItem,
} from "./types.ts"

View File

@@ -1,30 +1,16 @@
import type { FeedItem } from "@aris/core" import type { FeedItem } from "@aris/core"
// -- Credential provider -- // -- Event status --
export interface CalendarCredentials { export const CalDavEventStatus = {
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", Confirmed: "confirmed",
Tentative: "tentative", Tentative: "tentative",
Cancelled: "cancelled", Cancelled: "cancelled",
} as const } as const
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus] export type CalDavEventStatus = (typeof CalDavEventStatus)[keyof typeof CalDavEventStatus]
// -- Attendee types --
export const AttendeeRole = { export const AttendeeRole = {
Chair: "chair", Chair: "chair",
@@ -43,21 +29,25 @@ export const AttendeeStatus = {
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus] export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
export interface CalendarAttendee { export interface CalDavAttendee {
name: string | null name: string | null
email: string | null email: string | null
role: AttendeeRole | null role: AttendeeRole | null
status: AttendeeStatus | null status: AttendeeStatus | null
} }
export interface CalendarAlarm { // -- Alarm --
export interface CalDavAlarm {
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */ /** ISO 8601 duration relative to event start, e.g. "-PT15M" */
trigger: string trigger: string
/** e.g. "DISPLAY", "AUDIO" */ /** e.g. "DISPLAY", "AUDIO" */
action: string action: string
} }
export interface CalendarEventData extends Record<string, unknown> { // -- Event data --
export interface CalDavEventData extends Record<string, unknown> {
uid: string uid: string
title: string title: string
startDate: Date startDate: Date
@@ -66,36 +56,38 @@ export interface CalendarEventData extends Record<string, unknown> {
location: string | null location: string | null
description: string | null description: string | null
calendarName: string | null calendarName: string | null
status: CalendarEventStatus | null status: CalDavEventStatus | null
url: string | null url: string | null
organizer: string | null organizer: string | null
attendees: CalendarAttendee[] attendees: CalDavAttendee[]
alarms: CalendarAlarm[] alarms: CalDavAlarm[]
recurrenceId: string | null recurrenceId: string | null
} }
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData> // -- Feed item --
export type CalDavFeedItem = FeedItem<"caldav-event", CalDavEventData>
// -- DAV client interface -- // -- DAV client interface --
export interface CalendarDAVObject { export interface CalDavDAVObject {
data?: unknown data?: unknown
etag?: string etag?: string
url: string url: string
} }
export interface CalendarDAVCalendar { export interface CalDavDAVCalendar {
displayName?: string | Record<string, unknown> displayName?: string | Record<string, unknown>
url: string url: string
} }
/** Subset of DAVClient used by CalendarSource. */ /** Subset of tsdav's DAVClient used by CalDavSource. */
export interface CalendarDAVClient { export interface CalDavDAVClient {
login(): Promise<void> login(): Promise<void>
fetchCalendars(): Promise<CalendarDAVCalendar[]> fetchCalendars(): Promise<CalDavDAVCalendar[]>
fetchCalendarObjects(params: { fetchCalendarObjects(params: {
calendar: CalendarDAVCalendar calendar: CalDavDAVCalendar
timeRange: { start: string; end: string } timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]> }): Promise<CalDavDAVObject[]>
credentials: Record<string, unknown> credentials: Record<string, unknown>
} }