/* 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.=} aData.bysecond The seconds for the BYSECOND part * @param {Array.=} aData.byminute The minutes for the BYMINUTE part * @param {Array.=} aData.byhour The hours for the BYHOUR part * @param {Array.=} aData.byday The BYDAY values * @param {Array.=} aData.bymonthday The days for the BYMONTHDAY part * @param {Array.=} aData.byyearday The days for the BYYEARDAY part * @param {Array.=} aData.byweekno The weeks for the BYWEEKNO part * @param {Array.=} aData.bymonth The month for the BYMONTH part * @param {Array.=} 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.=} data.bysecond The seconds for the BYSECOND part * @param {Array.=} data.byminute The minutes for the BYMINUTE part * @param {Array.=} data.byhour The hours for the BYHOUR part * @param {Array.=} data.byday The BYDAY values * @param {Array.=} data.bymonthday The days for the BYMONTHDAY part * @param {Array.=} data.byyearday The days for the BYYEARDAY part * @param {Array.=} data.byweekno The weeks for the BYWEEKNO part * @param {Array.=} data.bymonth The month for the BYMONTH part * @param {Array.=} 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.=} BYSECOND The seconds for the BYSECOND part * @property {Array.=} BYMINUTE The minutes for the BYMINUTE part * @property {Array.=} BYHOUR The hours for the BYHOUR part * @property {Array.=} BYDAY The BYDAY values * @property {Array.=} BYMONTHDAY The days for the BYMONTHDAY part * @property {Array.=} BYYEARDAY The days for the BYYEARDAY part * @property {Array.=} BYWEEKNO The weeks for the BYWEEKNO part * @property {Array.=} BYMONTH The month for the BYMONTH part * @property {Array.=} 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.=} data.bysecond The seconds for the BYSECOND part * @param {Array.=} data.byminute The minutes for the BYMINUTE part * @param {Array.=} data.byhour The hours for the BYHOUR part * @param {Array.=} data.byday The BYDAY values * @param {Array.=} data.bymonthday The days for the BYMONTHDAY part * @param {Array.=} data.byyearday The days for the BYYEARDAY part * @param {Array.=} data.byweekno The weeks for the BYWEEKNO part * @param {Array.=} data.bymonth The month for the BYMONTH part * @param {Array.=} 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) };