/* 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)
};