mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 00:51:20 +00:00
The iCal parser returned master VEVENT components with their original start dates instead of expanding recurrences. Events from months ago appeared in today's feed. parseICalEvents now accepts an optional timeRange. When set, recurring events are expanded via ical.js iterator and only occurrences overlapping the range are returned. Exception overrides (RECURRENCE-ID) are applied during expansion. Co-authored-by: Ona <no-reply@ona.com>
324 lines
8.9 KiB
TypeScript
324 lines
8.9 KiB
TypeScript
import ICAL from "ical.js"
|
|
|
|
import {
|
|
AttendeeRole,
|
|
AttendeeStatus,
|
|
CalDavEventStatus,
|
|
type CalDavAlarm,
|
|
type CalDavAttendee,
|
|
type CalDavEventData,
|
|
} from "./types.ts"
|
|
|
|
export interface ICalTimeRange {
|
|
start: Date
|
|
end: Date
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
timeRange?: ICalTimeRange,
|
|
): CalDavEventData[] {
|
|
const jcal = ICAL.parse(icsData)
|
|
const comp = new ICAL.Component(jcal)
|
|
const vevents = comp.getAllSubcomponents("vevent")
|
|
|
|
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(
|
|
vevent: InstanceType<typeof ICAL.Component>,
|
|
calendarName: string | null,
|
|
): CalDavEventData {
|
|
const event = new ICAL.Event(vevent)
|
|
|
|
return {
|
|
uid: event.uid ?? "",
|
|
title: event.summary ?? "",
|
|
startDate: event.startDate?.toJSDate() ?? new Date(0),
|
|
endDate: event.endDate?.toJSDate() ?? new Date(0),
|
|
isAllDay: event.startDate?.isDate ?? false,
|
|
location: event.location ?? null,
|
|
description: event.description ?? null,
|
|
calendarName,
|
|
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
|
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
|
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
|
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
|
alarms: parseAlarms(vevent),
|
|
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
|
|
}
|
|
}
|
|
|
|
function parseStatus(raw: string | null): CalDavEventStatus | null {
|
|
if (!raw) return null
|
|
switch (raw.toLowerCase()) {
|
|
case "confirmed":
|
|
return CalDavEventStatus.Confirmed
|
|
case "tentative":
|
|
return CalDavEventStatus.Tentative
|
|
case "cancelled":
|
|
return CalDavEventStatus.Cancelled
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function parseOrganizer(
|
|
value: string | null,
|
|
vevent: InstanceType<typeof ICAL.Component>,
|
|
): string | null {
|
|
if (!value) return null
|
|
|
|
// Try CN parameter first
|
|
const prop = vevent.getFirstProperty("organizer")
|
|
if (prop) {
|
|
const cn = prop.getParameter("cn") as string | undefined
|
|
if (cn) return cn
|
|
}
|
|
|
|
// Fall back to mailto: value
|
|
return value.replace(/^mailto:/i, "")
|
|
}
|
|
|
|
function parseAttendees(properties: unknown[]): CalDavAttendee[] {
|
|
if (properties.length === 0) return []
|
|
|
|
return properties.flatMap((prop) => {
|
|
if (!prop || typeof prop !== "object" || !("getFirstValue" in prop)) return []
|
|
const p = prop as InstanceType<typeof ICAL.Property>
|
|
const value = asStringOrNull(p.getFirstValue())
|
|
const cn = asStringOrNull(p.getParameter("cn"))
|
|
const role = asStringOrNull(p.getParameter("role"))
|
|
const partstat = asStringOrNull(p.getParameter("partstat"))
|
|
|
|
return [
|
|
{
|
|
name: cn,
|
|
email: value ? value.replace(/^mailto:/i, "") : null,
|
|
role: parseAttendeeRole(role),
|
|
status: parseAttendeeStatus(partstat),
|
|
},
|
|
]
|
|
})
|
|
}
|
|
|
|
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
|
|
if (!raw) return null
|
|
switch (raw.toUpperCase()) {
|
|
case "CHAIR":
|
|
return AttendeeRole.Chair
|
|
case "REQ-PARTICIPANT":
|
|
return AttendeeRole.Required
|
|
case "OPT-PARTICIPANT":
|
|
return AttendeeRole.Optional
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
|
|
if (!raw) return null
|
|
switch (raw.toUpperCase()) {
|
|
case "ACCEPTED":
|
|
return AttendeeStatus.Accepted
|
|
case "DECLINED":
|
|
return AttendeeStatus.Declined
|
|
case "TENTATIVE":
|
|
return AttendeeStatus.Tentative
|
|
case "NEEDS-ACTION":
|
|
return AttendeeStatus.NeedsAction
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalDavAlarm[] {
|
|
const valarms = vevent.getAllSubcomponents("valarm")
|
|
if (!valarms || valarms.length === 0) return []
|
|
|
|
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
|
|
const trigger = valarm.getFirstPropertyValue("trigger")
|
|
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
|
|
|
|
return {
|
|
trigger: trigger ? trigger.toString() : "",
|
|
action: action ?? "DISPLAY",
|
|
}
|
|
})
|
|
}
|
|
|
|
function asStringOrNull(value: unknown): string | null {
|
|
return typeof value === "string" ? value : null
|
|
}
|