UNPKG

icalendar-events

Version:

A RFC5545 compliant parser for iCalendar VEVENT with time zone support and accurate recurring events generation.

124 lines (123 loc) 6.49 kB
import { DateTime, Interval } from 'luxon'; import { VEvent } from './vevent.js'; import './luxon-extensions.js'; export class ICalendarEvents { // All Events in the given date range sorted, with reccurence expanded events; // Optional raw list of vevents sorted. For debugging purpose mostly. vevents = []; // Parse string iCalendar data and build the ICalendar vevents constructor(data, dateRange, options) { process.env.ICALEVENTS_LOCAL_TZ = options?.localTZ ?? 'local'; let range; if (!dateRange) { // Default Range is [start of current month - 1 year later] // Time Zone is set to UTC with the same time to avoid overflowing to previous or next day in local time zone const firstDate = DateTime.now().setZone('UTC', { keepLocalTime: true }).startOf('month'); const lastDate = firstDate.plus({ months: 11 }).endOf('month'); range = Interval.fromDateTimes(firstDate, lastDate); } else { range = dateRange; } if (!range || !range.isValid) throw new Error(`ICalEvents constructor: range is invalid: ${range.invalidReason}`); this.events = []; // Add the events as they are parsed // We don't read the VTIMEZONE, instead we just use the standard Olson TZID const eventsData = data.split('BEGIN:VEVENT'); // First pass: parse all VEVENTs and separate masters from overrides const masterEvents = []; const overrideEvents = new Map(); // Map of UID -> list of overrides eventsData.forEach((eventData) => { if (eventData.includes('END:VEVENT')) { let vevent = null; try { vevent = new VEvent(eventData); } catch (e) { console.error("ICalEvents constructor", `Could not parse VEVENT`); console.error("ICalEvents constructor", e); } if (vevent && vevent.dtstart !== undefined) { if (options?.withVEvent) this.vevents.push(vevent); // Check if this is an override event (has RECURRENCE-ID) if (vevent.isException()) { // Collect overrides by UID const uid = vevent.uid ?? 'no-uid'; if (!overrideEvents.has(uid)) { overrideEvents.set(uid, []); } overrideEvents.get(uid).push(vevent); } else { // This is a master event or single event masterEvents.push(vevent); } } } }); // Second pass: expand masters and apply overrides masterEvents.forEach((vevent) => { // Expand the master event's recurrence let expandedEvents = vevent.expandRecurrence(range, options?.includeDTSTART); // Apply any overrides for this event's UID const uid = vevent.uid ?? 'no-uid'; const overrides = overrideEvents.get(uid) ?? []; // For each override, find and replace matching occurrences overrides.forEach((override) => { const recurrenceId = override.getRecurrenceIdDate(); if (recurrenceId) { // Find the matching occurrence in expandedEvents const matchIndex = expandedEvents.findIndex(event => event.dtstart.valueOf() === recurrenceId.valueOf()); if (matchIndex !== -1) { // If this override is canceled, remove the occurrence if (override.status?.toUpperCase() === 'CANCELLED') { expandedEvents.splice(matchIndex, 1); return; } // Check if override has higher sequence number if (override.getSequence() >= vevent.getSequence()) { // Replace with override's event const overrideExpandedEvents = override.expandRecurrence(range, options?.includeDTSTART); if (overrideExpandedEvents.length > 0) { expandedEvents[matchIndex] = overrideExpandedEvents[0]; } } } } }); this.events.push(...expandedEvents); }); this.vevents.sort((vevent1, vevent2) => { if (vevent1.dtstart === undefined || vevent2.dtstart === undefined) return 0; return vevent1.dtstart.valueOf() - vevent2.dtstart.valueOf(); }); this.events.sort((event1, event2) => { return event1.dtstart.valueOf() - event2.dtstart.valueOf(); }); } } // RECURRENCE-ID override support has been implemented (March 2026) // // Implementation follows RFC 5545 specification for handling recurring events with exceptions: // // 1. Master events (no RECURRENCE-ID) are expanded using RRULE, RDATE, and EXDATE // 2. Override events (with RECURRENCE-ID) are collected by UID // 3. For each override, the matching occurrence is found by comparing RECURRENCE-ID with generated event start times // Note: RECURRENCE-ID is the original start time of the occurrence being overridden, not the new start time in the override event // Note2: the check comes **after** expansion — you need the expanded candidates to know **which** original instance each `RECURRENCE-ID` is referring to. // 4. The override replaces the occurrence if its SEQUENCE number is >= the master's SEQUENCE // // The VEvent, RRule, and ICEvent classes have been updated to: // - Parse and store RECURRENCE-ID (as DateTime) and SEQUENCE (as number) // - Provide helper methods: isException(), getRecurrenceIdDate(), getSequence() // - Return events with exception dates as single occurrences via expandRecurrence() // // This approach correctly handles: // - Modified instances (override with different properties replaces the occurrence) // - Moved instances (override has different DTSTART) // - Canceled instances (can be detected and handled at higher levels) // - Version conflicts (SEQUENCE determines which version takes precedence)