570 lines
14 KiB
JavaScript
570 lines
14 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 { 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;
|
|
}
|