mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
* 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>
349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
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),
|
|
}
|
|
}
|