recur_expansion.js

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

  /**
   * 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) {
        compare = this.exDate.compare(this.last);

        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.
      this.exDateInc = binsearchInsert(
        this.exDates,
        this.last,
        (a, b) => a.compare(b)
      );

      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;