icalendar-events
Version:
A RFC5545 compliant parser for iCalendar VEVENT with time zone support and accurate recurring events generation.
271 lines (270 loc) • 10.2 kB
JavaScript
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;
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(":")[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(":")[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(":")[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) {
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(":")[1];
}
}
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
`;
}
// 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 there is neither DTEND nor DURATION then event duration is 1 day by default
endDate = newStartDate.plus({ days: 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
};
}
// 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, includeDTSTART = false) {
const events = [];
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 = 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()));
}
}