UNPKG

icalendar-events

Version:

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

319 lines (269 loc) 10.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 { ICEvent } from './icevent.js' import { toSQL } from './utils.js' export class VEvent { uid?: string dtstart?: DateTime dtend?: DateTime duration?: Duration summary?: string location?: string description?: string rrule?: RRule rdates: (DateTime | Interval)[] = [] exdates: DateTime[] = [] transp?: string constructor(eventData: string) { 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}`) } private parseEventLine(line: string) { const lineUC: string = line.toUpperCase() if(lineUC.startsWith("DESCRIPTION")) { this.description = line.split(":")[1] return } if(lineUC.startsWith("DTEND")) { try { //There is only 1 date in DTEND this.dtend = parseICalDateTime(line)[0] } catch (e: any) { console.error("VEvent", `Could not parse dtend: ${line}`) console.error("VEvent", e) } return } if(lineUC.startsWith("DURATION")) { this.duration = Duration.fromISO(line.split(":")[1]) } if(lineUC.startsWith("DTSTART")) { try { //There is only 1 date in DTSTART this.dtstart = parseICalDateTime(line)[0] } catch (e: any) { console.error("VEvent", `Could not parse dtstart: ${line}`) console.error("VEvent", e) } return } if(lineUC.startsWith("SUMMARY")) { this.summary = line.split(":")[1] return } if(lineUC.startsWith("LOCATION")) { this.location = line.split(":")[1] return } if(lineUC.startsWith("UID")) { this.uid = line.split(":")[1] return } if(lineUC.startsWith("RRULE")) { try { this.rrule = new RRule(line) } catch (e: any) { 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: any) { 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: any) { console.error("VEvent", `Could not parse exdate: ${line}`) console.error("VEvent", e) } return } if(lineUC.startsWith("TRANSP")) { this.transp = line.split(":")[1] } } toString(): string { 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<string>((rdate: DateTime | Interval): string=>{ if(rdate instanceof DateTime) { return toSQL(rdate) ?? "" } else { return rdate.toISO() } }).reduce((p,c): string=> {return p +((p === "") ? "" : ",")+ c},"") } \n exdate: ${(this.exdates).map<string>((i:DateTime): string=>{return toSQL(i) ?? ""}).reduce((p,c): string=> {return p +((p === "") ? "" : ",")+ c},"")} \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 private toEvent(newStartDate: DateTime, period?: Duration | null): ICEvent | null { if(this.dtstart === undefined) return null let endDate: DateTime | null = 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 } as ICEvent } // 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) expandRecurrence ( range: Interval, includeDTSTART: boolean = false ) : ICEvent[] { const events: ICEvent[] = [] if(this.dtstart === undefined || 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: ICEvent | null = 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: DateTime = 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: any) { console.error(`VEvent expandRecurrence: could not advance date:`) console.error(e) return events } const until: DateTime | null = this.rrule.until const count: number | null = 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: ICEvent | null = 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: any) { console.error(`VEvent expandRecurrence: could not advance date:`) console.error(e) break } } } this.rdates.forEach((rdate: DateTime | Interval) => { const rdateStartDate: DateTime | null = (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: Duration | null = (rdate instanceof Interval) ? rdate.toDuration() : null const event: ICEvent | null = this.toEvent(rdateStartDate, duration) if(event === null) { console.error("expandRecurrence: event could not be created from RDATE") } else { events.push(event) } } }); return events } private isExcluded(startDateTime: DateTime): boolean { return (this.exdates.some((exdate: DateTime) => exdate.valueOf() === startDateTime.valueOf())) } }