icalendar-events
Version:
A RFC5545 compliant parser for iCalendar VEVENT with time zone support and accurate recurring events generation.
480 lines (479 loc) • 23.5 kB
JavaScript
import { DateTime } from "luxon";
import { parseICalDateTime } from './parse-ical-datetime.js';
import { toSQL, setSmallUnits, plusUnit } from './utils.js';
function mapFreqUnit(freq) {
const map = {
SECONDLY: 'second', MINUTELY: 'minute', HOURLY: 'hour', DAILY: 'day', WEEKLY: 'week', MONTHLY: 'month', YEARLY: 'year',
};
return map[freq];
}
//iCal rrule parser. Provides list of recurrences in a range
export class RRule {
freq = null;
until = null;
count = null;
interval = 1;
bysecond = []; // (0 to 60)[]
byminute = []; // (0 to 59)[]
byhour = []; // (0 to 23)[]
byday = [];
bymonthday = []; // (-31 to 31 - {0})[]
byyearday = []; // (-366 to 366 - {0})[]
byweekno = []; // (-53 to 53 - {0})[]
bymonth = []; // 1 to 12
bysetpos = []; // (-366 to 366 -{0})[] ou context dependant. Can be (-31 to 31 -{0})[], etc...
wkst = "MO";
//cache: DateTime[] = []
matchesRRule(dateTime, ignoreSetPos = false) {
//Check if given dateTime verifies BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS in that order
// From the RFC5545 spec:
// If multiple BYxxx rule parts are specified, then after evaluating
// the specified FREQ and INTERVAL rule parts, the BYxxx rule parts
// are applied to the current set of evaluated occurrences in the
// following order: BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY,
// BYHOUR, BYMINUTE, BYSECOND and BYSETPOS; then COUNT and UNTIL are
// evaluated.
// A week is defined as a
// seven day period, starting on the day of the week defined to be
// the week start (see WKST). Week number one of the calendar year
// is the first week that contains at least four (4) days in that
// calendar year.
// The WKST rule part specifies the day on which the workweek starts.
// Valid values are MO, TU, WE, TH, FR, SA, and SU. This is significant
// when a WEEKLY "RRULE" has an interval greater than 1,
// and a BYDAY rule part is specified. This is also significant when
// in a YEARLY "RRULE" when a BYWEEKNO rule part is specified. The
// default value is MO.
// An example where the days generated makes a difference because of
// WKST:
// DTSTART;TZID=America/New_York:19970805T090000
// RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO
// ==> (1997 EDT) August 5,10,19,24
// changing only WKST from MO to SU, yields different results...
// DTSTART;TZID=America/New_York:19970805T090000
// RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU
// ==> (1997 EDT) August 5,17,19,31
// The table below summarizes the dependency of BYxxx rule part
// expand or limit behavior on the FREQ rule part value.
// The term "N/A" means that the corresponding BYxxx rule part MUST
// NOT be used with the corresponding FREQ value.
// BYDAY has some special behavior depending on the FREQ value and
// this is described in separate notes below the table.
// +----------+--------+--------+-------+-------+------+-------+------+
// | |SECONDLY|MINUTELY|HOURLY |DAILY |WEEKLY|MONTHLY|YEARLY|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYMONTH |Limit |Limit |Limit |Limit |Limit |Limit |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYWEEKNO |N/A |N/A |N/A |N/A |N/A |N/A |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYYEARDAY |Limit |Limit |Limit |N/A |N/A |N/A |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYMONTHDAY|Limit |Limit |Limit |Limit |N/A |Expand |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYDAY |Limit |Limit |Limit |Limit |Expand|Note 1 |Note 2|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYHOUR |Limit |Limit |Limit |Expand |Expand|Expand |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYMINUTE |Limit |Limit |Expand |Expand |Expand|Expand |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYSECOND |Limit |Expand |Expand |Expand |Expand|Expand |Expand|
// +----------+--------+--------+-------+-------+------+-------+------+
// |BYSETPOS |Limit |Limit |Limit |Limit |Limit |Limit |Limit |
// +----------+--------+--------+-------+-------+------+-------+------+
// Note 1: Limit if BYMONTHDAY is present; otherwise, special expand
// for MONTHLY.
// Note 2: Limit if BYYEARDAY or BYMONTHDAY is present; otherwise,
// special expand for WEEKLY if BYWEEKNO present; otherwise,
// special expand for MONTHLY if BYMONTH present; otherwise,
// special expand for YEARLY.
if (this.freq === null) {
console.error(`RRule matchesRRule: FREQ is null:\n ${this.toString()}`);
return false;
}
// Verifies BYMONTH ?
if (this.bymonth.length !== 0) {
if (!this.bymonth.includes(dateTime.month))
return false;
}
// Verifies BYWEEKNO ?
if (this.byweekno.length !== 0 && this.freq === "YEARLY") {
// Must consider WKST in this case
let dateTimeInLocale = this.adjustLocaleForWkst(dateTime);
if (dateTimeInLocale === null)
return false;
let byweeknoMatch = false;
for (const weekno of this.byweekno) {
if ((weekno > 0 && dateTimeInLocale.localWeekNumber === weekno) ||
(weekno < 0 && dateTimeInLocale.weeksInLocalWeekYear - dateTimeInLocale.localWeekNumber + 1 === weekno)) {
byweeknoMatch = true;
break;
}
}
if (!byweeknoMatch)
return false;
}
// Verifies BYYEARDAY ?
if (this.byyearday.length !== 0 && !["DAILY", "WEEKLY", "MONTHLY"].includes(this.freq)) {
let byyeardayMatch = false;
for (const yearday of this.byyearday) {
if ((yearday > 0 && dateTime.ordinal === yearday) ||
(yearday < 0 && dateTime.daysInYear - dateTime.ordinal + 1 === yearday)) {
byyeardayMatch = true;
break;
}
}
if (!byyeardayMatch)
return false;
}
// Verifies BYMONTHDAY ?
if (this.bymonthday.length !== 0 && this.freq !== "WEEKLY") {
const monthdayMatch = this.bymonthday.some((monthDay) => {
if (dateTime.daysInMonth === undefined) {
console.error("RRule matchesRule BYMONTHDAY: could not get the number of days in the month");
return false;
}
return ((monthDay > 0 && dateTime.day === monthDay) ||
(monthDay < 0 && (dateTime.daysInMonth) + monthDay + 1 === dateTime.day));
});
if (!monthdayMatch)
return false;
}
// Verifies BYDAY ?
if (this.byday.length !== 0) {
// YEARLY -> offset from day number in a year
// MONTHLY -> offset from day number in a month
// ELSE -> shouldn't have an offset so ignore it
let bydayMatch = false;
for (const day of this.byday) {
if (dateTime.setLocale("en-US").weekdayShort?.substring(0, 2).toUpperCase() !== day.weekday)
continue;
if (day.nth !== null && this.freq === "YEARLY") {
let positionInYear = 0;
let totalInYear = 0;
let dt = dateTime.startOf("year").setLocale("en-US"); // to get weekdays in English
while (dt.year === dateTime.year) {
if (dt.weekdayShort?.substring(0, 2).toUpperCase() === day.weekday) {
totalInYear++;
if (dt.toISODate() === dateTime.toISODate()) {
positionInYear = totalInYear;
}
}
dt = dt.plus({ days: 1 });
}
if (((day.nth > 0) && positionInYear === day.nth) ||
(day.nth < 0 && totalInYear + day.nth + 1 === positionInYear)) {
bydayMatch = true;
break;
}
}
else if (day.nth !== null && this.freq === "MONTHLY") {
let positionInMonth = 0;
let totalInMonth = 0;
let dt = dateTime.startOf("month").setLocale("en-US"); // to get weekdays in English
while (dt.month === dateTime.month) {
if (dt.weekdayShort?.substring(0, 2).toUpperCase() === day.weekday) {
totalInMonth++;
if (dt.toISODate() === dateTime.toISODate()) {
positionInMonth = totalInMonth;
}
}
dt = dt.plus({ days: 1 });
}
if (((day.nth > 0) && positionInMonth === day.nth) ||
(day.nth < 0 && totalInMonth + day.nth + 1 === positionInMonth)) {
bydayMatch = true;
break;
}
}
else {
bydayMatch = true;
break;
}
}
if (!bydayMatch)
return false;
}
// Verifies BYHOUR
if (this.byhour.length !== 0) {
if (!this.byhour.includes(dateTime.hour))
return false;
}
// Verifies BYMINUTE
if (this.byminute.length !== 0) {
if (!this.byminute.includes(dateTime.minute))
return false;
}
// Verifies BYSECOND
if (this.bysecond.length !== 0) {
if (!this.bysecond.includes(dateTime.second))
return false;
}
// Verifies SETPOS
// BYSETPOS must be checked after we get all the set in the relevant period
// BYSETPOS operates on
// a set of recurrence instances in one interval of the recurrence
// rule. For example, in a WEEKLY rule, the interval would be one
// week. A set of recurrence instances starts at the beginning of the
// interval defined by the FREQ rule part. Valid values are 1 to 366
// or -366 to -1. It MUST only be used in conjunction with another
// BYxxx rule part.
if (this.bysetpos.length !== 0 && !ignoreSetPos) {
const periodUnit = mapFreqUnit(this.freq);
// Consider WKST with useLocaleWeeks: true
const periodStart = dateTime.startOf(periodUnit, { useLocaleWeeks: true });
const periodEnd = dateTime.endOf(periodUnit, { useLocaleWeeks: true });
const validDates = [];
const advanceFreq = this.getAdvanceFreq();
let dt = setSmallUnits(periodStart, dateTime, this.unitsCap(advanceFreq));
while (dt <= periodEnd) {
if (this.matchesRRule(dt, true)) {
const iso = dt.toISO();
if (iso)
validDates.push(iso);
}
dt = this.advanceDate(dt);
}
const bysetposMatch = this.bysetpos.some(setpos => {
const index = validDates.indexOf(dateTime.toISO() ?? "");
return (index !== -1 &&
((setpos > 0 && setpos === index + 1) || (setpos < 0 && validDates.length + setpos === index)));
});
if (!bysetposMatch)
return false;
}
return true;
}
advanceDate(from) {
// TODO: optimize to skip values when possible
if (this.freq === null)
throw new Error(`RRule advanceDate: FREQ is null`);
// Must consider WKST for the case FREQ=WEEKLY and INTERVAL > 1
let fromInLocale = (this.freq === "WEEKLY" && this.interval > 1) ? this.adjustLocaleForWkst(from) : from;
if (fromInLocale === null) {
fromInLocale = from;
console.error("RRule advanceDate: could not change date locale");
}
let next = null;
const advanceFreq = this.getAdvanceFreq();
if (this.freq !== advanceFreq) {
next = plusUnit(fromInLocale, mapFreqUnit(advanceFreq), 1);
if (!next.hasSame(fromInLocale, mapFreqUnit(this.freq), { useLocaleWeeks: true })) {
const temp = plusUnit(fromInLocale, mapFreqUnit(this.freq), this.interval).startOf(mapFreqUnit(this.freq), { useLocaleWeeks: true });
const unitLimit = this.unitsCap(this.freq);
next = setSmallUnits(temp, fromInLocale, unitLimit);
}
}
else {
next = plusUnit(fromInLocale, mapFreqUnit(this.freq), this.interval);
}
if (next === null || !next.isValid) {
throw new Error(`RRule advanceDate: could not get the next date`);
}
next.isDate = from.isDate;
return next;
}
getAdvanceFreq() {
// Returns the frequency to be used in advanceDate
// Useful when handling BYSETPOS
if (this.freq === "SECONDLY" || this.bysecond.length !== 0) {
return "SECONDLY";
}
else if (this.freq === "MINUTELY" || this.byminute.length !== 0) {
return "MINUTELY";
}
else if (this.freq === "HOURLY" || this.byhour.length !== 0) {
return "HOURLY";
}
else if (this.freq === "DAILY" || this.byday.length !== 0 || this.bymonthday.length !== 0 || this.byyearday.length !== 0) {
return "DAILY";
}
else if (this.freq === "WEEKLY" || this.byweekno.length !== 0) {
return "WEEKLY";
}
else if (this.freq === "MONTHLY" || this.bymonth.length !== 0) {
return "MONTHLY";
}
else {
return "YEARLY";
}
}
unitsCap(freq) {
const map = {
SECONDLY: 'millisecond',
MINUTELY: 'second',
HOURLY: 'minute',
DAILY: 'hour',
WEEKLY: 'hour',
MONTHLY: 'hour',
YEARLY: (this.bymonth.length !== 0) ? 'day' : 'hour',
};
return map[freq];
}
adjustLocaleForWkst(dateTime) {
// return a new DateTime object with a locale in which wkst is appropriate
// only cover case where week start is on monday, sunday or saturday
let dateTimeInLocale;
const dateTimeISO = dateTime.toISO();
if (dateTimeISO === null)
return null;
switch (this.wkst) {
case "SU":
dateTimeInLocale = DateTime.fromISO(dateTimeISO, { locale: "en-US", zone: dateTime.zone });
break;
case "SA":
dateTimeInLocale = DateTime.fromISO(dateTimeISO, { locale: "ar-EG", zone: dateTime.zone });
break;
case "MO":
default:
dateTimeInLocale = DateTime.fromISO(dateTimeISO, { locale: "fr-FR", zone: dateTime.zone });
}
if (!dateTimeInLocale.isValid)
return null;
dateTimeInLocale.isDate = dateTime.isDate;
return dateTimeInLocale;
}
toString() {
return ` \n \
freq: ${this.freq} \n \
until: ${this.until ? toSQL(this.until) : ""} \n \
count: ${this.count} \n \
interval: ${this.interval} \n \
bysecond: ${this.bysecond.map(i => i.toString()).reduce((prev, cur) => { return prev + ((prev === "") ? "" : ",") + cur; }, "")} \n \
byminute: ${this.byminute.map(i => i.toString()).reduce((prev, cur) => { return prev + ((prev === "") ? "" : ",") + cur; }, "")} \n \
byhour: ${this.byhour.map(i => i.toString()).reduce((prev, cur) => { return prev + ((prev === "") ? "" : ",") + cur; }, "")} \n \
byday: ${JSON.stringify(this.byday)} \n \
bymonthday: ${JSON.stringify(this.bymonthday)} \n \
byyearday: ${JSON.stringify(this.byyearday)} \n \
byweekno: ${JSON.stringify(this.byweekno)} \n \
bymonth: ${JSON.stringify(this.bymonth)} \n \
bysetpos: ${JSON.stringify(this.bysetpos)} \n \
wkst: ${JSON.stringify(this.wkst)} \
`;
}
constructor(rrule) {
const rruleParts = rrule.split(":")[1].split(";");
for (const part of rruleParts) {
if (part === "")
continue;
const keyValue = part.split("=");
if (keyValue.length < 2)
continue;
const key = keyValue[0].trim().toUpperCase();
const value = keyValue[1].trim();
//TODO: check if each parsed value is acceptable (e.g. hour in 0-23)
switch (key) {
case "FREQ":
this.freq = value;
break;
case "UNTIL":
//need to keep the format including the key "UNTIL=..."" here
//there is only 1 until date
try {
this.until = parseICalDateTime(`UNTIL=${value}`)[0];
}
catch (e) {
console.error("RRule constructor", `Could not parse UNTIL: ${part}`);
console.error("RRule constructor", e);
}
break;
case "COUNT":
this.count = parseInt(value, 10);
if (Number.isNaN(this.count))
throw new Error(`RRULE constructor: COUNT format error value=${value}`);
break;
case "INTERVAL":
this.interval = parseInt(value, 10);
if (Number.isNaN(this.interval))
throw new Error(`RRULE constructor: INTERVAL format error value=${value}`);
break;
case "BYSECOND":
this.bysecond = value.split(",").map((second) => {
const parsed = parseInt(second, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYSECOND format error second=${second}`);
return parsed;
});
break;
case "BYMINUTE":
this.byminute = value.split(",").map((minute) => {
const parsed = parseInt(minute, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYMINUTE format error minute=${minute}`);
return parsed;
});
break;
case "BYHOUR":
this.byhour = value.split(",").map((hour) => {
const parsed = parseInt(hour, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYHOUR format error hour=${hour}`);
return parsed;
});
break;
case "BYDAY":
this.byday = value.split(",").map((day) => {
const match = day.match(/([+-]?\d+)?(MO|TU|WE|TH|FR|SA|SU)/);
const nth = (match && match[1]) ? parseInt(match[1], 10) : null;
const parsed = (match && match[2]) ? match[2].toUpperCase() : null;
if (parsed === null)
throw new Error(`RRULE constructor: BYDAY format error day=${day}`);
return {
nth: nth,
weekday: parsed
};
});
break;
case "BYMONTHDAY":
this.bymonthday = value.split(",").map((monthday) => {
const parsed = parseInt(monthday, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYMONTHDAY format error monthday=${monthday}`);
return parsed;
});
break;
case "BYYEARDAY":
this.byyearday = value.split(",").map((yearday) => {
const parsed = parseInt(yearday, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYYEARDAY format error yearday=${yearday}`);
return parsed;
});
break;
case "BYWEEKNO":
this.byweekno = value.split(",").map((weekno) => {
const parsed = parseInt(weekno, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYWEEKNO format error weekno=${weekno}`);
return parsed;
});
break;
case "BYMONTH":
this.bymonth = value.split(",").map((month) => {
const parsed = parseInt(month, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYMONTH format error month=${month}`);
return parsed;
});
break;
case "BYSETPOS":
this.bysetpos = value.split(",").map((setpos) => {
const parsed = parseInt(setpos, 10);
if (Number.isNaN(parsed))
throw new Error(`RRULE constructor: BYSETPOS format error setpos=${setpos}`);
return parsed;
});
break;
case "WKST":
const weekday = value.match(/MO|TU|WE|TH|FR|SA|SU/)?.[0].toUpperCase();
if (weekday === undefined)
throw new Error(`RRULE constructor: WKST format error value=${value}`);
this.wkst = weekday;
break;
default:
console.error(`RRULE constructor: unknown key=${key}, value=${value}`);
}
}
if (this.freq === null)
throw new Error("RRULE constructor: FREQ part is not defined");
}
}