event.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 { binsearchInsert } from "./helpers.js";
import Component from "./component.js";
import Property from "./property.js";
import Timezone from "./timezone.js";
// needed for typescript type resolution
// eslint-disable-next-line no-unused-vars
import Time from "./time.js";
// eslint-disable-next-line no-unused-vars
import Duration from "./duration.js";
import RecurExpansion from "./recur_expansion.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").frequencyValues} frequencyValues
 * Imports the 'frequencyValues' type from the "types.js" module
 * @typedef {import("./types.js").occurrenceDetails} occurrenceDetails
 * Imports the 'occurrenceDetails' type from the "types.js" module
 */

/**
 * ICAL.js is organized into multiple layers. The bottom layer is a raw jCal
 * object, followed by the component/property layer. The highest level is the
 * event representation, which this class is part of. See the
 * {@tutorial layers} guide for more details.
 *
 * @memberof ICAL
 */
class Event {
  /**
   * Creates a new ICAL.Event instance.
   *
   * @param {Component=} component              The ICAL.Component to base this event on
   * @param {Object} [options]                  Options for this event
   * @param {Boolean=} options.strictExceptions  When true, will verify exceptions are related by
   *                                              their UUID
   * @param {Array<Component|Event>=} options.exceptions
   *          Exceptions to this event, either as components or events. If not
   *            specified exceptions will automatically be set in relation of
   *            component's parent
   */
  constructor(component, options) {
    if (!(component instanceof Component)) {
      options = component;
      component = null;
    }

    if (component) {
      this.component = component;
    } else {
      this.component = new Component('vevent');
    }

    this._rangeExceptionCache = Object.create(null);
    this.exceptions = Object.create(null);
    this.rangeExceptions = [];

    if (options && options.strictExceptions) {
      this.strictExceptions = options.strictExceptions;
    }

    if (options && options.exceptions) {
      options.exceptions.forEach(this.relateException, this);
    } else if (this.component.parent && !this.isRecurrenceException()) {
      this.component.parent.getAllSubcomponents('vevent').forEach(function(event) {
        if (event.hasProperty('recurrence-id')) {
          this.relateException(event);
        }
      }, this);
    }
  }


  static THISANDFUTURE = 'THISANDFUTURE';

  /**
   * List of related event exceptions.
   *
   * @type {Event[]}
   */
  exceptions = null;

  /**
   * When true, will verify exceptions are related by their UUID.
   *
   * @type {Boolean}
   */
  strictExceptions = false;

  /**
   * Relates a given event exception to this object.  If the given component
   * does not share the UID of this event it cannot be related and will throw
   * an exception.
   *
   * If this component is an exception it cannot have other exceptions
   * related to it.
   *
   * @param {Component|Event} obj       Component or event
   */
  relateException(obj) {
    if (this.isRecurrenceException()) {
      throw new Error('cannot relate exception to exceptions');
    }

    if (obj instanceof Component) {
      obj = new Event(obj);
    }

    if (this.strictExceptions && obj.uid !== this.uid) {
      throw new Error('attempted to relate unrelated exception');
    }

    let id = obj.recurrenceId.toString();

    // we don't sort or manage exceptions directly
    // here the recurrence expander handles that.
    this.exceptions[id] = obj;

    // index RANGE=THISANDFUTURE exceptions so we can
    // look them up later in getOccurrenceDetails.
    if (obj.modifiesFuture()) {
      let item = [
        obj.recurrenceId.toUnixTime(), id
      ];

      // we keep them sorted so we can find the nearest
      // value later on...
      let idx = binsearchInsert(
        this.rangeExceptions,
        item,
        compareRangeException
      );

      this.rangeExceptions.splice(idx, 0, item);
    }
  }

  /**
   * Checks if this record is an exception and has the RANGE=THISANDFUTURE
   * value.
   *
   * @return {Boolean}        True, when exception is within range
   */
  modifiesFuture() {
    if (!this.component.hasProperty('recurrence-id')) {
      return false;
    }

    let range = this.component.getFirstProperty('recurrence-id').getParameter('range');
    return range === Event.THISANDFUTURE;
  }

  /**
   * Finds the range exception nearest to the given date.
   *
   * @param {Time} time   usually an occurrence time of an event
   * @return {?Event}     the related event/exception or null
   */
  findRangeException(time) {
    if (!this.rangeExceptions.length) {
      return null;
    }

    let utc = time.toUnixTime();
    let idx = binsearchInsert(
      this.rangeExceptions,
      [utc],
      compareRangeException
    );

    idx -= 1;

    // occurs before
    if (idx < 0) {
      return null;
    }

    let rangeItem = this.rangeExceptions[idx];

    /* c8 ignore next 4 */
    if (utc < rangeItem[0]) {
      // sanity check only
      return null;
    }

    return rangeItem[1];
  }

  /**
   * Returns the occurrence details based on its start time.  If the
   * occurrence has an exception will return the details for that exception.
   *
   * NOTE: this method is intend to be used in conjunction
   *       with the {@link ICAL.Event#iterator iterator} method.
   *
   * @param {Time} occurrence               time occurrence
   * @return {occurrenceDetails}            Information about the occurrence
   */
  getOccurrenceDetails(occurrence) {
    let id = occurrence.toString();
    let utcId = occurrence.convertToZone(Timezone.utcTimezone).toString();
    let item;
    let result = {
      //XXX: Clone?
      recurrenceId: occurrence
    };

    if (id in this.exceptions) {
      item = result.item = this.exceptions[id];
      result.startDate = item.startDate;
      result.endDate = item.endDate;
      result.item = item;
    } else if (utcId in this.exceptions) {
      item = this.exceptions[utcId];
      result.startDate = item.startDate;
      result.endDate = item.endDate;
      result.item = item;
    } else {
      // range exceptions (RANGE=THISANDFUTURE) have a
      // lower priority then direct exceptions but
      // must be accounted for first. Their item is
      // always the first exception with the range prop.
      let rangeExceptionId = this.findRangeException(
        occurrence
      );
      let end;

      if (rangeExceptionId) {
        let exception = this.exceptions[rangeExceptionId];

        // range exception must modify standard time
        // by the difference (if any) in start/end times.
        result.item = exception;

        let startDiff = this._rangeExceptionCache[rangeExceptionId];

        if (!startDiff) {
          let original = exception.recurrenceId.clone();
          let newStart = exception.startDate.clone();

          // zones must be same otherwise subtract may be incorrect.
          original.zone = newStart.zone;
          startDiff = newStart.subtractDate(original);

          this._rangeExceptionCache[rangeExceptionId] = startDiff;
        }

        let start = occurrence.clone();
        start.zone = exception.startDate.zone;
        start.addDuration(startDiff);

        end = start.clone();
        end.addDuration(exception.duration);

        result.startDate = start;
        result.endDate = end;
      } else {
        // no range exception standard expansion
        end = occurrence.clone();
        end.addDuration(this.duration);

        result.endDate = end;
        result.startDate = occurrence;
        result.item = this;
      }
    }

    return result;
  }

  /**
   * Builds a recur expansion instance for a specific point in time (defaults
   * to startDate).
   *
   * @param {Time=} startTime     Starting point for expansion
   * @return {RecurExpansion}    Expansion object
   */
  iterator(startTime) {
    return new RecurExpansion({
      component: this.component,
      dtstart: startTime || this.startDate
    });
  }

  /**
   * Checks if the event is recurring
   *
   * @return {Boolean}        True, if event is recurring
   */
  isRecurring() {
    let comp = this.component;
    return comp.hasProperty('rrule') || comp.hasProperty('rdate');
  }

  /**
   * Checks if the event describes a recurrence exception. See
   * {@tutorial terminology} for details.
   *
   * @return {Boolean}    True, if the event describes a recurrence exception
   */
  isRecurrenceException() {
    return this.component.hasProperty('recurrence-id');
  }

  /**
   * Returns the types of recurrences this event may have.
   *
   * Returned as an object with the following possible keys:
   *
   *    - YEARLY
   *    - MONTHLY
   *    - WEEKLY
   *    - DAILY
   *    - MINUTELY
   *    - SECONDLY
   *
   * @return {Object.<frequencyValues, Boolean>}
   *          Object of recurrence flags
   */
  getRecurrenceTypes() {
    let rules = this.component.getAllProperties('rrule');
    let i = 0;
    let len = rules.length;
    let result = Object.create(null);

    for (; i < len; i++) {
      let value = rules[i].getFirstValue();
      result[value.freq] = true;
    }

    return result;
  }

  /**
   * The uid of this event
   * @type {String}
   */
  get uid() {
    return this._firstProp('uid');
  }

  set uid(value) {
    this._setProp('uid', value);
  }

  /**
   * The start date
   * @type {Time}
   */
  get startDate() {
    return this._firstProp('dtstart');
  }

  set startDate(value) {
    this._setTime('dtstart', value);
  }

  /**
   * The end date. This can be the result directly from the property, or the
   * end date calculated from start date and duration. Setting the property
   * will remove any duration properties.
   * @type {Time}
   */
  get endDate() {
    let endDate = this._firstProp('dtend');
    if (!endDate) {
        let duration = this._firstProp('duration');
        endDate = this.startDate.clone();
        if (duration) {
            endDate.addDuration(duration);
        } else if (endDate.isDate) {
            endDate.day += 1;
        }
    }
    return endDate;
  }

  set endDate(value) {
    if (this.component.hasProperty('duration')) {
      this.component.removeProperty('duration');
    }
    this._setTime('dtend', value);
  }

  /**
   * The duration. This can be the result directly from the property, or the
   * duration calculated from start date and end date. Setting the property
   * will remove any `dtend` properties.
   * @type {Duration}
   */
  get duration() {
    let duration = this._firstProp('duration');
    if (!duration) {
      return this.endDate.subtractDateTz(this.startDate);
    }
    return duration;
  }

  set duration(value) {
    if (this.component.hasProperty('dtend')) {
      this.component.removeProperty('dtend');
    }

    this._setProp('duration', value);
  }

  /**
   * The location of the event.
   * @type {String}
   */
  get location() {
    return this._firstProp('location');
  }

  set location(value) {
    this._setProp('location', value);
  }

  /**
   * The attendees in the event
   * @type {Property[]}
   */
  get attendees() {
    //XXX: This is way lame we should have a better
    //     data structure for this later.
    return this.component.getAllProperties('attendee');
  }

  /**
   * The event summary
   * @type {String}
   */
  get summary() {
    return this._firstProp('summary');
  }

  set summary(value) {
    this._setProp('summary', value);
  }

  /**
   * The event description.
   * @type {String}
   */
  get description() {
    return this._firstProp('description');
  }

  set description(value) {
    this._setProp('description', value);
  }

  /**
   * The event color from [rfc7986](https://datatracker.ietf.org/doc/html/rfc7986)
   * @type {String}
   */
  get color() {
    return this._firstProp('color');
  }

  set color(value) {
    this._setProp('color', value);
  }

  /**
   * The organizer value as an uri. In most cases this is a mailto: uri, but
   * it can also be something else, like urn:uuid:...
   * @type {String}
   */
  get organizer() {
    return this._firstProp('organizer');
  }

  set organizer(value) {
    this._setProp('organizer', value);
  }

  /**
   * The sequence value for this event. Used for scheduling
   * see {@tutorial terminology}.
   * @type {Number}
   */
  get sequence() {
    return this._firstProp('sequence');
  }

  set sequence(value) {
    this._setProp('sequence', value);
  }

  /**
   * The recurrence id for this event. See {@tutorial terminology} for details.
   * @type {Time}
   */
  get recurrenceId() {
    return this._firstProp('recurrence-id');
  }

  set recurrenceId(value) {
    this._setTime('recurrence-id', value);
  }

  /**
   * Set/update a time property's value.
   * This will also update the TZID of the property.
   *
   * TODO: this method handles the case where we are switching
   * from a known timezone to an implied timezone (one without TZID).
   * This does _not_ handle the case of moving between a known
   *  (by TimezoneService) timezone to an unknown timezone...
   *
   * We will not add/remove/update the VTIMEZONE subcomponents
   *  leading to invalid ICAL data...
   * @private
   * @param {String} propName     The property name
   * @param {Time} time           The time to set
   */
  _setTime(propName, time) {
    let prop = this.component.getFirstProperty(propName);

    if (!prop) {
      prop = new Property(propName);
      this.component.addProperty(prop);
    }

    // utc and local don't get a tzid
    if (
      time.zone === Timezone.localTimezone ||
      time.zone === Timezone.utcTimezone
    ) {
      // remove the tzid
      prop.removeParameter('tzid');
    } else {
      prop.setParameter('tzid', time.zone.tzid);
    }

    prop.setValue(time);
  }

  _setProp(name, value) {
    this.component.updatePropertyWithValue(name, value);
  }

  _firstProp(name) {
    return this.component.getFirstPropertyValue(name);
  }

  /**
   * The string representation of this event.
   * @return {String}
   */
  toString() {
    return this.component.toString();
  }
}
export default Event;

function compareRangeException(a, b) {
  if (a[0] > b[0]) return 1;
  if (b[0] > a[0]) return -1;
  return 0;
}