UNPKG

icalendar-events

Version:

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

363 lines (362 loc) 14.4 kB
import { DateTime, Duration, Interval } from 'luxon'; import { RRule } from './rrule.js'; import { parseICalDateTime } from './parse-ical-datetime.js'; import { parseICalPeriod } from './parse-ical-period.js'; import { toSQL } from './utils.js'; export class VEvent { uid; dtstart; dtend; duration; summary; location; description; rrule; rdates = []; exdates = []; transp; status; recurrenceId; sequence; constructor(eventData) { const lines = eventData.split('\n'); this.rdates = []; this.exdates = []; let currentLine = ''; for (const line of lines) { if (line.startsWith(' ')) { currentLine += line.trim(); // Handle multi-line continuation } else if (line.trim().startsWith('END:VEVENT')) { break; } else { if (currentLine) this.parseEventLine(currentLine); currentLine = line.trim(); } } if (currentLine) this.parseEventLine(currentLine); if (this.dtstart === undefined) throw new Error(`VEvent constructor: couldn't parse start date: \n ${eventData}`); } parseEventLine(line) { const lineUC = line.toUpperCase(); if (lineUC.startsWith("DESCRIPTION")) { this.description = line.split("DESCRIPTION:")[1]; return; } if (lineUC.startsWith("DTEND")) { try { //There is only 1 date in DTEND this.dtend = parseICalDateTime(line)[0]; } catch (e) { console.error("VEvent", `Could not parse dtend: ${line}`); console.error("VEvent", e); } return; } if (lineUC.startsWith("DURATION")) { this.duration = Duration.fromISO(line.split("DURATION:")[1]); } if (lineUC.startsWith("DTSTART")) { try { //There is only 1 date in DTSTART this.dtstart = parseICalDateTime(line)[0]; } catch (e) { console.error("VEvent", `Could not parse dtstart: ${line}`); console.error("VEvent", e); } return; } if (lineUC.startsWith("SUMMARY")) { this.summary = line.split("SUMMARY:")[1]; return; } if (lineUC.startsWith("LOCATION")) { this.location = line.split("LOCATION:")[1]; return; } if (lineUC.startsWith("STATUS")) { this.status = line.split("STATUS:")[1]; return; } if (lineUC.startsWith("UID")) { this.uid = line.split("UID:")[1]; return; } if (lineUC.startsWith("RRULE")) { try { this.rrule = new RRule(line); } catch (e) { console.error("VEvent", `Could not parse rrule: ${line}`); console.error("VEvent", e); } return; } if (lineUC.startsWith("RDATE")) { try { if (lineUC.includes("VALUE=PERIOD")) { // Parse period (Interval) parseICalPeriod(line).forEach(period => { this.rdates.push(period); }); } else { // Parse DateTime (DateTime) parseICalDateTime(line).forEach(date => { this.rdates.push(date); }); } } catch (e) { console.error("VEvent", `Could not parse rdate: ${line}`); console.error("VEvent", e); } return; } if (lineUC.startsWith("EXDATE")) { try { parseICalDateTime(line).forEach(date => { this.exdates.push(date); }); } catch (e) { console.error("VEvent", `Could not parse exdate: ${line}`); console.error("VEvent", e); } return; } if (lineUC.startsWith("TRANSP")) { this.transp = line.split("TRANSP:")[1]; } if (lineUC.startsWith("RECURRENCE-ID")) { try { //There is only 1 date in RECURRENCE-ID this.recurrenceId = parseICalDateTime(line)[0]; } catch (e) { console.error("VEvent", `Could not parse recurrence-id: ${line}`); console.error("VEvent", e); } return; } if (lineUC.startsWith("SEQUENCE")) { try { this.sequence = parseInt(line.split("SEQUENCE:")[1], 10); } catch (e) { console.error("VEvent", `Could not parse sequence: ${line}`); console.error("VEvent", e); } return; } } toString() { return ` uuid: ${this.uid} \n dtstart: ${this.dtstart ? toSQL(this.dtstart) : ""} \n dtend: ${this.dtend ? toSQL(this.dtend) : ""} \n duration: ${this.duration?.toString()} \n summary: ${this.summary} \n location: ${this.location} \n description: ${this.description} \n rrule: ${this.rrule?.toString()} \n rdate: ${(this.rdates).map((rdate) => { if (rdate instanceof DateTime) { return toSQL(rdate) ?? ""; } else { return rdate.toISO(); } }).reduce((p, c) => { return p + ((p === "") ? "" : ",") + c; }, "")} \n exdate: ${(this.exdates).map((i) => { return toSQL(i) ?? ""; }).reduce((p, c) => { return p + ((p === "") ? "" : ",") + c; }, "")} \n transp: ${this.transp} \n status: ${this.status ?? ""} \n recurrenceId: ${this.recurrenceId ? toSQL(this.recurrenceId) : ""} \n sequence: ${this.sequence ?? ""} \n `; } // create corresponding event calculating the appropriate end time using original event duration // period is for the case RDATE is a period, we use that duraiton instead toEvent(newStartDate, period) { if (this.dtstart === undefined) return null; let endDate = null; if (period !== undefined && period !== null) { endDate = newStartDate.plus(period); } else if (this.dtend !== undefined) { endDate = newStartDate.plus(Duration.fromDurationLike(this.dtend.diff(this.dtstart))); } else if (this.duration !== undefined) { endDate = newStartDate.plus(this.duration); } else { // case where there is neither DTEND nor DURATION then event duration is 1 day by default. // RFC specifications state: // The "DTSTART" property for a "VEVENT" specifies the inclusive // start of the event. For recurring events, it also specifies the // very first instance in the recurrence set. The "DTEND" property // for a "VEVENT" calendar component specifies the non-inclusive end // of the event. ******* For cases where a "VEVENT" calendar component // specifies a "DTSTART" property with a DATE value type but no // "DTEND" nor "DURATION" property, the event's duration is taken to // be one day. For cases where a "VEVENT" calendar component // specifies a "DTSTART" property with a DATE-TIME value type but no // "DTEND" property, the event ends on the same calendar date and // time of day specified by the "DTSTART" property. ******* if (this.dtstart.isDate) { endDate = newStartDate.plus({ days: 1 }); } else { // just add 1 millisecond to the start date instead of // returning the exact same calendar date and time // to avoid problems because dtend is non-inclusive endDate = newStartDate.plus({ milliseconds: 1 }); } } if (endDate === null || !endDate.isValid) return null; endDate.isDate = this.dtstart.isDate; return { uid: this.uid, dtstart: newStartDate, dtend: endDate, summary: this.summary, location: this.location, description: this.description, allday: this.dtstart.isDate ?? false, transp: this.transp, status: this.status, recurrenceId: this.recurrenceId, sequence: this.sequence }; } // Method to expand recurrence rules and generate all event occurrences //1. find all start dates from RRULE and RDATE. (DTSTART is also included in the set) //2. Do not include start dates that are in EXDATE. //3. Build events from the list of start dates, and using the duration in the original event // duration = (DTEND - DTSTART) or (DURATION) or (RDATE if period) // For events with RECURRENCE-ID (exceptions to recurring events), generate only the single occurrence expandRecurrence(range, includeDTSTART = true) { const events = []; if (this.dtstart === undefined) return events; // If this is an exception event (has RECURRENCE-ID), return only this single occurrence. // The actual occurrence time is the event's DTSTART (the modified instance), // while RECURRENCE-ID points to the original instance being overridden. if (this.recurrenceId !== undefined) { if (range.contains(this.dtstart)) { const event = this.toEvent(this.dtstart); if (event === null) { console.error("VEvent expandRecurrence: exception event could not be created from dtstart"); } else { events.push(event); } } return events; } if (range.isBefore(this.dtstart)) return events; // Add DTSTART into the set if (range.contains(this.dtstart) && !this.isExcluded(this.dtstart)) { if (includeDTSTART || !this.rrule || this.rrule.matchesRRule(this.dtstart)) { const event = this.toEvent(this.dtstart); if (event === null) { console.error("VEvent expandRecurrence: event could not be created from start date"); } else { events.push(event); } } } if (this.rrule !== undefined) { let currentDateTime = this.dtstart; // Advance until next date in the range range.isAfter; try { do { currentDateTime = this.rrule.advanceDate(currentDateTime); if (range.isBefore(currentDateTime)) return events; } while (!range.contains(currentDateTime)); } catch (e) { console.error(`VEvent expandRecurrence: could not advance date:`); console.error(e); return events; } const until = this.rrule.until; const count = this.rrule.count; // Loop to find all recurrences while ((until === null || currentDateTime <= until) && (count === null || events.length < count) && range.contains(currentDateTime)) { if (!this.isExcluded(currentDateTime)) { if (this.rrule.matchesRRule(currentDateTime)) { const event = this.toEvent(currentDateTime); if (event === null) { console.error("VEvent expandRecurrence: event could not be created from new start date"); } else { events.push(event); } } } try { currentDateTime = this.rrule.advanceDate(currentDateTime); } catch (e) { console.error(`VEvent expandRecurrence: could not advance date:`); console.error(e); break; } } } this.rdates.forEach((rdate) => { const rdateStartDate = (rdate instanceof Interval) ? rdate.start : rdate; if (rdateStartDate === null) { console.error(`VEvent expandRecurrence: could not get RDATE start date`); return; } if (!range.contains(rdateStartDate)) return; if (!this.isExcluded(rdateStartDate)) { const duration = (rdate instanceof Interval) ? rdate.toDuration() : null; const event = this.toEvent(rdateStartDate, duration); if (event === null) { console.error("expandRecurrence: event could not be created from RDATE"); } else { events.push(event); } } }); return events; } isExcluded(startDateTime) { return (this.exdates.some((exdate) => exdate.valueOf() === startDateTime.valueOf())); } // Check if this VEvent is an exception to a recurring event // Exception events have a RECURRENCE-ID and typically no RRULE // They represent modified instances of a recurring event series isException() { return this.recurrenceId !== undefined; } // Get the most recent recurrence ID if this is an exception event // Useful for matching with recurring event instances that should be replaced getRecurrenceIdDate() { return this.recurrenceId; } // Get the sequence number for versioning // Higher sequence numbers indicate newer versions of an event // When multiple instances exist (e.g., one from RRULE expansion and one from exception), // the version with higher SEQUENCE should be used getSequence() { return this.sequence ?? 0; } }