577 lines
18 KiB
JavaScript
577 lines
18 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
* Portions Copyright (C) Philipp Kewisch */
|
|
|
|
import RecurIterator from "./recur_iterator.js";
|
|
import Time from "./time.js";
|
|
import design from "./design.js";
|
|
import { strictParseInt, clone } from "./helpers.js";
|
|
|
|
/**
|
|
* This lets typescript resolve our custom types in the
|
|
* generated d.ts files (jsdoc typedefs are converted to typescript types).
|
|
* Ignore prevents the typedefs from being documented more than once.
|
|
*
|
|
* @ignore
|
|
* @typedef {import("./types.js").weekDay} weekDay
|
|
* Imports the 'weekDay' type from the "types.js" module
|
|
* @typedef {import("./types.js").frequencyValues} frequencyValues
|
|
* Imports the 'frequencyValues' type from the "types.js" module
|
|
*/
|
|
|
|
const VALID_DAY_NAMES = /^(SU|MO|TU|WE|TH|FR|SA)$/;
|
|
const VALID_BYDAY_PART = /^([+-])?(5[0-3]|[1-4][0-9]|[1-9])?(SU|MO|TU|WE|TH|FR|SA)$/;
|
|
const DOW_MAP = {
|
|
SU: Time.SUNDAY,
|
|
MO: Time.MONDAY,
|
|
TU: Time.TUESDAY,
|
|
WE: Time.WEDNESDAY,
|
|
TH: Time.THURSDAY,
|
|
FR: Time.FRIDAY,
|
|
SA: Time.SATURDAY
|
|
};
|
|
|
|
const REVERSE_DOW_MAP = Object.fromEntries(Object.entries(DOW_MAP).map(entry => entry.reverse()));
|
|
|
|
const ALLOWED_FREQ = ['SECONDLY', 'MINUTELY', 'HOURLY',
|
|
'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'];
|
|
|
|
/**
|
|
* This class represents the "recur" value type, used for example by RRULE. It provides methods to
|
|
* calculate occurrences among others.
|
|
*
|
|
* @memberof ICAL
|
|
*/
|
|
class Recur {
|
|
/**
|
|
* Creates a new {@link ICAL.Recur} instance from the passed string.
|
|
*
|
|
* @param {String} string The string to parse
|
|
* @return {Recur} The created recurrence instance
|
|
*/
|
|
static fromString(string) {
|
|
let data = this._stringToData(string, false);
|
|
return new Recur(data);
|
|
}
|
|
|
|
/**
|
|
* Creates a new {@link ICAL.Recur} instance using members from the passed
|
|
* data object.
|
|
*
|
|
* @param {Object} aData An object with members of the recurrence
|
|
* @param {frequencyValues=} aData.freq The frequency value
|
|
* @param {Number=} aData.interval The INTERVAL value
|
|
* @param {weekDay=} aData.wkst The week start value
|
|
* @param {Time=} aData.until The end of the recurrence set
|
|
* @param {Number=} aData.count The number of occurrences
|
|
* @param {Array.<Number>=} aData.bysecond The seconds for the BYSECOND part
|
|
* @param {Array.<Number>=} aData.byminute The minutes for the BYMINUTE part
|
|
* @param {Array.<Number>=} aData.byhour The hours for the BYHOUR part
|
|
* @param {Array.<String>=} aData.byday The BYDAY values
|
|
* @param {Array.<Number>=} aData.bymonthday The days for the BYMONTHDAY part
|
|
* @param {Array.<Number>=} aData.byyearday The days for the BYYEARDAY part
|
|
* @param {Array.<Number>=} aData.byweekno The weeks for the BYWEEKNO part
|
|
* @param {Array.<Number>=} aData.bymonth The month for the BYMONTH part
|
|
* @param {Array.<Number>=} aData.bysetpos The positionals for the BYSETPOS part
|
|
*/
|
|
static fromData(aData) {
|
|
return new Recur(aData);
|
|
}
|
|
|
|
/**
|
|
* Converts a recurrence string to a data object, suitable for the fromData
|
|
* method.
|
|
*
|
|
* @private
|
|
* @param {String} string The string to parse
|
|
* @param {Boolean} fmtIcal If true, the string is considered to be an
|
|
* iCalendar string
|
|
* @return {Recur} The recurrence instance
|
|
*/
|
|
static _stringToData(string, fmtIcal) {
|
|
let dict = Object.create(null);
|
|
|
|
// split is slower in FF but fast enough.
|
|
// v8 however this is faster then manual split?
|
|
let values = string.split(';');
|
|
let len = values.length;
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
let parts = values[i].split('=');
|
|
let ucname = parts[0].toUpperCase();
|
|
let lcname = parts[0].toLowerCase();
|
|
let name = (fmtIcal ? lcname : ucname);
|
|
let value = parts[1];
|
|
|
|
if (ucname in partDesign) {
|
|
let partArr = value.split(',');
|
|
let partSet = new Set();
|
|
|
|
for (let part of partArr) {
|
|
partSet.add(partDesign[ucname](part));
|
|
}
|
|
partArr = [...partSet];
|
|
|
|
dict[name] = (partArr.length == 1 ? partArr[0] : partArr);
|
|
} else if (ucname in optionDesign) {
|
|
optionDesign[ucname](value, dict, fmtIcal);
|
|
} else {
|
|
// Don't swallow unknown values. Just set them as they are.
|
|
dict[lcname] = value;
|
|
}
|
|
}
|
|
|
|
return dict;
|
|
}
|
|
|
|
/**
|
|
* Convert an ical representation of a day (SU, MO, etc..)
|
|
* into a numeric value of that day.
|
|
*
|
|
* @param {String} string The iCalendar day name
|
|
* @param {weekDay=} aWeekStart
|
|
* The week start weekday, defaults to SUNDAY
|
|
* @return {Number} Numeric value of given day
|
|
*/
|
|
static icalDayToNumericDay(string, aWeekStart) {
|
|
//XXX: this is here so we can deal
|
|
// with possibly invalid string values.
|
|
let firstDow = aWeekStart || Time.SUNDAY;
|
|
return ((DOW_MAP[string] - firstDow + 7) % 7) + 1;
|
|
}
|
|
|
|
/**
|
|
* Convert a numeric day value into its ical representation (SU, MO, etc..)
|
|
*
|
|
* @param {Number} num Numeric value of given day
|
|
* @param {weekDay=} aWeekStart
|
|
* The week start weekday, defaults to SUNDAY
|
|
* @return {String} The ICAL day value, e.g SU,MO,...
|
|
*/
|
|
static numericDayToIcalDay(num, aWeekStart) {
|
|
//XXX: this is here so we can deal with possibly invalid number values.
|
|
// Also, this allows consistent mapping between day numbers and day
|
|
// names for external users.
|
|
let firstDow = aWeekStart || Time.SUNDAY;
|
|
let dow = (num + firstDow - Time.SUNDAY);
|
|
if (dow > 7) {
|
|
dow -= 7;
|
|
}
|
|
return REVERSE_DOW_MAP[dow];
|
|
}
|
|
|
|
/**
|
|
* Create a new instance of the Recur class.
|
|
*
|
|
* @param {Object} data An object with members of the recurrence
|
|
* @param {frequencyValues=} data.freq The frequency value
|
|
* @param {Number=} data.interval The INTERVAL value
|
|
* @param {weekDay=} data.wkst The week start value
|
|
* @param {Time=} data.until The end of the recurrence set
|
|
* @param {Number=} data.count The number of occurrences
|
|
* @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part
|
|
* @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part
|
|
* @param {Array.<Number>=} data.byhour The hours for the BYHOUR part
|
|
* @param {Array.<String>=} data.byday The BYDAY values
|
|
* @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part
|
|
* @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part
|
|
* @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part
|
|
* @param {Array.<Number>=} data.bymonth The month for the BYMONTH part
|
|
* @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part
|
|
*/
|
|
constructor(data) {
|
|
this.wrappedJSObject = this;
|
|
this.parts = {};
|
|
|
|
if (data && typeof(data) === 'object') {
|
|
this.fromData(data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* An object holding the BY-parts of the recurrence rule
|
|
* @memberof ICAL.Recur
|
|
* @typedef {Object} byParts
|
|
* @property {Array.<Number>=} BYSECOND The seconds for the BYSECOND part
|
|
* @property {Array.<Number>=} BYMINUTE The minutes for the BYMINUTE part
|
|
* @property {Array.<Number>=} BYHOUR The hours for the BYHOUR part
|
|
* @property {Array.<String>=} BYDAY The BYDAY values
|
|
* @property {Array.<Number>=} BYMONTHDAY The days for the BYMONTHDAY part
|
|
* @property {Array.<Number>=} BYYEARDAY The days for the BYYEARDAY part
|
|
* @property {Array.<Number>=} BYWEEKNO The weeks for the BYWEEKNO part
|
|
* @property {Array.<Number>=} BYMONTH The month for the BYMONTH part
|
|
* @property {Array.<Number>=} BYSETPOS The positionals for the BYSETPOS part
|
|
*/
|
|
|
|
/**
|
|
* An object holding the BY-parts of the recurrence rule
|
|
* @type {byParts}
|
|
*/
|
|
parts = null;
|
|
|
|
/**
|
|
* The interval value for the recurrence rule.
|
|
* @type {Number}
|
|
*/
|
|
interval = 1;
|
|
|
|
/**
|
|
* The week start day
|
|
*
|
|
* @type {weekDay}
|
|
* @default ICAL.Time.MONDAY
|
|
*/
|
|
wkst = Time.MONDAY;
|
|
|
|
/**
|
|
* The end of the recurrence
|
|
* @type {?Time}
|
|
*/
|
|
until = null;
|
|
|
|
/**
|
|
* The maximum number of occurrences
|
|
* @type {?Number}
|
|
*/
|
|
count = null;
|
|
|
|
/**
|
|
* The frequency value.
|
|
* @type {frequencyValues}
|
|
*/
|
|
freq = null;
|
|
|
|
/**
|
|
* The class identifier.
|
|
* @constant
|
|
* @type {String}
|
|
* @default "icalrecur"
|
|
*/
|
|
icalclass = "icalrecur";
|
|
|
|
/**
|
|
* The type name, to be used in the jCal object.
|
|
* @constant
|
|
* @type {String}
|
|
* @default "recur"
|
|
*/
|
|
icaltype = "recur";
|
|
|
|
/**
|
|
* Create a new iterator for this recurrence rule. The passed start date
|
|
* must be the start date of the event, not the start of the range to
|
|
* search in.
|
|
*
|
|
* @example
|
|
* let recur = comp.getFirstPropertyValue('rrule');
|
|
* let dtstart = comp.getFirstPropertyValue('dtstart');
|
|
* let iter = recur.iterator(dtstart);
|
|
* for (let next = iter.next(); next; next = iter.next()) {
|
|
* if (next.compare(rangeStart) < 0) {
|
|
* continue;
|
|
* }
|
|
* console.log(next.toString());
|
|
* }
|
|
*
|
|
* @param {Time} aStart The item's start date
|
|
* @return {RecurIterator} The recurrence iterator
|
|
*/
|
|
iterator(aStart) {
|
|
return new RecurIterator({
|
|
rule: this,
|
|
dtstart: aStart
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a clone of the recurrence object.
|
|
*
|
|
* @return {Recur} The cloned object
|
|
*/
|
|
clone() {
|
|
return new Recur(this.toJSON());
|
|
}
|
|
|
|
/**
|
|
* Checks if the current rule is finite, i.e. has a count or until part.
|
|
*
|
|
* @return {Boolean} True, if the rule is finite
|
|
*/
|
|
isFinite() {
|
|
return !!(this.count || this.until);
|
|
}
|
|
|
|
/**
|
|
* Checks if the current rule has a count part, and not limited by an until
|
|
* part.
|
|
*
|
|
* @return {Boolean} True, if the rule is by count
|
|
*/
|
|
isByCount() {
|
|
return !!(this.count && !this.until);
|
|
}
|
|
|
|
/**
|
|
* Adds a component (part) to the recurrence rule. This is not a component
|
|
* in the sense of {@link ICAL.Component}, but a part of the recurrence
|
|
* rule, i.e. BYMONTH.
|
|
*
|
|
* @param {String} aType The name of the component part
|
|
* @param {Array|String} aValue The component value
|
|
*/
|
|
addComponent(aType, aValue) {
|
|
let ucname = aType.toUpperCase();
|
|
if (ucname in this.parts) {
|
|
this.parts[ucname].push(aValue);
|
|
} else {
|
|
this.parts[ucname] = [aValue];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the component value for the given by-part.
|
|
*
|
|
* @param {String} aType The component part name
|
|
* @param {Array} aValues The component values
|
|
*/
|
|
setComponent(aType, aValues) {
|
|
this.parts[aType.toUpperCase()] = aValues.slice();
|
|
}
|
|
|
|
/**
|
|
* Gets (a copy) of the requested component value.
|
|
*
|
|
* @param {String} aType The component part name
|
|
* @return {Array} The component part value
|
|
*/
|
|
getComponent(aType) {
|
|
let ucname = aType.toUpperCase();
|
|
return (ucname in this.parts ? this.parts[ucname].slice() : []);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the next occurrence after the given recurrence id. See the
|
|
* guide on {@tutorial terminology} for more details.
|
|
*
|
|
* NOTE: Currently, this method iterates all occurrences from the start
|
|
* date. It should not be called in a loop for performance reasons. If you
|
|
* would like to get more than one occurrence, you can iterate the
|
|
* occurrences manually, see the example on the
|
|
* {@link ICAL.Recur#iterator iterator} method.
|
|
*
|
|
* @param {Time} aStartTime The start of the event series
|
|
* @param {Time} aRecurrenceId The date of the last occurrence
|
|
* @return {Time} The next occurrence after
|
|
*/
|
|
getNextOccurrence(aStartTime, aRecurrenceId) {
|
|
let iter = this.iterator(aStartTime);
|
|
let next;
|
|
|
|
do {
|
|
next = iter.next();
|
|
} while (next && next.compare(aRecurrenceId) <= 0);
|
|
|
|
if (next && aRecurrenceId.zone) {
|
|
next.zone = aRecurrenceId.zone;
|
|
}
|
|
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Sets up the current instance using members from the passed data object.
|
|
*
|
|
* @param {Object} data An object with members of the recurrence
|
|
* @param {frequencyValues=} data.freq The frequency value
|
|
* @param {Number=} data.interval The INTERVAL value
|
|
* @param {weekDay=} data.wkst The week start value
|
|
* @param {Time=} data.until The end of the recurrence set
|
|
* @param {Number=} data.count The number of occurrences
|
|
* @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part
|
|
* @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part
|
|
* @param {Array.<Number>=} data.byhour The hours for the BYHOUR part
|
|
* @param {Array.<String>=} data.byday The BYDAY values
|
|
* @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part
|
|
* @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part
|
|
* @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part
|
|
* @param {Array.<Number>=} data.bymonth The month for the BYMONTH part
|
|
* @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part
|
|
*/
|
|
fromData(data) {
|
|
for (let key in data) {
|
|
let uckey = key.toUpperCase();
|
|
|
|
if (uckey in partDesign) {
|
|
if (Array.isArray(data[key])) {
|
|
this.parts[uckey] = data[key];
|
|
} else {
|
|
this.parts[uckey] = [data[key]];
|
|
}
|
|
} else {
|
|
this[key] = data[key];
|
|
}
|
|
}
|
|
|
|
if (this.interval && typeof this.interval != "number") {
|
|
optionDesign.INTERVAL(this.interval, this);
|
|
}
|
|
|
|
if (this.wkst && typeof this.wkst != "number") {
|
|
this.wkst = Recur.icalDayToNumericDay(this.wkst);
|
|
}
|
|
|
|
if (this.until && !(this.until instanceof Time)) {
|
|
this.until = Time.fromString(this.until);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The jCal representation of this recurrence type.
|
|
* @return {Object}
|
|
*/
|
|
toJSON() {
|
|
let res = Object.create(null);
|
|
res.freq = this.freq;
|
|
|
|
if (this.count) {
|
|
res.count = this.count;
|
|
}
|
|
|
|
if (this.interval > 1) {
|
|
res.interval = this.interval;
|
|
}
|
|
|
|
for (let [k, kparts] of Object.entries(this.parts)) {
|
|
if (Array.isArray(kparts) && kparts.length == 1) {
|
|
res[k.toLowerCase()] = kparts[0];
|
|
} else {
|
|
res[k.toLowerCase()] = clone(kparts);
|
|
}
|
|
}
|
|
|
|
if (this.until) {
|
|
res.until = this.until.toString();
|
|
}
|
|
if ('wkst' in this && this.wkst !== Time.DEFAULT_WEEK_START) {
|
|
res.wkst = Recur.numericDayToIcalDay(this.wkst);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* The string representation of this recurrence rule.
|
|
* @return {String}
|
|
*/
|
|
toString() {
|
|
// TODO retain order
|
|
let str = "FREQ=" + this.freq;
|
|
if (this.count) {
|
|
str += ";COUNT=" + this.count;
|
|
}
|
|
if (this.interval > 1) {
|
|
str += ";INTERVAL=" + this.interval;
|
|
}
|
|
for (let [k, v] of Object.entries(this.parts)) {
|
|
str += ";" + k + "=" + v;
|
|
}
|
|
if (this.until) {
|
|
str += ';UNTIL=' + this.until.toICALString();
|
|
}
|
|
if ('wkst' in this && this.wkst !== Time.DEFAULT_WEEK_START) {
|
|
str += ';WKST=' + Recur.numericDayToIcalDay(this.wkst);
|
|
}
|
|
return str;
|
|
}
|
|
}
|
|
export default Recur;
|
|
|
|
function parseNumericValue(type, min, max, value) {
|
|
let result = value;
|
|
|
|
if (value[0] === '+') {
|
|
result = value.slice(1);
|
|
}
|
|
|
|
result = strictParseInt(result);
|
|
|
|
if (min !== undefined && value < min) {
|
|
throw new Error(
|
|
type + ': invalid value "' + value + '" must be > ' + min
|
|
);
|
|
}
|
|
|
|
if (max !== undefined && value > max) {
|
|
throw new Error(
|
|
type + ': invalid value "' + value + '" must be < ' + min
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const optionDesign = {
|
|
FREQ: function(value, dict, fmtIcal) {
|
|
// yes this is actually equal or faster then regex.
|
|
// upside here is we can enumerate the valid values.
|
|
if (ALLOWED_FREQ.indexOf(value) !== -1) {
|
|
dict.freq = value;
|
|
} else {
|
|
throw new Error(
|
|
'invalid frequency "' + value + '" expected: "' +
|
|
ALLOWED_FREQ.join(', ') + '"'
|
|
);
|
|
}
|
|
},
|
|
|
|
COUNT: function(value, dict, fmtIcal) {
|
|
dict.count = strictParseInt(value);
|
|
},
|
|
|
|
INTERVAL: function(value, dict, fmtIcal) {
|
|
dict.interval = strictParseInt(value);
|
|
if (dict.interval < 1) {
|
|
// 0 or negative values are not allowed, some engines seem to generate
|
|
// it though. Assume 1 instead.
|
|
dict.interval = 1;
|
|
}
|
|
},
|
|
|
|
UNTIL: function(value, dict, fmtIcal) {
|
|
if (value.length > 10) {
|
|
dict.until = design.icalendar.value['date-time'].fromICAL(value);
|
|
} else {
|
|
dict.until = design.icalendar.value.date.fromICAL(value);
|
|
}
|
|
if (!fmtIcal) {
|
|
dict.until = Time.fromString(dict.until);
|
|
}
|
|
},
|
|
|
|
WKST: function(value, dict, fmtIcal) {
|
|
if (VALID_DAY_NAMES.test(value)) {
|
|
dict.wkst = Recur.icalDayToNumericDay(value);
|
|
} else {
|
|
throw new Error('invalid WKST value "' + value + '"');
|
|
}
|
|
}
|
|
};
|
|
|
|
const partDesign = {
|
|
BYSECOND: parseNumericValue.bind(undefined, 'BYSECOND', 0, 60),
|
|
BYMINUTE: parseNumericValue.bind(undefined, 'BYMINUTE', 0, 59),
|
|
BYHOUR: parseNumericValue.bind(undefined, 'BYHOUR', 0, 23),
|
|
BYDAY: function(value) {
|
|
if (VALID_BYDAY_PART.test(value)) {
|
|
return value;
|
|
} else {
|
|
throw new Error('invalid BYDAY value "' + value + '"');
|
|
}
|
|
},
|
|
BYMONTHDAY: parseNumericValue.bind(undefined, 'BYMONTHDAY', -31, 31),
|
|
BYYEARDAY: parseNumericValue.bind(undefined, 'BYYEARDAY', -366, 366),
|
|
BYWEEKNO: parseNumericValue.bind(undefined, 'BYWEEKNO', -53, 53),
|
|
BYMONTH: parseNumericValue.bind(undefined, 'BYMONTH', 1, 12),
|
|
BYSETPOS: parseNumericValue.bind(undefined, 'BYSETPOS', -366, 366)
|
|
};
|