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