493 lines
12 KiB
JavaScript
493 lines
12 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 Time from "./time.js";
|
|
import RecurIterator from "./recur_iterator.js";
|
|
// needed for typescript type resolution
|
|
// eslint-disable-next-line no-unused-vars
|
|
import Component from "./component.js";
|
|
import { formatClassType, binsearchInsert } from "./helpers.js";
|
|
|
|
/**
|
|
* Primary class for expanding recurring rules. Can take multiple rrules, rdates, exdate(s) and
|
|
* iterate (in order) over each next occurrence.
|
|
*
|
|
* Once initialized this class can also be serialized saved and continue iteration from the last
|
|
* point.
|
|
*
|
|
* NOTE: it is intended that this class is to be used with {@link ICAL.Event} which handles recurrence
|
|
* exceptions.
|
|
*
|
|
* @example
|
|
* // assuming event is a parsed ical component
|
|
* var event;
|
|
*
|
|
* var expand = new ICAL.RecurExpansion({
|
|
* component: event,
|
|
* dtstart: event.getFirstPropertyValue('dtstart')
|
|
* });
|
|
*
|
|
* // remember there are infinite rules so it is a good idea to limit the scope of the iterations
|
|
* // then resume later on.
|
|
*
|
|
* // next is always an ICAL.Time or null
|
|
* var next;
|
|
*
|
|
* while (someCondition && (next = expand.next())) {
|
|
* // do something with next
|
|
* }
|
|
*
|
|
* // save instance for later
|
|
* var json = JSON.stringify(expand);
|
|
*
|
|
* //...
|
|
*
|
|
* // NOTE: if the component's properties have changed you will need to rebuild the class and start
|
|
* // over. This only works when the component's recurrence info is the same.
|
|
* var expand = new ICAL.RecurExpansion(JSON.parse(json));
|
|
*
|
|
* @memberof ICAL
|
|
*/
|
|
class RecurExpansion {
|
|
/**
|
|
* Creates a new ICAL.RecurExpansion instance.
|
|
*
|
|
* The options object can be filled with the specified initial values. It can also contain
|
|
* additional members, as a result of serializing a previous expansion state, as shown in the
|
|
* example.
|
|
*
|
|
* @param {Object} options
|
|
* Recurrence expansion options
|
|
* @param {Time} options.dtstart
|
|
* Start time of the event
|
|
* @param {Component=} options.component
|
|
* Component for expansion, required if not resuming.
|
|
*/
|
|
constructor(options) {
|
|
this.ruleDates = [];
|
|
this.exDates = [];
|
|
this.fromData(options);
|
|
}
|
|
|
|
/**
|
|
* True when iteration is fully completed.
|
|
* @type {Boolean}
|
|
*/
|
|
complete = false;
|
|
|
|
/**
|
|
* Array of rrule iterators.
|
|
*
|
|
* @type {RecurIterator[]}
|
|
* @private
|
|
*/
|
|
ruleIterators = null;
|
|
|
|
/**
|
|
* Array of rdate instances.
|
|
*
|
|
* @type {Time[]}
|
|
* @private
|
|
*/
|
|
ruleDates = null;
|
|
|
|
/**
|
|
* Array of exdate instances.
|
|
*
|
|
* @type {Time[]}
|
|
* @private
|
|
*/
|
|
exDates = null;
|
|
|
|
/**
|
|
* Current position in ruleDates array.
|
|
* @type {Number}
|
|
* @private
|
|
*/
|
|
ruleDateInc = 0;
|
|
|
|
/**
|
|
* Current position in exDates array
|
|
* @type {Number}
|
|
* @private
|
|
*/
|
|
exDateInc = 0;
|
|
|
|
/**
|
|
* Current negative date.
|
|
*
|
|
* @type {Time}
|
|
* @private
|
|
*/
|
|
exDate = null;
|
|
|
|
/**
|
|
* Current additional date.
|
|
*
|
|
* @type {Time}
|
|
* @private
|
|
*/
|
|
ruleDate = null;
|
|
|
|
/**
|
|
* Start date of recurring rules.
|
|
*
|
|
* @type {Time}
|
|
*/
|
|
dtstart = null;
|
|
|
|
/**
|
|
* Last expanded time
|
|
*
|
|
* @type {Time}
|
|
*/
|
|
last = null;
|
|
|
|
/**
|
|
* Initialize the recurrence expansion from the data object. The options
|
|
* object may also contain additional members, see the
|
|
* {@link ICAL.RecurExpansion constructor} for more details.
|
|
*
|
|
* @param {Object} options
|
|
* Recurrence expansion options
|
|
* @param {Time} options.dtstart
|
|
* Start time of the event
|
|
* @param {Component=} options.component
|
|
* Component for expansion, required if not resuming.
|
|
*/
|
|
fromData(options) {
|
|
let start = formatClassType(options.dtstart, Time);
|
|
|
|
if (!start) {
|
|
throw new Error('.dtstart (ICAL.Time) must be given');
|
|
} else {
|
|
this.dtstart = start;
|
|
}
|
|
|
|
if (options.component) {
|
|
this._init(options.component);
|
|
} else {
|
|
this.last = formatClassType(options.last, Time) || start.clone();
|
|
|
|
if (!options.ruleIterators) {
|
|
throw new Error('.ruleIterators or .component must be given');
|
|
}
|
|
|
|
this.ruleIterators = options.ruleIterators.map(function(item) {
|
|
return formatClassType(item, RecurIterator);
|
|
});
|
|
|
|
this.ruleDateInc = options.ruleDateInc;
|
|
this.exDateInc = options.exDateInc;
|
|
|
|
if (options.ruleDates) {
|
|
this.ruleDates = options.ruleDates.map(item => formatClassType(item, Time));
|
|
this.ruleDate = this.ruleDates[this.ruleDateInc];
|
|
}
|
|
|
|
if (options.exDates) {
|
|
this.exDates = options.exDates.map(item => formatClassType(item, Time));
|
|
this.exDate = this.exDates[this.exDateInc];
|
|
}
|
|
|
|
if (typeof(options.complete) !== 'undefined') {
|
|
this.complete = options.complete;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare two ICAL.Time objects. When the second parameter is a DATE and the first parameter is
|
|
* DATE-TIME, strip the time and compare only the days.
|
|
*
|
|
* @private
|
|
* @param {Time} a The one object to compare
|
|
* @param {Time} b The other object to compare
|
|
*/
|
|
_compare_special(a, b) {
|
|
if (!a.isDate && b.isDate)
|
|
return new Time({ year: a.year, month: a.month, day: a.day }).compare(b);
|
|
return a.compare(b);
|
|
}
|
|
|
|
/**
|
|
* Retrieve the next occurrence in the series.
|
|
* @return {Time}
|
|
*/
|
|
next() {
|
|
let iter;
|
|
let next;
|
|
let compare;
|
|
|
|
let maxTries = 500;
|
|
let currentTry = 0;
|
|
|
|
while (true) {
|
|
if (currentTry++ > maxTries) {
|
|
throw new Error(
|
|
'max tries have occurred, rule may be impossible to fulfill.'
|
|
);
|
|
}
|
|
|
|
next = this.ruleDate;
|
|
iter = this._nextRecurrenceIter(this.last);
|
|
|
|
// no more matches
|
|
// because we increment the rule day or rule
|
|
// _after_ we choose a value this should be
|
|
// the only spot where we need to worry about the
|
|
// end of events.
|
|
if (!next && !iter) {
|
|
// there are no more iterators or rdates
|
|
this.complete = true;
|
|
break;
|
|
}
|
|
|
|
// no next rule day or recurrence rule is first.
|
|
if (!next || (iter && next.compare(iter.last) > 0)) {
|
|
// must be cloned, recur will reuse the time element.
|
|
next = iter.last.clone();
|
|
// move to next so we can continue
|
|
iter.next();
|
|
}
|
|
|
|
// if the ruleDate is still next increment it.
|
|
if (this.ruleDate === next) {
|
|
this._nextRuleDay();
|
|
}
|
|
|
|
this.last = next;
|
|
|
|
// check the negative rules
|
|
if (this.exDate) {
|
|
// EXDATE can be in DATE format, but DTSTART is in DATE-TIME format
|
|
compare = this._compare_special(this.last, this.exDate);
|
|
|
|
if (compare > 0) {
|
|
this._nextExDay();
|
|
}
|
|
|
|
// if the current rule is excluded skip it.
|
|
if (compare === 0) {
|
|
this._nextExDay();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
//XXX: The spec states that after we resolve the final
|
|
// list of dates we execute exdate this seems somewhat counter
|
|
// intuitive to what I have seen most servers do so for now
|
|
// I exclude based on the original date not the one that may
|
|
// have been modified by the exception.
|
|
return this.last;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts object into a serialize-able format. This format can be passed
|
|
* back into the expansion to resume iteration.
|
|
* @return {Object}
|
|
*/
|
|
toJSON() {
|
|
function toJSON(item) {
|
|
return item.toJSON();
|
|
}
|
|
|
|
let result = Object.create(null);
|
|
result.ruleIterators = this.ruleIterators.map(toJSON);
|
|
|
|
if (this.ruleDates) {
|
|
result.ruleDates = this.ruleDates.map(toJSON);
|
|
}
|
|
|
|
if (this.exDates) {
|
|
result.exDates = this.exDates.map(toJSON);
|
|
}
|
|
|
|
result.ruleDateInc = this.ruleDateInc;
|
|
result.exDateInc = this.exDateInc;
|
|
result.last = this.last.toJSON();
|
|
result.dtstart = this.dtstart.toJSON();
|
|
result.complete = this.complete;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Extract all dates from the properties in the given component. The
|
|
* properties will be filtered by the property name.
|
|
*
|
|
* @private
|
|
* @param {Component} component The component to search in
|
|
* @param {String} propertyName The property name to search for
|
|
* @return {Time[]} The extracted dates.
|
|
*/
|
|
_extractDates(component, propertyName) {
|
|
let result = [];
|
|
let props = component.getAllProperties(propertyName);
|
|
|
|
for (let i = 0, len = props.length; i < len; i++) {
|
|
for (let prop of props[i].getValues()) {
|
|
let idx = binsearchInsert(
|
|
result,
|
|
prop,
|
|
(a, b) => a.compare(b)
|
|
);
|
|
|
|
// ordered insert
|
|
result.splice(idx, 0, prop);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Initialize the recurrence expansion.
|
|
*
|
|
* @private
|
|
* @param {Component} component The component to initialize from.
|
|
*/
|
|
_init(component) {
|
|
this.ruleIterators = [];
|
|
|
|
this.last = this.dtstart.clone();
|
|
|
|
// to provide api consistency non-recurring
|
|
// events can also use the iterator though it will
|
|
// only return a single time.
|
|
if (!component.hasProperty('rdate') &&
|
|
!component.hasProperty('rrule') &&
|
|
!component.hasProperty('recurrence-id')) {
|
|
this.ruleDate = this.last.clone();
|
|
this.complete = true;
|
|
return;
|
|
}
|
|
|
|
if (component.hasProperty('rdate')) {
|
|
this.ruleDates = this._extractDates(component, 'rdate');
|
|
|
|
// special hack for cases where first rdate is prior
|
|
// to the start date. We only check for the first rdate.
|
|
// This is mostly for google's crazy recurring date logic
|
|
// (contacts birthdays).
|
|
if ((this.ruleDates[0]) &&
|
|
(this.ruleDates[0].compare(this.dtstart) < 0)) {
|
|
|
|
this.ruleDateInc = 0;
|
|
this.last = this.ruleDates[0].clone();
|
|
} else {
|
|
this.ruleDateInc = binsearchInsert(
|
|
this.ruleDates,
|
|
this.last,
|
|
(a, b) => a.compare(b)
|
|
);
|
|
}
|
|
|
|
this.ruleDate = this.ruleDates[this.ruleDateInc];
|
|
}
|
|
|
|
if (component.hasProperty('rrule')) {
|
|
let rules = component.getAllProperties('rrule');
|
|
let i = 0;
|
|
let len = rules.length;
|
|
|
|
let rule;
|
|
let iter;
|
|
|
|
for (; i < len; i++) {
|
|
rule = rules[i].getFirstValue();
|
|
iter = rule.iterator(this.dtstart);
|
|
this.ruleIterators.push(iter);
|
|
|
|
// increment to the next occurrence so future
|
|
// calls to next return times beyond the initial iteration.
|
|
// XXX: I find this suspicious might be a bug?
|
|
iter.next();
|
|
}
|
|
}
|
|
|
|
if (component.hasProperty('exdate')) {
|
|
this.exDates = this._extractDates(component, 'exdate');
|
|
// if we have a .last day we increment the index to beyond it.
|
|
// When DTSTART is in DATE-TIME format, EXDATE is in DATE format and EXDATE is
|
|
// the date of DTSTART, _compare_special finds this out and compareTime fails.
|
|
this.exDateInc = binsearchInsert(
|
|
this.exDates,
|
|
this.last,
|
|
this._compare_special
|
|
);
|
|
|
|
this.exDate = this.exDates[this.exDateInc];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Advance to the next exdate
|
|
* @private
|
|
*/
|
|
_nextExDay() {
|
|
this.exDate = this.exDates[++this.exDateInc];
|
|
}
|
|
|
|
/**
|
|
* Advance to the next rule date
|
|
* @private
|
|
*/
|
|
_nextRuleDay() {
|
|
this.ruleDate = this.ruleDates[++this.ruleDateInc];
|
|
}
|
|
|
|
/**
|
|
* Find and return the recurrence rule with the most recent event and
|
|
* return it.
|
|
*
|
|
* @private
|
|
* @return {?RecurIterator} Found iterator.
|
|
*/
|
|
_nextRecurrenceIter() {
|
|
let iters = this.ruleIterators;
|
|
|
|
if (iters.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let len = iters.length;
|
|
let iter;
|
|
let iterTime;
|
|
let iterIdx = 0;
|
|
let chosenIter;
|
|
|
|
// loop through each iterator
|
|
for (; iterIdx < len; iterIdx++) {
|
|
iter = iters[iterIdx];
|
|
iterTime = iter.last;
|
|
|
|
// if iteration is complete
|
|
// then we must exclude it from
|
|
// the search and remove it.
|
|
if (iter.completed) {
|
|
len--;
|
|
if (iterIdx !== 0) {
|
|
iterIdx--;
|
|
}
|
|
iters.splice(iterIdx, 1);
|
|
continue;
|
|
}
|
|
|
|
// find the most recent possible choice
|
|
if (!chosenIter || chosenIter.last.compare(iterTime) > 0) {
|
|
// that iterator is saved
|
|
chosenIter = iter;
|
|
}
|
|
}
|
|
|
|
// the chosen iterator is returned but not mutated
|
|
// this iterator contains the most recent event.
|
|
return chosenIter;
|
|
}
|
|
}
|
|
export default RecurExpansion;
|