mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
2 Commits
c78ad25f0d
...
feat/calda
| Author | SHA1 | Date | |
|---|---|---|---|
|
2717ec1b30
|
|||
|
a4baef521f
|
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:daily-allday-001@test
|
||||
DTSTART;VALUE=DATE:20260112
|
||||
DTEND;VALUE=DATE:20260113
|
||||
SUMMARY:Daily Reminder
|
||||
RRULE:FREQ=DAILY;COUNT=7
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:weekly-exc-001@test
|
||||
DTSTART:20260101T140000Z
|
||||
DTEND:20260101T150000Z
|
||||
SUMMARY:Standup
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=8
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:weekly-exc-001@test
|
||||
RECURRENCE-ID:20260115T140000Z
|
||||
DTSTART:20260115T160000Z
|
||||
DTEND:20260115T170000Z
|
||||
SUMMARY:Standup (rescheduled)
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
13
packages/aris-source-caldav/fixtures/weekly-recurring.ics
Normal file
13
packages/aris-source-caldav/fixtures/weekly-recurring.ics
Normal file
@@ -0,0 +1,13 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:weekly-001@test
|
||||
DTSTART:20260101T100000Z
|
||||
DTEND:20260101T110000Z
|
||||
SUMMARY:Weekly Team Meeting
|
||||
RRULE:FREQ=WEEKLY;BYDAY=TH;COUNT=10
|
||||
LOCATION:Room B
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -3,8 +3,13 @@
|
||||
*
|
||||
* Usage:
|
||||
* bun run test-live.ts
|
||||
*
|
||||
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { Context } from "@aris/core"
|
||||
|
||||
import { CalDavSource } from "../src/index.ts"
|
||||
@@ -51,6 +56,9 @@ for (const item of items) {
|
||||
console.log(` Status: ${item.data.status ?? "(none)"}`)
|
||||
console.log(` Urgency: ${item.signals?.urgency}`)
|
||||
console.log(` Relevance: ${item.signals?.timeRelevance}`)
|
||||
if (item.slots) {
|
||||
console.log(` Slots: ${Object.keys(item.slots).join(", ")}`)
|
||||
}
|
||||
if (item.data.attendees.length > 0) {
|
||||
console.log(` Attendees: ${item.data.attendees.map((a) => a.name ?? a.email).join(", ")}`)
|
||||
}
|
||||
@@ -62,3 +70,11 @@ for (const item of items) {
|
||||
if (items.length === 0) {
|
||||
console.log("(no events found in the time window)")
|
||||
}
|
||||
|
||||
// Write feed items to .cache for slot testing
|
||||
const cacheDir = join(import.meta.dir, ".cache")
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
|
||||
const outPath = join(cacheDir, "feed-items.json")
|
||||
writeFileSync(outPath, JSON.stringify(items, null, 2))
|
||||
console.log(`\nFeed items written to ${outPath}`)
|
||||
|
||||
@@ -208,7 +208,7 @@ describe("CalDavSource", () => {
|
||||
expect(items[0]!.data.calendarName).toBeNull()
|
||||
})
|
||||
|
||||
test("handles recurring events with exceptions", async () => {
|
||||
test("expands recurring events within the time range", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{
|
||||
@@ -218,21 +218,42 @@ describe("CalDavSource", () => {
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
// lookAheadDays=0 → range is Jan 15 only
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
// Only the Jan 15 occurrence falls in the single-day window
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.data.title).toBe("Weekly Sync")
|
||||
expect(items[0]!.data.startDate).toEqual(new Date("2026-01-15T09:00:00Z"))
|
||||
})
|
||||
|
||||
const base = items.find((i) => i.data.title === "Weekly Sync")
|
||||
test("includes exception overrides when they fall in range", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [
|
||||
{
|
||||
url: "/cal/work/recurring.ics",
|
||||
data: loadFixture("recurring-event.ics"),
|
||||
},
|
||||
],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
// lookAheadDays=8 → range covers Jan 15 through Jan 23, includes the Jan 22 exception
|
||||
const source = createSource(client, 8)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||
|
||||
const base = items.filter((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()
|
||||
// Jan 15 base occurrence
|
||||
expect(base.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Jan 22 exception replaces the base occurrence
|
||||
expect(exception).toBeDefined()
|
||||
expect(exception!.data.recurrenceId).not.toBeNull()
|
||||
expect(exception!.id).toContain("-")
|
||||
expect(exception!.data.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
||||
expect(exception!.data.endDate).toEqual(new Date("2026-01-22T10:30:00Z"))
|
||||
})
|
||||
|
||||
test("caches events within the same refresh cycle", async () => {
|
||||
@@ -512,3 +533,69 @@ describe("computeSignals", () => {
|
||||
expect(computeSignals(event, now, "Asia/Tokyo").urgency).toBe(0.2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CalDavSource feed item slots", () => {
|
||||
const EXPECTED_SLOT_NAMES = ["insight", "preparation", "crossSource"]
|
||||
|
||||
test("timed event has all three slots with null content", 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)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
const item = items[0]!
|
||||
expect(item.slots).toBeDefined()
|
||||
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||
|
||||
for (const name of EXPECTED_SLOT_NAMES) {
|
||||
const slot = item.slots![name]!
|
||||
expect(slot.content).toBeNull()
|
||||
expect(typeof slot.description).toBe("string")
|
||||
expect(slot.description.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("all-day event has all three slots with null content", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/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 = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
const item = items[0]!
|
||||
expect(item.data.isAllDay).toBe(true)
|
||||
expect(item.slots).toBeDefined()
|
||||
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||
|
||||
for (const name of EXPECTED_SLOT_NAMES) {
|
||||
expect(item.slots![name]!.content).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancelled event has all three slots with null content", async () => {
|
||||
const objects: Record<string, CalDavDAVObject[]> = {
|
||||
"/cal/work": [{ url: "/cal/work/cancelled.ics", data: loadFixture("cancelled-event.ics") }],
|
||||
}
|
||||
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
|
||||
const source = createSource(client)
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
const item = items[0]!
|
||||
expect(item.data.status).toBe("cancelled")
|
||||
expect(item.slots).toBeDefined()
|
||||
expect(Object.keys(item.slots!).sort()).toEqual([...EXPECTED_SLOT_NAMES].sort())
|
||||
|
||||
for (const name of EXPECTED_SLOT_NAMES) {
|
||||
expect(item.slots![name]!.content).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from "@aris/core"
|
||||
import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource, Slot } from "@aris/core"
|
||||
|
||||
import { Context, TimeRelevance, UnknownActionError } from "@aris/core"
|
||||
import { DAVClient } from "tsdav"
|
||||
@@ -7,6 +7,9 @@ import type { CalDavDAVClient, CalDavEventData, CalDavFeedItem } from "./types.t
|
||||
|
||||
import { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
import { parseICalEvents } from "./ical-parser.ts"
|
||||
import crossSourcePrompt from "./prompts/cross-source.txt"
|
||||
import insightPrompt from "./prompts/insight.txt"
|
||||
import preparationPrompt from "./prompts/preparation.txt"
|
||||
import { CalDavEventStatus, CalDavFeedItemType } from "./types.ts"
|
||||
|
||||
// -- Source options --
|
||||
@@ -184,7 +187,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
||||
for (const obj of objects) {
|
||||
if (typeof obj.data !== "string") continue
|
||||
|
||||
const events = parseICalEvents(obj.data, calendarName)
|
||||
const events = parseICalEvents(obj.data, calendarName, { start, end })
|
||||
for (const event of events) {
|
||||
allEvents.push(event)
|
||||
}
|
||||
@@ -340,6 +343,14 @@ export function computeSignals(
|
||||
return { urgency: 0.2, timeRelevance: TimeRelevance.Ambient }
|
||||
}
|
||||
|
||||
function createEventSlots(): Record<string, Slot> {
|
||||
return {
|
||||
insight: { description: insightPrompt, content: null },
|
||||
preparation: { description: preparationPrompt, content: null },
|
||||
crossSource: { description: crossSourcePrompt, content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||
return {
|
||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||
@@ -347,5 +358,6 @@ function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): C
|
||||
timestamp: now,
|
||||
data: event,
|
||||
signals: computeSignals(event, now, timeZone),
|
||||
slots: createEventSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,3 +105,94 @@ describe("parseICalEvents", () => {
|
||||
expect(events[0]!.status).toBe("cancelled")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseICalEvents with timeRange (recurrence expansion)", () => {
|
||||
test("expands weekly recurring event into occurrences within range", () => {
|
||||
// weekly-recurring.ics: DTSTART 2026-01-01 (Thu), FREQ=WEEKLY;BYDAY=TH;COUNT=10
|
||||
// Occurrences: Jan 1, 8, 15, 22, 29, Feb 5, 12, 19, 26, Mar 5
|
||||
// Query window: Jan 14 – Jan 23 → should get Jan 15 and Jan 22
|
||||
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), "Work", {
|
||||
start: new Date("2026-01-14T00:00:00Z"),
|
||||
end: new Date("2026-01-23T00:00:00Z"),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T10:00:00Z"))
|
||||
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T11:00:00Z"))
|
||||
expect(events[1]!.startDate).toEqual(new Date("2026-01-22T10:00:00Z"))
|
||||
expect(events[1]!.endDate).toEqual(new Date("2026-01-22T11:00:00Z"))
|
||||
|
||||
// All occurrences share the same UID and metadata
|
||||
for (const event of events) {
|
||||
expect(event.uid).toBe("weekly-001@test")
|
||||
expect(event.title).toBe("Weekly Team Meeting")
|
||||
expect(event.location).toBe("Room B")
|
||||
expect(event.calendarName).toBe("Work")
|
||||
}
|
||||
})
|
||||
|
||||
test("returns empty array when no occurrences fall in range", () => {
|
||||
// Query window: Dec 2025 — before the first occurrence
|
||||
const events = parseICalEvents(loadFixture("weekly-recurring.ics"), null, {
|
||||
start: new Date("2025-12-01T00:00:00Z"),
|
||||
end: new Date("2025-12-31T00:00:00Z"),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("applies exception overrides during expansion", () => {
|
||||
// weekly-recurring-with-exception.ics:
|
||||
// Master: DTSTART 2026-01-01 (Thu) 14:00, FREQ=WEEKLY;BYDAY=TH;COUNT=8
|
||||
// Exception: RECURRENCE-ID 2026-01-15T14:00 → moved to 16:00-17:00, title changed
|
||||
// Query window: Jan 14 – Jan 16 → should get the exception occurrence for Jan 15
|
||||
const events = parseICalEvents(loadFixture("weekly-recurring-with-exception.ics"), "Work", {
|
||||
start: new Date("2026-01-14T00:00:00Z"),
|
||||
end: new Date("2026-01-16T00:00:00Z"),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]!.title).toBe("Standup (rescheduled)")
|
||||
expect(events[0]!.startDate).toEqual(new Date("2026-01-15T16:00:00Z"))
|
||||
expect(events[0]!.endDate).toEqual(new Date("2026-01-15T17:00:00Z"))
|
||||
})
|
||||
|
||||
test("expands recurring all-day events", () => {
|
||||
// daily-recurring-allday.ics: DTSTART 2026-01-12, FREQ=DAILY;COUNT=7
|
||||
// Occurrences: Jan 12, 13, 14, 15, 16, 17, 18
|
||||
// Query window: Jan 14 – Jan 17 → should get Jan 14, 15, 16
|
||||
const events = parseICalEvents(loadFixture("daily-recurring-allday.ics"), null, {
|
||||
start: new Date("2026-01-14T00:00:00Z"),
|
||||
end: new Date("2026-01-17T00:00:00Z"),
|
||||
})
|
||||
|
||||
expect(events).toHaveLength(3)
|
||||
for (const event of events) {
|
||||
expect(event.isAllDay).toBe(true)
|
||||
expect(event.title).toBe("Daily Reminder")
|
||||
}
|
||||
})
|
||||
|
||||
test("non-recurring events are filtered by range", () => {
|
||||
// single-event.ics: 2026-01-15T14:00 – 15:00
|
||||
// Query window that includes it
|
||||
const included = parseICalEvents(loadFixture("single-event.ics"), null, {
|
||||
start: new Date("2026-01-15T00:00:00Z"),
|
||||
end: new Date("2026-01-16T00:00:00Z"),
|
||||
})
|
||||
expect(included).toHaveLength(1)
|
||||
|
||||
// Query window that excludes it
|
||||
const excluded = parseICalEvents(loadFixture("single-event.ics"), null, {
|
||||
start: new Date("2026-01-16T00:00:00Z"),
|
||||
end: new Date("2026-01-17T00:00:00Z"),
|
||||
})
|
||||
expect(excluded).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("without timeRange, recurring events return raw VEVENTs (legacy)", () => {
|
||||
// Legacy behavior: no expansion, just returns the VEVENT components as-is
|
||||
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
||||
expect(events).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,21 +9,191 @@ import {
|
||||
type CalDavEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
export interface ICalTimeRange {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||
* Safety cap to prevent runaway iteration on pathological recurrence rules.
|
||||
* Each iteration is pure date math (no I/O), so a high cap is fine.
|
||||
* 10,000 covers a daily event with DTSTART ~27 years in the past.
|
||||
*/
|
||||
const MAX_RECURRENCE_ITERATIONS = 10_000
|
||||
|
||||
/**
|
||||
* Parses a raw iCalendar string and extracts VEVENT components
|
||||
* into CalDavEventData objects.
|
||||
*
|
||||
* When a timeRange is provided, recurring events are expanded into
|
||||
* individual occurrences within that range. Without a timeRange,
|
||||
* each VEVENT component is returned as-is (legacy behavior).
|
||||
*
|
||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||
* @param calendarName - Display name of the calendar this event belongs to
|
||||
* @param timeRange - When set, expand recurrences and filter to this window
|
||||
*/
|
||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalDavEventData[] {
|
||||
export function parseICalEvents(
|
||||
icsData: string,
|
||||
calendarName: string | null,
|
||||
timeRange?: ICalTimeRange,
|
||||
): CalDavEventData[] {
|
||||
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),
|
||||
)
|
||||
if (!timeRange) {
|
||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
||||
parseVEvent(vevent, calendarName),
|
||||
)
|
||||
}
|
||||
|
||||
// Group VEVENTs by UID: master + exceptions
|
||||
const byUid = new Map<
|
||||
string,
|
||||
{
|
||||
master: InstanceType<typeof ICAL.Component> | null
|
||||
exceptions: InstanceType<typeof ICAL.Component>[]
|
||||
}
|
||||
>()
|
||||
|
||||
for (const vevent of vevents as InstanceType<typeof ICAL.Component>[]) {
|
||||
const uid = vevent.getFirstPropertyValue("uid") as string | null
|
||||
if (!uid) continue
|
||||
|
||||
const hasRecurrenceId = vevent.getFirstPropertyValue("recurrence-id") !== null
|
||||
let group = byUid.get(uid)
|
||||
if (!group) {
|
||||
group = { master: null, exceptions: [] }
|
||||
byUid.set(uid, group)
|
||||
}
|
||||
|
||||
if (hasRecurrenceId) {
|
||||
group.exceptions.push(vevent)
|
||||
} else {
|
||||
group.master = vevent
|
||||
}
|
||||
}
|
||||
|
||||
const results: CalDavEventData[] = []
|
||||
const rangeStart = ICAL.Time.fromJSDate(timeRange.start, true)
|
||||
const rangeEnd = ICAL.Time.fromJSDate(timeRange.end, true)
|
||||
|
||||
for (const group of byUid.values()) {
|
||||
if (!group.master) {
|
||||
// Orphan exceptions — parse them directly if they fall in range
|
||||
for (const exc of group.exceptions) {
|
||||
const parsed = parseVEvent(exc, calendarName)
|
||||
if (overlapsRange(parsed, timeRange)) {
|
||||
results.push(parsed)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const masterEvent = new ICAL.Event(group.master)
|
||||
|
||||
// Register exceptions so getOccurrenceDetails resolves them
|
||||
for (const exc of group.exceptions) {
|
||||
masterEvent.relateException(exc)
|
||||
}
|
||||
|
||||
if (!masterEvent.isRecurring()) {
|
||||
const parsed = parseVEvent(group.master, calendarName)
|
||||
if (overlapsRange(parsed, timeRange)) {
|
||||
results.push(parsed)
|
||||
}
|
||||
// Also include standalone exceptions for non-recurring events
|
||||
for (const exc of group.exceptions) {
|
||||
const parsedExc = parseVEvent(exc, calendarName)
|
||||
if (overlapsRange(parsedExc, timeRange)) {
|
||||
results.push(parsedExc)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand recurring event occurrences within the time range.
|
||||
// The iterator must start from DTSTART (not rangeStart) because
|
||||
// ical.js needs to walk the recurrence rule grid from the original
|
||||
// anchor. We cap iterations to avoid runaway expansion on
|
||||
// pathological rules.
|
||||
const iter = masterEvent.iterator()
|
||||
let next: InstanceType<typeof ICAL.Time> | null = iter.next()
|
||||
let iterations = 0
|
||||
|
||||
while (next) {
|
||||
if (++iterations > MAX_RECURRENCE_ITERATIONS) {
|
||||
console.warn(
|
||||
`[aris.caldav] Recurrence expansion for "${masterEvent.uid}" hit iteration limit (${MAX_RECURRENCE_ITERATIONS}), stopping`,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Stop once we're past the range end
|
||||
if (next.compare(rangeEnd) >= 0) break
|
||||
|
||||
const details = masterEvent.getOccurrenceDetails(next)
|
||||
const occEnd = details.endDate
|
||||
|
||||
// Skip occurrences that end before the range starts
|
||||
if (occEnd.compare(rangeStart) <= 0) {
|
||||
next = iter.next()
|
||||
continue
|
||||
}
|
||||
|
||||
const occEvent = details.item
|
||||
const occComponent = occEvent.component
|
||||
|
||||
const parsed = parseVEventWithDates(
|
||||
occComponent,
|
||||
calendarName,
|
||||
details.startDate.toJSDate(),
|
||||
details.endDate.toJSDate(),
|
||||
details.recurrenceId ? details.recurrenceId.toString() : null,
|
||||
)
|
||||
results.push(parsed)
|
||||
|
||||
next = iter.next()
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function overlapsRange(event: CalDavEventData, range: ICalTimeRange): boolean {
|
||||
return event.startDate < range.end && event.endDate > range.start
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a VEVENT component, overriding start/end/recurrenceId with
|
||||
* values from recurrence expansion.
|
||||
*/
|
||||
function parseVEventWithDates(
|
||||
vevent: InstanceType<typeof ICAL.Component>,
|
||||
calendarName: string | null,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
recurrenceId: string | null,
|
||||
): CalDavEventData {
|
||||
const event = new ICAL.Event(vevent)
|
||||
|
||||
return {
|
||||
uid: event.uid ?? "",
|
||||
title: event.summary ?? "",
|
||||
startDate,
|
||||
endDate,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function parseVEvent(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
||||
export { parseICalEvents } from "./ical-parser.ts"
|
||||
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
|
||||
export {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
|
||||
8
packages/aris-source-caldav/src/prompts/cross-source.txt
Normal file
8
packages/aris-source-caldav/src/prompts/cross-source.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
If other feed data (weather, transit, nearby events) would disrupt or materially affect this event, state the connection in one sentence. Infer whether the event is indoor/outdoor/virtual from the title and location. Weather is only relevant if it affects getting to the event or the activity itself (e.g., rain for outdoor events, extreme conditions for physical activities). Return null for indoor or virtual events where weather has no impact. Do not fabricate information you don't have — only reference data present in the feed.
|
||||
|
||||
Examples:
|
||||
- "rain expected at 5pm — bring an umbrella for the walk to Tooley Street"
|
||||
- "Northern line has delays — leave 15 minutes early"
|
||||
- "your next event is across town — the 40 min gap may not be enough"
|
||||
- null (indoor guitar class with wind outside — weather doesn't affect the event)
|
||||
- null
|
||||
7
packages/aris-source-caldav/src/prompts/insight.txt
Normal file
7
packages/aris-source-caldav/src/prompts/insight.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
One sentence of actionable insight the user can't already see from the event title, time, and location. Do not restate event details. Do not fabricate information you don't have. Return null if there's nothing non-obvious to say.
|
||||
|
||||
Examples:
|
||||
- "you have 2 hours free before this starts"
|
||||
- "all 8 attendees accepted — expect a full room"
|
||||
- "third time this has been rescheduled"
|
||||
- null
|
||||
6
packages/aris-source-caldav/src/prompts/preparation.txt
Normal file
6
packages/aris-source-caldav/src/prompts/preparation.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
A concrete preparation step — something the user should do, bring, or review before this event. Infer only from available event and feed data. Do not restate event details. Do not fabricate information you don't have. Return null if no useful preparation comes to mind.
|
||||
|
||||
Examples:
|
||||
- "different building from your previous meeting — allow travel time"
|
||||
- "recurring meeting you declined last week — check if you need to attend"
|
||||
- null
|
||||
4
packages/aris-source-caldav/src/text.d.ts
vendored
Normal file
4
packages/aris-source-caldav/src/text.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.txt" {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
Reference in New Issue
Block a user