summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/modules
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/base/modules
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/base/modules')
-rw-r--r--comm/calendar/base/modules/Ical.jsm9707
-rw-r--r--comm/calendar/base/modules/calCalendarDeactivator.jsm171
-rw-r--r--comm/calendar/base/modules/calExtract.jsm1417
-rw-r--r--comm/calendar/base/modules/calHashedArray.jsm258
-rw-r--r--comm/calendar/base/modules/calRecurrenceUtils.jsm553
-rw-r--r--comm/calendar/base/modules/calUtils.jsm578
-rw-r--r--comm/calendar/base/modules/moz.build36
-rw-r--r--comm/calendar/base/modules/utils/calACLUtils.jsm92
-rw-r--r--comm/calendar/base/modules/utils/calAlarmUtils.jsm161
-rw-r--r--comm/calendar/base/modules/utils/calAuthUtils.jsm564
-rw-r--r--comm/calendar/base/modules/utils/calCategoryUtils.jsm103
-rw-r--r--comm/calendar/base/modules/utils/calDataUtils.jsm313
-rw-r--r--comm/calendar/base/modules/utils/calDateTimeFormatter.jsm620
-rw-r--r--comm/calendar/base/modules/utils/calDateTimeUtils.jsm430
-rw-r--r--comm/calendar/base/modules/utils/calEmailUtils.jsm218
-rw-r--r--comm/calendar/base/modules/utils/calInvitationUtils.jsm875
-rw-r--r--comm/calendar/base/modules/utils/calItemUtils.jsm675
-rw-r--r--comm/calendar/base/modules/utils/calIteratorUtils.jsm279
-rw-r--r--comm/calendar/base/modules/utils/calItipUtils.jsm2181
-rw-r--r--comm/calendar/base/modules/utils/calL10NUtils.jsm162
-rw-r--r--comm/calendar/base/modules/utils/calPrintUtils.jsm616
-rw-r--r--comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm182
-rw-r--r--comm/calendar/base/modules/utils/calProviderUtils.jsm907
-rw-r--r--comm/calendar/base/modules/utils/calUnifinderUtils.jsm206
-rw-r--r--comm/calendar/base/modules/utils/calViewUtils.jsm521
-rw-r--r--comm/calendar/base/modules/utils/calWindowUtils.jsm182
-rw-r--r--comm/calendar/base/modules/utils/calXMLUtils.jsm188
27 files changed, 22195 insertions, 0 deletions
diff --git a/comm/calendar/base/modules/Ical.jsm b/comm/calendar/base/modules/Ical.jsm
new file mode 100644
index 0000000000..470051fe4c
--- /dev/null
+++ b/comm/calendar/base/modules/Ical.jsm
@@ -0,0 +1,9707 @@
+/* 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/. */
+
+/**
+ * This is ical.js from <https://github.com/kewisch/ical.js>.
+ *
+ * A maintenance branch is used as this version doesn't use ES6 modules,
+ * so that changes can be easily backported to Thunderbird 102 ESR.
+ *
+ * If you would like to change anything in ical.js, it is required to do so
+ * upstream first.
+ *
+ * Current ical.js git revision:
+ * https://github.com/darktrojan/ical.js/commit/0f1af2444b82708bb3a0a6b05d834884dedd8109
+ */
+
+var EXPORTED_SYMBOLS = ["ICAL", "unwrap", "unwrapSetter", "unwrapSingle", "wrapGetter"];
+
+function wrapGetter(type, val) {
+ return val ? new type(val) : null;
+}
+
+function unwrap(type, innerFunc) {
+ return function(val) { return unwrapSetter.call(this, type, val, innerFunc); };
+}
+
+function unwrapSetter(type, val, innerFunc, thisObj) {
+ return innerFunc.call(thisObj || this, unwrapSingle(type, val));
+}
+
+function unwrapSingle(type, val) {
+ if (!val || !val.wrappedJSObject) {
+ return null;
+ } else if (val.wrappedJSObject.innerObject instanceof type) {
+ return val.wrappedJSObject.innerObject;
+ } else {
+ var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+ Cu.reportError("Unknown " + (type.icalclass || type) + " passed at " + cal.STACK(10));
+ return null;
+ }
+}
+
+// -- start ical.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, 2021 */
+
+var ICAL = {};
+
+/**
+ * The number of characters before iCalendar line folding should occur
+ * @type {Number}
+ * @default 75
+ */
+ICAL.foldLength = 75;
+
+
+/**
+ * The character(s) to be used for a newline. The default value is provided by
+ * rfc5545.
+ * @type {String}
+ * @default "\r\n"
+ */
+ICAL.newLineChar = '\r\n';
+
+
+/**
+ * Helper functions used in various places within ical.js
+ * @namespace
+ */
+ICAL.helpers = {
+ /**
+ * Compiles a list of all referenced TZIDs in all subcomponents and
+ * removes any extra VTIMEZONE subcomponents. In addition, if any TZIDs
+ * are referenced by a component, but a VTIMEZONE does not exist,
+ * an attempt will be made to generate a VTIMEZONE using ICAL.TimezoneService.
+ *
+ * @param {ICAL.Component} vcal The top-level VCALENDAR component.
+ * @return {ICAL.Component} The ICAL.Component that was passed in.
+ */
+ updateTimezones: function(vcal) {
+ var allsubs, properties, vtimezones, reqTzid, i, tzid;
+
+ if (!vcal || vcal.name !== "vcalendar") {
+ //not a top-level vcalendar component
+ return vcal;
+ }
+
+ //Store vtimezone subcomponents in an object reference by tzid.
+ //Store properties from everything else in another array
+ allsubs = vcal.getAllSubcomponents();
+ properties = [];
+ vtimezones = {};
+ for (i = 0; i < allsubs.length; i++) {
+ if (allsubs[i].name === "vtimezone") {
+ tzid = allsubs[i].getFirstProperty("tzid").getFirstValue();
+ vtimezones[tzid] = allsubs[i];
+ } else {
+ properties = properties.concat(allsubs[i].getAllProperties());
+ }
+ }
+
+ //create an object with one entry for each required tz
+ reqTzid = {};
+ for (i = 0; i < properties.length; i++) {
+ if ((tzid = properties[i].getParameter("tzid"))) {
+ reqTzid[tzid] = true;
+ }
+ }
+
+ //delete any vtimezones that are not on the reqTzid list.
+ for (i in vtimezones) {
+ if (vtimezones.hasOwnProperty(i) && !reqTzid[i]) {
+ vcal.removeSubcomponent(vtimezones[i]);
+ }
+ }
+
+ //create any missing, but registered timezones
+ for (i in reqTzid) {
+ if (
+ reqTzid.hasOwnProperty(i) &&
+ !vtimezones[i] &&
+ ICAL.TimezoneService.has(i)
+ ) {
+ vcal.addSubcomponent(ICAL.TimezoneService.get(i).component);
+ }
+ }
+
+ return vcal;
+ },
+
+ /**
+ * Checks if the given type is of the number type and also NaN.
+ *
+ * @param {Number} number The number to check
+ * @return {Boolean} True, if the number is strictly NaN
+ */
+ isStrictlyNaN: function(number) {
+ return typeof(number) === 'number' && isNaN(number);
+ },
+
+ /**
+ * Parses a string value that is expected to be an integer, when the valid is
+ * not an integer throws a decoration error.
+ *
+ * @param {String} string Raw string input
+ * @return {Number} Parsed integer
+ */
+ strictParseInt: function(string) {
+ var result = parseInt(string, 10);
+
+ if (ICAL.helpers.isStrictlyNaN(result)) {
+ throw new Error(
+ 'Could not extract integer from "' + string + '"'
+ );
+ }
+
+ return result;
+ },
+
+ /**
+ * Creates or returns a class instance of a given type with the initialization
+ * data if the data is not already an instance of the given type.
+ *
+ * @example
+ * var time = new ICAL.Time(...);
+ * var result = ICAL.helpers.formatClassType(time, ICAL.Time);
+ *
+ * (result instanceof ICAL.Time)
+ * // => true
+ *
+ * result = ICAL.helpers.formatClassType({}, ICAL.Time);
+ * (result isntanceof ICAL.Time)
+ * // => true
+ *
+ *
+ * @param {Object} data object initialization data
+ * @param {Object} type object type (like ICAL.Time)
+ * @return {?} An instance of the found type.
+ */
+ formatClassType: function formatClassType(data, type) {
+ if (typeof(data) === 'undefined') {
+ return undefined;
+ }
+
+ if (data instanceof type) {
+ return data;
+ }
+ return new type(data);
+ },
+
+ /**
+ * Identical to indexOf but will only match values when they are not preceded
+ * by a backslash character.
+ *
+ * @param {String} buffer String to search
+ * @param {String} search Value to look for
+ * @param {Number} pos Start position
+ * @return {Number} The position, or -1 if not found
+ */
+ unescapedIndexOf: function(buffer, search, pos) {
+ while ((pos = buffer.indexOf(search, pos)) !== -1) {
+ if (pos > 0 && buffer[pos - 1] === '\\') {
+ pos += 1;
+ } else {
+ return pos;
+ }
+ }
+ return -1;
+ },
+
+ /**
+ * Find the index for insertion using binary search.
+ *
+ * @param {Array} list The list to search
+ * @param {?} seekVal The value to insert
+ * @param {function(?,?)} cmpfunc The comparison func, that can
+ * compare two seekVals
+ * @return {Number} The insert position
+ */
+ binsearchInsert: function(list, seekVal, cmpfunc) {
+ if (!list.length)
+ return 0;
+
+ var low = 0, high = list.length - 1,
+ mid, cmpval;
+
+ while (low <= high) {
+ mid = low + Math.floor((high - low) / 2);
+ cmpval = cmpfunc(seekVal, list[mid]);
+
+ if (cmpval < 0)
+ high = mid - 1;
+ else if (cmpval > 0)
+ low = mid + 1;
+ else
+ break;
+ }
+
+ if (cmpval < 0)
+ return mid; // insertion is displacing, so use mid outright.
+ else if (cmpval > 0)
+ return mid + 1;
+ else
+ return mid;
+ },
+
+ /**
+ * Convenience function for debug output
+ * @private
+ */
+ dumpn: /* istanbul ignore next */ function() {
+ if (!ICAL.debug) {
+ return;
+ }
+
+ if (typeof (console) !== 'undefined' && 'log' in console) {
+ ICAL.helpers.dumpn = function consoleDumpn(input) {
+ console.log(input);
+ };
+ } else {
+ ICAL.helpers.dumpn = function geckoDumpn(input) {
+ dump(input + '\n');
+ };
+ }
+
+ ICAL.helpers.dumpn(arguments[0]);
+ },
+
+ /**
+ * Clone the passed object or primitive. By default a shallow clone will be
+ * executed.
+ *
+ * @param {*} aSrc The thing to clone
+ * @param {Boolean=} aDeep If true, a deep clone will be performed
+ * @return {*} The copy of the thing
+ */
+ clone: function(aSrc, aDeep) {
+ if (!aSrc || typeof aSrc != "object") {
+ return aSrc;
+ } else if (aSrc instanceof Date) {
+ return new Date(aSrc.getTime());
+ } else if ("clone" in aSrc) {
+ return aSrc.clone();
+ } else if (Array.isArray(aSrc)) {
+ var arr = [];
+ for (var i = 0; i < aSrc.length; i++) {
+ arr.push(aDeep ? ICAL.helpers.clone(aSrc[i], true) : aSrc[i]);
+ }
+ return arr;
+ } else {
+ var obj = {};
+ for (var name in aSrc) {
+ // uses prototype method to allow use of Object.create(null);
+ /* istanbul ignore else */
+ if (Object.prototype.hasOwnProperty.call(aSrc, name)) {
+ if (aDeep) {
+ obj[name] = ICAL.helpers.clone(aSrc[name], true);
+ } else {
+ obj[name] = aSrc[name];
+ }
+ }
+ }
+ return obj;
+ }
+ },
+
+ /**
+ * Performs iCalendar line folding. A line ending character is inserted and
+ * the next line begins with a whitespace.
+ *
+ * @example
+ * SUMMARY:This line will be fold
+ * ed right in the middle of a word.
+ *
+ * @param {String} aLine The line to fold
+ * @return {String} The folded line
+ */
+ foldline: function foldline(aLine) {
+ var result = "";
+ var line = aLine || "", pos = 0, line_length = 0;
+ //pos counts position in line for the UTF-16 presentation
+ //line_length counts the bytes for the UTF-8 presentation
+ while (line.length) {
+ var cp = line.codePointAt(pos);
+ if (cp < 128) ++line_length;
+ else if (cp < 2048) line_length += 2;//needs 2 UTF-8 bytes
+ else if (cp < 65536) line_length += 3;
+ else line_length += 4; //cp is less than 1114112
+ if (line_length < ICAL.foldLength + 1)
+ pos += cp > 65535 ? 2 : 1;
+ else {
+ result += ICAL.newLineChar + " " + line.substring(0, pos);
+ line = line.substring(pos);
+ pos = line_length = 0;
+ }
+ }
+ return result.substr(ICAL.newLineChar.length + 1);
+ },
+
+ /**
+ * Pads the given string or number with zeros so it will have at least two
+ * characters.
+ *
+ * @param {String|Number} data The string or number to pad
+ * @return {String} The number padded as a string
+ */
+ pad2: function pad(data) {
+ if (typeof(data) !== 'string') {
+ // handle fractions.
+ if (typeof(data) === 'number') {
+ data = parseInt(data);
+ }
+ data = String(data);
+ }
+
+ var len = data.length;
+
+ switch (len) {
+ case 0:
+ return '00';
+ case 1:
+ return '0' + data;
+ default:
+ return data;
+ }
+ },
+
+ /**
+ * Truncates the given number, correctly handling negative numbers.
+ *
+ * @param {Number} number The number to truncate
+ * @return {Number} The truncated number
+ */
+ trunc: function trunc(number) {
+ return (number < 0 ? Math.ceil(number) : Math.floor(number));
+ },
+
+ /**
+ * Poor-man's cross-browser inheritance for JavaScript. Doesn't support all
+ * the features, but enough for our usage.
+ *
+ * @param {Function} base The base class constructor function.
+ * @param {Function} child The child class constructor function.
+ * @param {Object} extra Extends the prototype with extra properties
+ * and methods
+ */
+ inherits: function(base, child, extra) {
+ function F() {}
+ F.prototype = base.prototype;
+ child.prototype = new F();
+
+ if (extra) {
+ ICAL.helpers.extend(extra, child.prototype);
+ }
+ },
+
+ /**
+ * Poor-man's cross-browser object extension. Doesn't support all the
+ * features, but enough for our usage. Note that the target's properties are
+ * not overwritten with the source properties.
+ *
+ * @example
+ * var child = ICAL.helpers.extend(parent, {
+ * "bar": 123
+ * });
+ *
+ * @param {Object} source The object to extend
+ * @param {Object} target The object to extend with
+ * @return {Object} Returns the target.
+ */
+ extend: function(source, target) {
+ for (var key in source) {
+ var descr = Object.getOwnPropertyDescriptor(source, key);
+ if (descr && !Object.getOwnPropertyDescriptor(target, key)) {
+ Object.defineProperty(target, key, descr);
+ }
+ }
+ return target;
+ }
+};
+/* 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, 2011-2015 */
+
+/** @namespace ICAL */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.design = (function() {
+ 'use strict';
+
+ var FROM_ICAL_NEWLINE = /\\\\|\\;|\\,|\\[Nn]/g;
+ var TO_ICAL_NEWLINE = /\\|;|,|\n/g;
+ var FROM_VCARD_NEWLINE = /\\\\|\\,|\\[Nn]/g;
+ var TO_VCARD_NEWLINE = /\\|,|\n/g;
+
+ function createTextType(fromNewline, toNewline) {
+ var result = {
+ matches: /.*/,
+
+ fromICAL: function(aValue, structuredEscape) {
+ return replaceNewline(aValue, fromNewline, structuredEscape);
+ },
+
+ toICAL: function(aValue, structuredEscape) {
+ var regEx = toNewline;
+ if (structuredEscape)
+ regEx = new RegExp(regEx.source + '|' + structuredEscape, regEx.flags);
+ return aValue.replace(regEx, function(str) {
+ switch (str) {
+ case "\\":
+ return "\\\\";
+ case ";":
+ return "\\;";
+ case ",":
+ return "\\,";
+ case "\n":
+ return "\\n";
+ /* istanbul ignore next */
+ default:
+ return str;
+ }
+ });
+ }
+ };
+ return result;
+ }
+
+ // default types used multiple times
+ var DEFAULT_TYPE_TEXT = { defaultType: "text" };
+ var DEFAULT_TYPE_TEXT_MULTI = { defaultType: "text", multiValue: "," };
+ var DEFAULT_TYPE_TEXT_STRUCTURED = { defaultType: "text", structuredValue: ";" };
+ var DEFAULT_TYPE_INTEGER = { defaultType: "integer" };
+ var DEFAULT_TYPE_DATETIME_DATE = { defaultType: "date-time", allowedTypes: ["date-time", "date"] };
+ var DEFAULT_TYPE_DATETIME = { defaultType: "date-time" };
+ var DEFAULT_TYPE_URI = { defaultType: "uri" };
+ var DEFAULT_TYPE_UTCOFFSET = { defaultType: "utc-offset" };
+ var DEFAULT_TYPE_RECUR = { defaultType: "recur" };
+ var DEFAULT_TYPE_DATE_ANDOR_TIME = { defaultType: "date-and-or-time", allowedTypes: ["date-time", "date", "text"] };
+
+ function replaceNewlineReplace(string) {
+ switch (string) {
+ case "\\\\":
+ return "\\";
+ case "\\;":
+ return ";";
+ case "\\,":
+ return ",";
+ case "\\n":
+ case "\\N":
+ return "\n";
+ /* istanbul ignore next */
+ default:
+ return string;
+ }
+ }
+
+ function replaceNewline(value, newline, structuredEscape) {
+ // avoid regex when possible.
+ if (value.indexOf('\\') === -1) {
+ return value;
+ }
+ if (structuredEscape)
+ newline = new RegExp(newline.source + '|\\\\' + structuredEscape, newline.flags);
+ return value.replace(newline, replaceNewlineReplace);
+ }
+
+ var commonProperties = {
+ "categories": DEFAULT_TYPE_TEXT_MULTI,
+ "url": DEFAULT_TYPE_URI,
+ "version": DEFAULT_TYPE_TEXT,
+ "uid": DEFAULT_TYPE_TEXT
+ };
+
+ var commonValues = {
+ "boolean": {
+ values: ["TRUE", "FALSE"],
+
+ fromICAL: function(aValue) {
+ switch (aValue) {
+ case 'TRUE':
+ return true;
+ case 'FALSE':
+ return false;
+ default:
+ //TODO: parser warning
+ return false;
+ }
+ },
+
+ toICAL: function(aValue) {
+ if (aValue) {
+ return 'TRUE';
+ }
+ return 'FALSE';
+ }
+
+ },
+ float: {
+ matches: /^[+-]?\d+\.\d+$/,
+
+ fromICAL: function(aValue) {
+ var parsed = parseFloat(aValue);
+ if (ICAL.helpers.isStrictlyNaN(parsed)) {
+ // TODO: parser warning
+ return 0.0;
+ }
+ return parsed;
+ },
+
+ toICAL: function(aValue) {
+ return String(aValue);
+ }
+ },
+ integer: {
+ fromICAL: function(aValue) {
+ var parsed = parseInt(aValue);
+ if (ICAL.helpers.isStrictlyNaN(parsed)) {
+ return 0;
+ }
+ return parsed;
+ },
+
+ toICAL: function(aValue) {
+ return String(aValue);
+ }
+ },
+ "utc-offset": {
+ toICAL: function(aValue) {
+ if (aValue.length < 7) {
+ // no seconds
+ // -0500
+ return aValue.substr(0, 3) +
+ aValue.substr(4, 2);
+ } else {
+ // seconds
+ // -050000
+ return aValue.substr(0, 3) +
+ aValue.substr(4, 2) +
+ aValue.substr(7, 2);
+ }
+ },
+
+ fromICAL: function(aValue) {
+ if (aValue.length < 6) {
+ // no seconds
+ // -05:00
+ return aValue.substr(0, 3) + ':' +
+ aValue.substr(3, 2);
+ } else {
+ // seconds
+ // -05:00:00
+ return aValue.substr(0, 3) + ':' +
+ aValue.substr(3, 2) + ':' +
+ aValue.substr(5, 2);
+ }
+ },
+
+ decorate: function(aValue) {
+ return ICAL.UtcOffset.fromString(aValue);
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ }
+ };
+
+ var icalParams = {
+ // Although the syntax is DQUOTE uri DQUOTE, I don't think we should
+ // enforce anything aside from it being a valid content line.
+ //
+ // At least some params require - if multi values are used - DQUOTEs
+ // for each of its values - e.g. delegated-from="uri1","uri2"
+ // To indicate this, I introduced the new k/v pair
+ // multiValueSeparateDQuote: true
+ //
+ // "ALTREP": { ... },
+
+ // CN just wants a param-value
+ // "CN": { ... }
+
+ "cutype": {
+ values: ["INDIVIDUAL", "GROUP", "RESOURCE", "ROOM", "UNKNOWN"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+
+ "delegated-from": {
+ valueType: "cal-address",
+ multiValue: ",",
+ multiValueSeparateDQuote: true
+ },
+ "delegated-to": {
+ valueType: "cal-address",
+ multiValue: ",",
+ multiValueSeparateDQuote: true
+ },
+ // "DIR": { ... }, // See ALTREP
+ "encoding": {
+ values: ["8BIT", "BASE64"]
+ },
+ // "FMTTYPE": { ... }, // See ALTREP
+ "fbtype": {
+ values: ["FREE", "BUSY", "BUSY-UNAVAILABLE", "BUSY-TENTATIVE"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ // "LANGUAGE": { ... }, // See ALTREP
+ "member": {
+ valueType: "cal-address",
+ multiValue: ",",
+ multiValueSeparateDQuote: true
+ },
+ "partstat": {
+ // TODO These values are actually different per-component
+ values: ["NEEDS-ACTION", "ACCEPTED", "DECLINED", "TENTATIVE",
+ "DELEGATED", "COMPLETED", "IN-PROCESS"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ "range": {
+ values: ["THISANDFUTURE"]
+ },
+ "related": {
+ values: ["START", "END"]
+ },
+ "reltype": {
+ values: ["PARENT", "CHILD", "SIBLING"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ "role": {
+ values: ["REQ-PARTICIPANT", "CHAIR",
+ "OPT-PARTICIPANT", "NON-PARTICIPANT"],
+ allowXName: true,
+ allowIanaToken: true
+ },
+ "rsvp": {
+ values: ["TRUE", "FALSE"]
+ },
+ "sent-by": {
+ valueType: "cal-address"
+ },
+ "tzid": {
+ matches: /^\//
+ },
+ "value": {
+ // since the value here is a 'type' lowercase is used.
+ values: ["binary", "boolean", "cal-address", "date", "date-time",
+ "duration", "float", "integer", "period", "recur", "text",
+ "time", "uri", "utc-offset"],
+ allowXName: true,
+ allowIanaToken: true
+ }
+ };
+
+ // When adding a value here, be sure to add it to the parameter types!
+ var icalValues = ICAL.helpers.extend(commonValues, {
+ text: createTextType(FROM_ICAL_NEWLINE, TO_ICAL_NEWLINE),
+
+ uri: {
+ // TODO
+ /* ... */
+ },
+
+ "binary": {
+ decorate: function(aString) {
+ return ICAL.Binary.fromString(aString);
+ },
+
+ undecorate: function(aBinary) {
+ return aBinary.toString();
+ }
+ },
+ "cal-address": {
+ // needs to be an uri
+ },
+ "date": {
+ decorate: function(aValue, aProp) {
+ if (design.strict) {
+ return ICAL.Time.fromDateString(aValue, aProp);
+ } else {
+ return ICAL.Time.fromString(aValue, aProp);
+ }
+ },
+
+ /**
+ * undecorates a time object.
+ */
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+
+ fromICAL: function(aValue) {
+ // from: 20120901
+ // to: 2012-09-01
+ if (!design.strict && aValue.length >= 15) {
+ // This is probably a date-time, e.g. 20120901T130000Z
+ return icalValues["date-time"].fromICAL(aValue);
+ } else {
+ return aValue.substr(0, 4) + '-' +
+ aValue.substr(4, 2) + '-' +
+ aValue.substr(6, 2);
+ }
+ },
+
+ toICAL: function(aValue) {
+ // from: 2012-09-01
+ // to: 20120901
+ var len = aValue.length;
+
+ if (len == 10) {
+ return aValue.substr(0, 4) +
+ aValue.substr(5, 2) +
+ aValue.substr(8, 2);
+ } else if (len >= 19) {
+ return icalValues["date-time"].toICAL(aValue);
+ } else {
+ //TODO: serialize warning?
+ return aValue;
+ }
+
+ }
+ },
+ "date-time": {
+ fromICAL: function(aValue) {
+ // from: 20120901T130000
+ // to: 2012-09-01T13:00:00
+ if (!design.strict && aValue.length == 8) {
+ // This is probably a date, e.g. 20120901
+ return icalValues.date.fromICAL(aValue);
+ } else {
+ var result = aValue.substr(0, 4) + '-' +
+ aValue.substr(4, 2) + '-' +
+ aValue.substr(6, 2) + 'T' +
+ aValue.substr(9, 2) + ':' +
+ aValue.substr(11, 2) + ':' +
+ aValue.substr(13, 2);
+
+ if (aValue[15] && aValue[15] === 'Z') {
+ result += 'Z';
+ }
+
+ return result;
+ }
+ },
+
+ toICAL: function(aValue) {
+ // from: 2012-09-01T13:00:00
+ // to: 20120901T130000
+ var len = aValue.length;
+
+ if (len == 10 && !design.strict) {
+ return icalValues.date.toICAL(aValue);
+ } else if (len >= 19) {
+ var result = aValue.substr(0, 4) +
+ aValue.substr(5, 2) +
+ // grab the (DDTHH) segment
+ aValue.substr(8, 5) +
+ // MM
+ aValue.substr(14, 2) +
+ // SS
+ aValue.substr(17, 2);
+
+ if (aValue[19] && aValue[19] === 'Z') {
+ result += 'Z';
+ }
+ return result;
+ } else {
+ // TODO: error
+ return aValue;
+ }
+ },
+
+ decorate: function(aValue, aProp) {
+ if (design.strict) {
+ return ICAL.Time.fromDateTimeString(aValue, aProp);
+ } else {
+ return ICAL.Time.fromString(aValue, aProp);
+ }
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ },
+ duration: {
+ decorate: function(aValue) {
+ return ICAL.Duration.fromString(aValue);
+ },
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ },
+ period: {
+ fromICAL: function(string) {
+ var parts = string.split('/');
+ parts[0] = icalValues['date-time'].fromICAL(parts[0]);
+
+ if (!ICAL.Duration.isValueString(parts[1])) {
+ parts[1] = icalValues['date-time'].fromICAL(parts[1]);
+ }
+
+ return parts;
+ },
+
+ toICAL: function(parts) {
+ parts = parts.slice();
+ if (!design.strict && parts[0].length == 10) {
+ parts[0] = icalValues.date.toICAL(parts[0]);
+ } else {
+ parts[0] = icalValues['date-time'].toICAL(parts[0]);
+ }
+
+ if (!ICAL.Duration.isValueString(parts[1])) {
+ if (!design.strict && parts[1].length == 10) {
+ parts[1] = icalValues.date.toICAL(parts[1]);
+ } else {
+ parts[1] = icalValues['date-time'].toICAL(parts[1]);
+ }
+ }
+
+ return parts.join("/");
+ },
+
+ decorate: function(aValue, aProp) {
+ return ICAL.Period.fromJSON(aValue, aProp, !design.strict);
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toJSON();
+ }
+ },
+ recur: {
+ fromICAL: function(string) {
+ return ICAL.Recur._stringToData(string, true);
+ },
+
+ toICAL: function(data) {
+ var str = "";
+ for (var k in data) {
+ /* istanbul ignore if */
+ if (!Object.prototype.hasOwnProperty.call(data, k)) {
+ continue;
+ }
+ var val = data[k];
+ if (k == "until") {
+ if (val.length > 10) {
+ val = icalValues['date-time'].toICAL(val);
+ } else {
+ val = icalValues.date.toICAL(val);
+ }
+ } else if (k == "wkst") {
+ if (typeof val === 'number') {
+ val = ICAL.Recur.numericDayToIcalDay(val);
+ }
+ } else if (Array.isArray(val)) {
+ val = val.join(",");
+ }
+ str += k.toUpperCase() + "=" + val + ";";
+ }
+ return str.substr(0, str.length - 1);
+ },
+
+ decorate: function decorate(aValue) {
+ return ICAL.Recur.fromData(aValue);
+ },
+
+ undecorate: function(aRecur) {
+ return aRecur.toJSON();
+ }
+ },
+
+ time: {
+ fromICAL: function(aValue) {
+ // from: MMHHSS(Z)?
+ // to: HH:MM:SS(Z)?
+ if (aValue.length < 6) {
+ // TODO: parser exception?
+ return aValue;
+ }
+
+ // HH::MM::SSZ?
+ var result = aValue.substr(0, 2) + ':' +
+ aValue.substr(2, 2) + ':' +
+ aValue.substr(4, 2);
+
+ if (aValue[6] === 'Z') {
+ result += 'Z';
+ }
+
+ return result;
+ },
+
+ toICAL: function(aValue) {
+ // from: HH:MM:SS(Z)?
+ // to: MMHHSS(Z)?
+ if (aValue.length < 8) {
+ //TODO: error
+ return aValue;
+ }
+
+ var result = aValue.substr(0, 2) +
+ aValue.substr(3, 2) +
+ aValue.substr(6, 2);
+
+ if (aValue[8] === 'Z') {
+ result += 'Z';
+ }
+
+ return result;
+ }
+ }
+ });
+
+ var icalProperties = ICAL.helpers.extend(commonProperties, {
+
+ "action": DEFAULT_TYPE_TEXT,
+ "attach": { defaultType: "uri" },
+ "attendee": { defaultType: "cal-address" },
+ "calscale": DEFAULT_TYPE_TEXT,
+ "class": DEFAULT_TYPE_TEXT,
+ "comment": DEFAULT_TYPE_TEXT,
+ "completed": DEFAULT_TYPE_DATETIME,
+ "contact": DEFAULT_TYPE_TEXT,
+ "created": DEFAULT_TYPE_DATETIME,
+ "description": DEFAULT_TYPE_TEXT,
+ "dtend": DEFAULT_TYPE_DATETIME_DATE,
+ "dtstamp": DEFAULT_TYPE_DATETIME,
+ "dtstart": DEFAULT_TYPE_DATETIME_DATE,
+ "due": DEFAULT_TYPE_DATETIME_DATE,
+ "duration": { defaultType: "duration" },
+ "exdate": {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date"],
+ multiValue: ','
+ },
+ "exrule": DEFAULT_TYPE_RECUR,
+ "freebusy": { defaultType: "period", multiValue: "," },
+ "geo": { defaultType: "float", structuredValue: ";" },
+ "last-modified": DEFAULT_TYPE_DATETIME,
+ "location": DEFAULT_TYPE_TEXT,
+ "method": DEFAULT_TYPE_TEXT,
+ "organizer": { defaultType: "cal-address" },
+ "percent-complete": DEFAULT_TYPE_INTEGER,
+ "priority": DEFAULT_TYPE_INTEGER,
+ "prodid": DEFAULT_TYPE_TEXT,
+ "related-to": DEFAULT_TYPE_TEXT,
+ "repeat": DEFAULT_TYPE_INTEGER,
+ "rdate": {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date", "period"],
+ multiValue: ',',
+ detectType: function(string) {
+ if (string.indexOf('/') !== -1) {
+ return 'period';
+ }
+ return (string.indexOf('T') === -1) ? 'date' : 'date-time';
+ }
+ },
+ "recurrence-id": DEFAULT_TYPE_DATETIME_DATE,
+ "resources": DEFAULT_TYPE_TEXT_MULTI,
+ "request-status": DEFAULT_TYPE_TEXT_STRUCTURED,
+ "rrule": DEFAULT_TYPE_RECUR,
+ "sequence": DEFAULT_TYPE_INTEGER,
+ "status": DEFAULT_TYPE_TEXT,
+ "summary": DEFAULT_TYPE_TEXT,
+ "transp": DEFAULT_TYPE_TEXT,
+ "trigger": { defaultType: "duration", allowedTypes: ["duration", "date-time"] },
+ "tzoffsetfrom": DEFAULT_TYPE_UTCOFFSET,
+ "tzoffsetto": DEFAULT_TYPE_UTCOFFSET,
+ "tzurl": DEFAULT_TYPE_URI,
+ "tzid": DEFAULT_TYPE_TEXT,
+ "tzname": DEFAULT_TYPE_TEXT
+ });
+
+ // When adding a value here, be sure to add it to the parameter types!
+ var vcardValues = ICAL.helpers.extend(commonValues, {
+ text: createTextType(FROM_VCARD_NEWLINE, TO_VCARD_NEWLINE),
+ uri: createTextType(FROM_VCARD_NEWLINE, TO_VCARD_NEWLINE),
+
+ date: {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date");
+ },
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+ fromICAL: function(aValue) {
+ if (aValue.length == 8) {
+ return icalValues.date.fromICAL(aValue);
+ } else if (aValue[0] == '-' && aValue.length == 6) {
+ return aValue.substr(0, 4) + '-' + aValue.substr(4);
+ } else {
+ return aValue;
+ }
+ },
+ toICAL: function(aValue) {
+ if (aValue.length == 10) {
+ return icalValues.date.toICAL(aValue);
+ } else if (aValue[0] == '-' && aValue.length == 7) {
+ return aValue.substr(0, 4) + aValue.substr(5);
+ } else {
+ return aValue;
+ }
+ }
+ },
+
+ time: {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString("T" + aValue, "time");
+ },
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+ fromICAL: function(aValue) {
+ var splitzone = vcardValues.time._splitZone(aValue, true);
+ var zone = splitzone[0], value = splitzone[1];
+
+ //console.log("SPLIT: ",splitzone);
+
+ if (value.length == 6) {
+ value = value.substr(0, 2) + ':' +
+ value.substr(2, 2) + ':' +
+ value.substr(4, 2);
+ } else if (value.length == 4 && value[0] != '-') {
+ value = value.substr(0, 2) + ':' + value.substr(2, 2);
+ } else if (value.length == 5) {
+ value = value.substr(0, 3) + ':' + value.substr(3, 2);
+ }
+
+ if (zone.length == 5 && (zone[0] == '-' || zone[0] == '+')) {
+ zone = zone.substr(0, 3) + ':' + zone.substr(3);
+ }
+
+ return value + zone;
+ },
+
+ toICAL: function(aValue) {
+ var splitzone = vcardValues.time._splitZone(aValue);
+ var zone = splitzone[0], value = splitzone[1];
+
+ if (value.length == 8) {
+ value = value.substr(0, 2) +
+ value.substr(3, 2) +
+ value.substr(6, 2);
+ } else if (value.length == 5 && value[0] != '-') {
+ value = value.substr(0, 2) + value.substr(3, 2);
+ } else if (value.length == 6) {
+ value = value.substr(0, 3) + value.substr(4, 2);
+ }
+
+ if (zone.length == 6 && (zone[0] == '-' || zone[0] == '+')) {
+ zone = zone.substr(0, 3) + zone.substr(4);
+ }
+
+ return value + zone;
+ },
+
+ _splitZone: function(aValue, isFromIcal) {
+ var lastChar = aValue.length - 1;
+ var signChar = aValue.length - (isFromIcal ? 5 : 6);
+ var sign = aValue[signChar];
+ var zone, value;
+
+ if (aValue[lastChar] == 'Z') {
+ zone = aValue[lastChar];
+ value = aValue.substr(0, lastChar);
+ } else if (aValue.length > 6 && (sign == '-' || sign == '+')) {
+ zone = aValue.substr(signChar);
+ value = aValue.substr(0, signChar);
+ } else {
+ zone = "";
+ value = aValue;
+ }
+
+ return [zone, value];
+ }
+ },
+
+ "date-time": {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date-time");
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+
+ fromICAL: function(aValue) {
+ return vcardValues['date-and-or-time'].fromICAL(aValue);
+ },
+
+ toICAL: function(aValue) {
+ return vcardValues['date-and-or-time'].toICAL(aValue);
+ }
+ },
+
+ "date-and-or-time": {
+ decorate: function(aValue) {
+ return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date-and-or-time");
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ },
+
+ fromICAL: function(aValue) {
+ var parts = aValue.split('T');
+ return (parts[0] ? vcardValues.date.fromICAL(parts[0]) : '') +
+ (parts[1] ? 'T' + vcardValues.time.fromICAL(parts[1]) : '');
+ },
+
+ toICAL: function(aValue) {
+ var parts = aValue.split('T');
+ return vcardValues.date.toICAL(parts[0]) +
+ (parts[1] ? 'T' + vcardValues.time.toICAL(parts[1]) : '');
+
+ }
+ },
+ timestamp: icalValues['date-time'],
+ "language-tag": {
+ matches: /^[a-zA-Z0-9-]+$/ // Could go with a more strict regex here
+ },
+ "phone-number": {
+ fromICAL: function(aValue) {
+ return Array.from(aValue).filter(function(c) {
+ return c === '\\' ? undefined : c;
+ }).join('');
+ },
+ toICAL: function(aValue) {
+ return Array.from(aValue).map(function(c) {
+ return c === ',' || c === ";" ? '\\' + c : c;
+ }).join('');
+ }
+ }
+ });
+
+ var vcardParams = {
+ "type": {
+ valueType: "text",
+ multiValue: ","
+ },
+ "value": {
+ // since the value here is a 'type' lowercase is used.
+ values: ["text", "uri", "date", "time", "date-time", "date-and-or-time",
+ "timestamp", "boolean", "integer", "float", "utc-offset",
+ "language-tag"],
+ allowXName: true,
+ allowIanaToken: true
+ }
+ };
+
+ var vcardProperties = ICAL.helpers.extend(commonProperties, {
+ "adr": { defaultType: "text", structuredValue: ";", multiValue: "," },
+ "anniversary": DEFAULT_TYPE_DATE_ANDOR_TIME,
+ "bday": DEFAULT_TYPE_DATE_ANDOR_TIME,
+ "caladruri": DEFAULT_TYPE_URI,
+ "caluri": DEFAULT_TYPE_URI,
+ "clientpidmap": DEFAULT_TYPE_TEXT_STRUCTURED,
+ "email": DEFAULT_TYPE_TEXT,
+ "fburl": DEFAULT_TYPE_URI,
+ "fn": DEFAULT_TYPE_TEXT,
+ "gender": DEFAULT_TYPE_TEXT_STRUCTURED,
+ "geo": DEFAULT_TYPE_URI,
+ "impp": DEFAULT_TYPE_URI,
+ "key": DEFAULT_TYPE_URI,
+ "kind": DEFAULT_TYPE_TEXT,
+ "lang": { defaultType: "language-tag" },
+ "logo": DEFAULT_TYPE_URI,
+ "member": DEFAULT_TYPE_URI,
+ "n": { defaultType: "text", structuredValue: ";", multiValue: "," },
+ "nickname": DEFAULT_TYPE_TEXT_MULTI,
+ "note": DEFAULT_TYPE_TEXT,
+ "org": { defaultType: "text", structuredValue: ";" },
+ "photo": DEFAULT_TYPE_URI,
+ "related": DEFAULT_TYPE_URI,
+ "rev": { defaultType: "timestamp" },
+ "role": DEFAULT_TYPE_TEXT,
+ "sound": DEFAULT_TYPE_URI,
+ "source": DEFAULT_TYPE_URI,
+ "tel": { defaultType: "uri", allowedTypes: ["uri", "text"] },
+ "title": DEFAULT_TYPE_TEXT,
+ "tz": { defaultType: "text", allowedTypes: ["text", "utc-offset", "uri"] },
+ "xml": DEFAULT_TYPE_TEXT
+ });
+
+ var vcard3Values = ICAL.helpers.extend(commonValues, {
+ binary: icalValues.binary,
+ date: vcardValues.date,
+ "date-time": vcardValues["date-time"],
+ "phone-number": vcardValues["phone-number"],
+ uri: icalValues.uri,
+ text: icalValues.text,
+ time: icalValues.time,
+ vcard: icalValues.text,
+ "utc-offset": {
+ toICAL: function(aValue) {
+ return aValue.substr(0, 7);
+ },
+
+ fromICAL: function(aValue) {
+ return aValue.substr(0, 7);
+ },
+
+ decorate: function(aValue) {
+ return ICAL.UtcOffset.fromString(aValue);
+ },
+
+ undecorate: function(aValue) {
+ return aValue.toString();
+ }
+ }
+ });
+
+ var vcard3Params = {
+ "type": {
+ valueType: "text",
+ multiValue: ","
+ },
+ "value": {
+ // since the value here is a 'type' lowercase is used.
+ values: ["text", "uri", "date", "date-time", "phone-number", "time",
+ "boolean", "integer", "float", "utc-offset", "vcard", "binary"],
+ allowXName: true,
+ allowIanaToken: true
+ }
+ };
+
+ var vcard3Properties = ICAL.helpers.extend(commonProperties, {
+ fn: DEFAULT_TYPE_TEXT,
+ n: { defaultType: "text", structuredValue: ";", multiValue: "," },
+ nickname: DEFAULT_TYPE_TEXT_MULTI,
+ photo: { defaultType: "binary", allowedTypes: ["binary", "uri"] },
+ bday: {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date"],
+ detectType: function(string) {
+ return (string.indexOf('T') === -1) ? 'date' : 'date-time';
+ }
+ },
+
+ adr: { defaultType: "text", structuredValue: ";", multiValue: "," },
+ label: DEFAULT_TYPE_TEXT,
+
+ tel: { defaultType: "phone-number" },
+ email: DEFAULT_TYPE_TEXT,
+ mailer: DEFAULT_TYPE_TEXT,
+
+ tz: { defaultType: "utc-offset", allowedTypes: ["utc-offset", "text"] },
+ geo: { defaultType: "float", structuredValue: ";" },
+
+ title: DEFAULT_TYPE_TEXT,
+ role: DEFAULT_TYPE_TEXT,
+ logo: { defaultType: "binary", allowedTypes: ["binary", "uri"] },
+ agent: { defaultType: "vcard", allowedTypes: ["vcard", "text", "uri"] },
+ org: DEFAULT_TYPE_TEXT_STRUCTURED,
+
+ note: DEFAULT_TYPE_TEXT_MULTI,
+ prodid: DEFAULT_TYPE_TEXT,
+ rev: {
+ defaultType: "date-time",
+ allowedTypes: ["date-time", "date"],
+ detectType: function(string) {
+ return (string.indexOf('T') === -1) ? 'date' : 'date-time';
+ }
+ },
+ "sort-string": DEFAULT_TYPE_TEXT,
+ sound: { defaultType: "binary", allowedTypes: ["binary", "uri"] },
+
+ class: DEFAULT_TYPE_TEXT,
+ key: { defaultType: "binary", allowedTypes: ["binary", "text"] }
+ });
+
+ /**
+ * iCalendar design set
+ * @type {ICAL.design.designSet}
+ */
+ var icalSet = {
+ value: icalValues,
+ param: icalParams,
+ property: icalProperties,
+ propertyGroups: false
+ };
+
+ /**
+ * vCard 4.0 design set
+ * @type {ICAL.design.designSet}
+ */
+ var vcardSet = {
+ value: vcardValues,
+ param: vcardParams,
+ property: vcardProperties,
+ propertyGroups: true
+ };
+
+ /**
+ * vCard 3.0 design set
+ * @type {ICAL.design.designSet}
+ */
+ var vcard3Set = {
+ value: vcard3Values,
+ param: vcard3Params,
+ property: vcard3Properties,
+ propertyGroups: true
+ };
+
+ /**
+ * The design data, used by the parser to determine types for properties and
+ * other metadata needed to produce correct jCard/jCal data.
+ *
+ * @alias ICAL.design
+ * @namespace
+ */
+ var design = {
+ /**
+ * A designSet describes value, parameter and property data. It is used by
+ * ther parser and stringifier in components and properties to determine they
+ * should be represented.
+ *
+ * @typedef {Object} designSet
+ * @memberOf ICAL.design
+ * @property {Object} value Definitions for value types, keys are type names
+ * @property {Object} param Definitions for params, keys are param names
+ * @property {Object} property Definitions for properties, keys are property names
+ * @property {boolean} propertyGroups If content lines may include a group name
+ */
+
+ /**
+ * Can be set to false to make the parser more lenient.
+ */
+ strict: true,
+
+ /**
+ * The default set for new properties and components if none is specified.
+ * @type {ICAL.design.designSet}
+ */
+ defaultSet: icalSet,
+
+ /**
+ * The default type for unknown properties
+ * @type {String}
+ */
+ defaultType: 'unknown',
+
+ /**
+ * Holds the design set for known top-level components
+ *
+ * @type {Object}
+ * @property {ICAL.design.designSet} vcard vCard VCARD
+ * @property {ICAL.design.designSet} vevent iCalendar VEVENT
+ * @property {ICAL.design.designSet} vtodo iCalendar VTODO
+ * @property {ICAL.design.designSet} vjournal iCalendar VJOURNAL
+ * @property {ICAL.design.designSet} valarm iCalendar VALARM
+ * @property {ICAL.design.designSet} vtimezone iCalendar VTIMEZONE
+ * @property {ICAL.design.designSet} daylight iCalendar DAYLIGHT
+ * @property {ICAL.design.designSet} standard iCalendar STANDARD
+ *
+ * @example
+ * var propertyName = 'fn';
+ * var componentDesign = ICAL.design.components.vcard;
+ * var propertyDetails = componentDesign.property[propertyName];
+ * if (propertyDetails.defaultType == 'text') {
+ * // Yep, sure is...
+ * }
+ */
+ components: {
+ vcard: vcardSet,
+ vcard3: vcard3Set,
+ vevent: icalSet,
+ vtodo: icalSet,
+ vjournal: icalSet,
+ valarm: icalSet,
+ vtimezone: icalSet,
+ daylight: icalSet,
+ standard: icalSet
+ },
+
+
+ /**
+ * The design set for iCalendar (rfc5545/rfc7265) components.
+ * @type {ICAL.design.designSet}
+ */
+ icalendar: icalSet,
+
+ /**
+ * The design set for vCard (rfc6350/rfc7095) components.
+ * @type {ICAL.design.designSet}
+ */
+ vcard: vcardSet,
+
+ /**
+ * The design set for vCard (rfc2425/rfc2426/rfc7095) components.
+ * @type {ICAL.design.designSet}
+ */
+ vcard3: vcard3Set,
+
+ /**
+ * Gets the design set for the given component name.
+ *
+ * @param {String} componentName The name of the component
+ * @return {ICAL.design.designSet} The design set for the component
+ */
+ getDesignSet: function(componentName) {
+ var isInDesign = componentName && componentName in design.components;
+ return isInDesign ? design.components[componentName] : design.defaultSet;
+ }
+ };
+
+ return design;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * Contains various functions to convert jCal and jCard data back into
+ * iCalendar and vCard.
+ * @namespace
+ */
+ICAL.stringify = (function() {
+ 'use strict';
+
+ var LINE_ENDING = '\r\n';
+ var DEFAULT_VALUE_TYPE = 'unknown';
+
+ var design = ICAL.design;
+ var helpers = ICAL.helpers;
+
+ /**
+ * Convert a full jCal/jCard array into a iCalendar/vCard string.
+ *
+ * @function ICAL.stringify
+ * @variation function
+ * @param {Array} jCal The jCal/jCard document
+ * @return {String} The stringified iCalendar/vCard document
+ */
+ function stringify(jCal) {
+ if (typeof jCal[0] == "string") {
+ // This is a single component
+ jCal = [jCal];
+ }
+
+ var i = 0;
+ var len = jCal.length;
+ var result = '';
+
+ for (; i < len; i++) {
+ result += stringify.component(jCal[i]) + LINE_ENDING;
+ }
+
+ return result;
+ }
+
+ /**
+ * Converts an jCal component array into a ICAL string.
+ * Recursive will resolve sub-components.
+ *
+ * Exact component/property order is not saved all
+ * properties will come before subcomponents.
+ *
+ * @function ICAL.stringify.component
+ * @param {Array} component
+ * jCal/jCard fragment of a component
+ * @param {ICAL.design.designSet} designSet
+ * The design data to use for this component
+ * @return {String} The iCalendar/vCard string
+ */
+ stringify.component = function(component, designSet) {
+ var name = component[0].toUpperCase();
+ var result = 'BEGIN:' + name + LINE_ENDING;
+
+ var props = component[1];
+ var propIdx = 0;
+ var propLen = props.length;
+
+ var designSetName = component[0];
+ // rfc6350 requires that in vCard 4.0 the first component is the VERSION
+ // component with as value 4.0, note that 3.0 does not have this requirement.
+ if (designSetName === 'vcard' && component[1].length > 0 &&
+ !(component[1][0][0] === "version" && component[1][0][3] === "4.0")) {
+ designSetName = "vcard3";
+ }
+ designSet = designSet || design.getDesignSet(designSetName);
+
+ for (; propIdx < propLen; propIdx++) {
+ result += stringify.property(props[propIdx], designSet) + LINE_ENDING;
+ }
+
+ // Ignore subcomponents if none exist, e.g. in vCard.
+ var comps = component[2] || [];
+ var compIdx = 0;
+ var compLen = comps.length;
+
+ for (; compIdx < compLen; compIdx++) {
+ result += stringify.component(comps[compIdx], designSet) + LINE_ENDING;
+ }
+
+ result += 'END:' + name;
+ return result;
+ };
+
+ /**
+ * Converts a single jCal/jCard property to a iCalendar/vCard string.
+ *
+ * @function ICAL.stringify.property
+ * @param {Array} property
+ * jCal/jCard property array
+ * @param {ICAL.design.designSet} designSet
+ * The design data to use for this property
+ * @param {Boolean} noFold
+ * If true, the line is not folded
+ * @return {String} The iCalendar/vCard string
+ */
+ stringify.property = function(property, designSet, noFold) {
+ var name = property[0].toUpperCase();
+ var jsName = property[0];
+ var params = property[1];
+
+ if (!designSet) {
+ designSet = design.defaultSet;
+ }
+
+ var groupName = params.group;
+ var line;
+ if (designSet.propertyGroups && groupName) {
+ line = groupName.toUpperCase() + "." + name;
+ } else {
+ line = name;
+ }
+
+ var paramName;
+ for (paramName in params) {
+ if (designSet.propertyGroups && paramName == 'group') {
+ continue;
+ }
+
+ var value = params[paramName];
+ var paramDesign = designSet.param[paramName];
+
+ /* istanbul ignore else */
+ if (params.hasOwnProperty(paramName)) {
+ var multiValue = paramDesign && paramDesign.multiValue;
+ if (multiValue && Array.isArray(value)) {
+ value = value.map(function(val) {
+ val = stringify._rfc6868Unescape(val);
+ val = stringify.paramPropertyValue(val, paramDesign.multiValueSeparateDQuote);
+ return val;
+ });
+ value = stringify.multiValue(value, multiValue, "unknown", null, designSet);
+ } else {
+ value = stringify._rfc6868Unescape(value);
+ value = stringify.paramPropertyValue(value);
+ }
+
+ line += ';' + paramName.toUpperCase() + '=' + value;
+ }
+ }
+
+ if (property.length === 3) {
+ // If there are no values, we must assume a blank value
+ return line + ':';
+ }
+
+ var valueType = property[2];
+
+ var propDetails;
+ var multiValue = false;
+ var structuredValue = false;
+ var isDefault = false;
+
+ if (jsName in designSet.property) {
+ propDetails = designSet.property[jsName];
+
+ if ('multiValue' in propDetails) {
+ multiValue = propDetails.multiValue;
+ }
+
+ if (('structuredValue' in propDetails) && Array.isArray(property[3])) {
+ structuredValue = propDetails.structuredValue;
+ }
+
+ if ('defaultType' in propDetails) {
+ if (valueType === propDetails.defaultType) {
+ isDefault = true;
+ }
+ } else {
+ if (valueType === DEFAULT_VALUE_TYPE) {
+ isDefault = true;
+ }
+ }
+ } else {
+ if (valueType === DEFAULT_VALUE_TYPE) {
+ isDefault = true;
+ }
+ }
+
+ // push the VALUE property if type is not the default
+ // for the current property.
+ if (!isDefault) {
+ // value will never contain ;/:/, so we don't escape it here.
+ line += ';VALUE=' + valueType.toUpperCase();
+ }
+
+ line += ':';
+
+ if (multiValue && structuredValue) {
+ line += stringify.multiValue(
+ property[3], structuredValue, valueType, multiValue, designSet, structuredValue
+ );
+ } else if (multiValue) {
+ line += stringify.multiValue(
+ property.slice(3), multiValue, valueType, null, designSet, false
+ );
+ } else if (structuredValue) {
+ line += stringify.multiValue(
+ property[3], structuredValue, valueType, null, designSet, structuredValue
+ );
+ } else {
+ line += stringify.value(property[3], valueType, designSet, false);
+ }
+
+ return noFold ? line : ICAL.helpers.foldline(line);
+ };
+
+ /**
+ * Handles escaping of property values that may contain:
+ *
+ * COLON (:), SEMICOLON (;), or COMMA (,)
+ *
+ * If any of the above are present the result is wrapped
+ * in double quotes.
+ *
+ * @function ICAL.stringify.paramPropertyValue
+ * @param {String} value Raw property value
+ * @param {boolean} force If value should be escaped even when unnecessary
+ * @return {String} Given or escaped value when needed
+ */
+ stringify.paramPropertyValue = function(value, force) {
+ if (!force &&
+ (helpers.unescapedIndexOf(value, ',') === -1) &&
+ (helpers.unescapedIndexOf(value, ':') === -1) &&
+ (helpers.unescapedIndexOf(value, ';') === -1)) {
+
+ return value;
+ }
+
+ return '"' + value + '"';
+ };
+
+ /**
+ * Converts an array of ical values into a single
+ * string based on a type and a delimiter value (like ",").
+ *
+ * @function ICAL.stringify.multiValue
+ * @param {Array} values List of values to convert
+ * @param {String} delim Used to join the values (",", ";", ":")
+ * @param {String} type Lowecase ical value type
+ * (like boolean, date-time, etc..)
+ * @param {?String} innerMulti If set, each value will again be processed
+ * Used for structured values
+ * @param {ICAL.design.designSet} designSet
+ * The design data to use for this property
+ *
+ * @return {String} iCalendar/vCard string for value
+ */
+ stringify.multiValue = function(values, delim, type, innerMulti, designSet, structuredValue) {
+ var result = '';
+ var len = values.length;
+ var i = 0;
+
+ for (; i < len; i++) {
+ if (innerMulti && Array.isArray(values[i])) {
+ result += stringify.multiValue(values[i], innerMulti, type, null, designSet, structuredValue);
+ } else {
+ result += stringify.value(values[i], type, designSet, structuredValue);
+ }
+
+ if (i !== (len - 1)) {
+ result += delim;
+ }
+ }
+
+ return result;
+ };
+
+ /**
+ * Processes a single ical value runs the associated "toICAL" method from the
+ * design value type if available to convert the value.
+ *
+ * @function ICAL.stringify.value
+ * @param {String|Number} value A formatted value
+ * @param {String} type Lowercase iCalendar/vCard value type
+ * (like boolean, date-time, etc..)
+ * @return {String} iCalendar/vCard value for single value
+ */
+ stringify.value = function(value, type, designSet, structuredValue) {
+ if (type in designSet.value && 'toICAL' in designSet.value[type]) {
+ return designSet.value[type].toICAL(value, structuredValue);
+ }
+ return value;
+ };
+
+ /**
+ * Internal helper for rfc6868. Exposing this on ICAL.stringify so that
+ * hackers can disable the rfc6868 parsing if the really need to.
+ *
+ * @param {String} val The value to unescape
+ * @return {String} The escaped value
+ */
+ stringify._rfc6868Unescape = function(val) {
+ return val.replace(/[\n^"]/g, function(x) {
+ return RFC6868_REPLACE_MAP[x];
+ });
+ };
+ var RFC6868_REPLACE_MAP = { '"': "^'", "\n": "^n", "^": "^^" };
+
+ return stringify;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * Contains various functions to parse iCalendar and vCard data.
+ * @namespace
+ */
+ICAL.parse = (function() {
+ 'use strict';
+
+ var CHAR = /[^ \t]/;
+ var MULTIVALUE_DELIMITER = ',';
+ var VALUE_DELIMITER = ':';
+ var PARAM_DELIMITER = ';';
+ var PARAM_NAME_DELIMITER = '=';
+ var DEFAULT_VALUE_TYPE = 'unknown';
+ var DEFAULT_PARAM_TYPE = 'text';
+
+ var design = ICAL.design;
+ var helpers = ICAL.helpers;
+
+ /**
+ * An error that occurred during parsing.
+ *
+ * @param {String} message The error message
+ * @memberof ICAL.parse
+ * @extends {Error}
+ * @class
+ */
+ function ParserError(message) {
+ this.message = message;
+ this.name = 'ParserError';
+
+ try {
+ throw new Error();
+ } catch (e) {
+ if (e.stack) {
+ var split = e.stack.split('\n');
+ split.shift();
+ this.stack = split.join('\n');
+ }
+ }
+ }
+
+ ParserError.prototype = Error.prototype;
+
+ /**
+ * Parses iCalendar or vCard data into a raw jCal object. Consult
+ * documentation on the {@tutorial layers|layers of parsing} for more
+ * details.
+ *
+ * @function ICAL.parse
+ * @variation function
+ * @todo Fix the API to be more clear on the return type
+ * @param {String} input The string data to parse
+ * @return {Object|Object[]} A single jCal object, or an array thereof
+ */
+ function parser(input) {
+ var state = {};
+ var root = state.component = [];
+
+ state.stack = [root];
+
+ parser._eachLine(input, function(err, line) {
+ parser._handleContentLine(line, state);
+ });
+
+
+ // when there are still items on the stack
+ // throw a fatal error, a component was not closed
+ // correctly in that case.
+ if (state.stack.length > 1) {
+ throw new ParserError(
+ 'invalid ical body. component began but did not end'
+ );
+ }
+
+ state = null;
+
+ return (root.length == 1 ? root[0] : root);
+ }
+
+ /**
+ * Parse an iCalendar property value into the jCal for a single property
+ *
+ * @function ICAL.parse.property
+ * @param {String} str
+ * The iCalendar property string to parse
+ * @param {ICAL.design.designSet=} designSet
+ * The design data to use for this property
+ * @return {Object}
+ * The jCal Object containing the property
+ */
+ parser.property = function(str, designSet) {
+ var state = {
+ component: [[], []],
+ designSet: designSet || design.defaultSet
+ };
+ parser._handleContentLine(str, state);
+ return state.component[1][0];
+ };
+
+ /**
+ * Convenience method to parse a component. You can use ICAL.parse() directly
+ * instead.
+ *
+ * @function ICAL.parse.component
+ * @see ICAL.parse(function)
+ * @param {String} str The iCalendar component string to parse
+ * @return {Object} The jCal Object containing the component
+ */
+ parser.component = function(str) {
+ return parser(str);
+ };
+
+ // classes & constants
+ parser.ParserError = ParserError;
+
+ /**
+ * The state for parsing content lines from an iCalendar/vCard string.
+ *
+ * @private
+ * @memberof ICAL.parse
+ * @typedef {Object} parserState
+ * @property {ICAL.design.designSet} designSet The design set to use for parsing
+ * @property {ICAL.Component[]} stack The stack of components being processed
+ * @property {ICAL.Component} component The currently active component
+ */
+
+
+ /**
+ * Handles a single line of iCalendar/vCard, updating the state.
+ *
+ * @private
+ * @function ICAL.parse._handleContentLine
+ * @param {String} line The content line to process
+ * @param {ICAL.parse.parserState} The current state of the line parsing
+ */
+ parser._handleContentLine = function(line, state) {
+ // break up the parts of the line
+ var valuePos = line.indexOf(VALUE_DELIMITER);
+ var paramPos = line.indexOf(PARAM_DELIMITER);
+
+ var lastParamIndex;
+ var lastValuePos;
+
+ // name of property or begin/end
+ var name;
+ var value;
+ // params is only overridden if paramPos !== -1.
+ // we can't do params = params || {} later on
+ // because it sacrifices ops.
+ var params = {};
+
+ /**
+ * Different property cases
+ *
+ *
+ * 1. RRULE:FREQ=foo
+ * // FREQ= is not a param but the value
+ *
+ * 2. ATTENDEE;ROLE=REQ-PARTICIPANT;
+ * // ROLE= is a param because : has not happened yet
+ */
+ // when the parameter delimiter is after the
+ // value delimiter then it is not a parameter.
+
+ if ((paramPos !== -1 && valuePos !== -1)) {
+ // when the parameter delimiter is after the
+ // value delimiter then it is not a parameter.
+ if (paramPos > valuePos) {
+ paramPos = -1;
+ }
+ }
+
+ var parsedParams;
+ if (paramPos !== -1) {
+ name = line.substring(0, paramPos).toLowerCase();
+ parsedParams = parser._parseParameters(line.substring(paramPos), 0, state.designSet);
+ if (parsedParams[2] == -1) {
+ throw new ParserError("Invalid parameters in '" + line + "'");
+ }
+ params = parsedParams[0];
+ lastParamIndex = parsedParams[1].length + parsedParams[2] + paramPos;
+ if ((lastValuePos =
+ line.substring(lastParamIndex).indexOf(VALUE_DELIMITER)) !== -1) {
+ value = line.substring(lastParamIndex + lastValuePos + 1);
+ } else {
+ throw new ParserError("Missing parameter value in '" + line + "'");
+ }
+ } else if (valuePos !== -1) {
+ // without parmeters (BEGIN:VCAENDAR, CLASS:PUBLIC)
+ name = line.substring(0, valuePos).toLowerCase();
+ value = line.substring(valuePos + 1);
+
+ if (name === 'begin') {
+ var newComponent = [value.toLowerCase(), [], []];
+ if (state.stack.length === 1) {
+ state.component.push(newComponent);
+ } else {
+ state.component[2].push(newComponent);
+ }
+ state.stack.push(state.component);
+ state.component = newComponent;
+ if (!state.designSet) {
+ state.designSet = design.getDesignSet(state.component[0]);
+ }
+ return;
+ } else if (name === 'end') {
+ state.component = state.stack.pop();
+ return;
+ }
+ // If it is not begin/end, then this is a property with an empty value,
+ // which should be considered valid.
+ } else {
+ /**
+ * Invalid line.
+ * The rational to throw an error is we will
+ * never be certain that the rest of the file
+ * is sane and it is unlikely that we can serialize
+ * the result correctly either.
+ */
+ throw new ParserError(
+ 'invalid line (no token ";" or ":") "' + line + '"'
+ );
+ }
+
+ var valueType;
+ var multiValue = false;
+ var structuredValue = false;
+ var propertyDetails;
+ var splitName;
+ var ungroupedName;
+
+ // fetch the ungrouped part of the name
+ if (state.designSet.propertyGroups && name.indexOf('.') !== -1) {
+ splitName = name.split('.');
+ params.group = splitName[0];
+ ungroupedName = splitName[1];
+ } else {
+ ungroupedName = name;
+ }
+
+ if (ungroupedName in state.designSet.property) {
+ propertyDetails = state.designSet.property[ungroupedName];
+
+ if ('multiValue' in propertyDetails) {
+ multiValue = propertyDetails.multiValue;
+ }
+
+ if ('structuredValue' in propertyDetails) {
+ structuredValue = propertyDetails.structuredValue;
+ }
+
+ if (value && 'detectType' in propertyDetails) {
+ valueType = propertyDetails.detectType(value);
+ }
+ }
+
+ // attempt to determine value
+ if (!valueType) {
+ if (!('value' in params)) {
+ if (propertyDetails) {
+ valueType = propertyDetails.defaultType;
+ } else {
+ valueType = DEFAULT_VALUE_TYPE;
+ }
+ } else {
+ // possible to avoid this?
+ valueType = params.value.toLowerCase();
+ }
+ }
+
+ delete params.value;
+
+ /**
+ * Note on `var result` juggling:
+ *
+ * I observed that building the array in pieces has adverse
+ * effects on performance, so where possible we inline the creation.
+ * It is a little ugly but resulted in ~2000 additional ops/sec.
+ */
+
+ var result;
+ if (multiValue && structuredValue) {
+ value = parser._parseMultiValue(value, structuredValue, valueType, [], multiValue, state.designSet, structuredValue);
+ result = [ungroupedName, params, valueType, value];
+ } else if (multiValue) {
+ result = [ungroupedName, params, valueType];
+ parser._parseMultiValue(value, multiValue, valueType, result, null, state.designSet, false);
+ } else if (structuredValue) {
+ value = parser._parseMultiValue(value, structuredValue, valueType, [], null, state.designSet, structuredValue);
+ result = [ungroupedName, params, valueType, value];
+ } else {
+ value = parser._parseValue(value, valueType, state.designSet, false);
+ result = [ungroupedName, params, valueType, value];
+ }
+ // rfc6350 requires that in vCard 4.0 the first component is the VERSION
+ // component with as value 4.0, note that 3.0 does not have this requirement.
+ if (state.component[0] === 'vcard' && state.component[1].length === 0 &&
+ !(name === 'version' && value === '4.0')) {
+ state.designSet = design.getDesignSet("vcard3");
+ }
+ state.component[1].push(result);
+ };
+
+ /**
+ * Parse a value from the raw value into the jCard/jCal value.
+ *
+ * @private
+ * @function ICAL.parse._parseValue
+ * @param {String} value Original value
+ * @param {String} type Type of value
+ * @param {Object} designSet The design data to use for this value
+ * @return {Object} varies on type
+ */
+ parser._parseValue = function(value, type, designSet, structuredValue) {
+ if (type in designSet.value && 'fromICAL' in designSet.value[type]) {
+ return designSet.value[type].fromICAL(value, structuredValue);
+ }
+ return value;
+ };
+
+ /**
+ * Parse parameters from a string to object.
+ *
+ * @function ICAL.parse._parseParameters
+ * @private
+ * @param {String} line A single unfolded line
+ * @param {Numeric} start Position to start looking for properties
+ * @param {Object} designSet The design data to use for this property
+ * @return {Object} key/value pairs
+ */
+ parser._parseParameters = function(line, start, designSet) {
+ var lastParam = start;
+ var pos = 0;
+ var delim = PARAM_NAME_DELIMITER;
+ var result = {};
+ var name, lcname;
+ var value, valuePos = -1;
+ var type, multiValue, mvdelim;
+
+ // find the next '=' sign
+ // use lastParam and pos to find name
+ // check if " is used if so get value from "->"
+ // then increment pos to find next ;
+
+ while ((pos !== false) &&
+ (pos = helpers.unescapedIndexOf(line, delim, pos + 1)) !== -1) {
+
+ name = line.substr(lastParam + 1, pos - lastParam - 1);
+ if (name.length == 0) {
+ throw new ParserError("Empty parameter name in '" + line + "'");
+ }
+ lcname = name.toLowerCase();
+ mvdelim = false;
+ multiValue = false;
+
+ if (lcname in designSet.param && designSet.param[lcname].valueType) {
+ type = designSet.param[lcname].valueType;
+ } else {
+ type = DEFAULT_PARAM_TYPE;
+ }
+
+ if (lcname in designSet.param) {
+ multiValue = designSet.param[lcname].multiValue;
+ if (designSet.param[lcname].multiValueSeparateDQuote) {
+ mvdelim = parser._rfc6868Escape('"' + multiValue + '"');
+ }
+ }
+
+ var nextChar = line[pos + 1];
+ if (nextChar === '"') {
+ valuePos = pos + 2;
+ pos = helpers.unescapedIndexOf(line, '"', valuePos);
+ if (multiValue && pos != -1) {
+ var extendedValue = true;
+ while (extendedValue) {
+ if (line[pos + 1] == multiValue && line[pos + 2] == '"') {
+ pos = helpers.unescapedIndexOf(line, '"', pos + 3);
+ } else {
+ extendedValue = false;
+ }
+ }
+ }
+ if (pos === -1) {
+ throw new ParserError(
+ 'invalid line (no matching double quote) "' + line + '"'
+ );
+ }
+ value = line.substr(valuePos, pos - valuePos);
+ lastParam = helpers.unescapedIndexOf(line, PARAM_DELIMITER, pos);
+ if (lastParam === -1) {
+ pos = false;
+ }
+ } else {
+ valuePos = pos + 1;
+
+ // move to next ";"
+ var nextPos = helpers.unescapedIndexOf(line, PARAM_DELIMITER, valuePos);
+ var propValuePos = helpers.unescapedIndexOf(line, VALUE_DELIMITER, valuePos);
+ if (propValuePos !== -1 && nextPos > propValuePos) {
+ // this is a delimiter in the property value, let's stop here
+ nextPos = propValuePos;
+ pos = false;
+ } else if (nextPos === -1) {
+ // no ";"
+ if (propValuePos === -1) {
+ nextPos = line.length;
+ } else {
+ nextPos = propValuePos;
+ }
+ pos = false;
+ } else {
+ lastParam = nextPos;
+ pos = nextPos;
+ }
+
+ value = line.substr(valuePos, nextPos - valuePos);
+ }
+
+ value = parser._rfc6868Escape(value);
+ if (multiValue) {
+ var delimiter = mvdelim || multiValue;
+ value = parser._parseMultiValue(value, delimiter, type, [], null, designSet);
+ } else {
+ value = parser._parseValue(value, type, designSet);
+ }
+
+ if (multiValue && (lcname in result)) {
+ if (Array.isArray(result[lcname])) {
+ result[lcname].push(value);
+ } else {
+ result[lcname] = [
+ result[lcname],
+ value
+ ];
+ }
+ } else {
+ result[lcname] = value;
+ }
+ }
+ return [result, value, valuePos];
+ };
+
+ /**
+ * Internal helper for rfc6868. Exposing this on ICAL.parse so that
+ * hackers can disable the rfc6868 parsing if the really need to.
+ *
+ * @function ICAL.parse._rfc6868Escape
+ * @param {String} val The value to escape
+ * @return {String} The escaped value
+ */
+ parser._rfc6868Escape = function(val) {
+ return val.replace(/\^['n^]/g, function(x) {
+ return RFC6868_REPLACE_MAP[x];
+ });
+ };
+ var RFC6868_REPLACE_MAP = { "^'": '"', "^n": "\n", "^^": "^" };
+
+ /**
+ * Parse a multi value string. This function is used either for parsing
+ * actual multi-value property's values, or for handling parameter values. It
+ * can be used for both multi-value properties and structured value properties.
+ *
+ * @private
+ * @function ICAL.parse._parseMultiValue
+ * @param {String} buffer The buffer containing the full value
+ * @param {String} delim The multi-value delimiter
+ * @param {String} type The value type to be parsed
+ * @param {Array.<?>} result The array to append results to, varies on value type
+ * @param {String} innerMulti The inner delimiter to split each value with
+ * @param {ICAL.design.designSet} designSet The design data for this value
+ * @return {?|Array.<?>} Either an array of results, or the first result
+ */
+ parser._parseMultiValue = function(buffer, delim, type, result, innerMulti, designSet, structuredValue) {
+ var pos = 0;
+ var lastPos = 0;
+ var value;
+ if (delim.length === 0) {
+ return buffer;
+ }
+
+ // split each piece
+ while ((pos = helpers.unescapedIndexOf(buffer, delim, lastPos)) !== -1) {
+ value = buffer.substr(lastPos, pos - lastPos);
+ if (innerMulti) {
+ value = parser._parseMultiValue(value, innerMulti, type, [], null, designSet, structuredValue);
+ } else {
+ value = parser._parseValue(value, type, designSet, structuredValue);
+ }
+ result.push(value);
+ lastPos = pos + delim.length;
+ }
+
+ // on the last piece take the rest of string
+ value = buffer.substr(lastPos);
+ if (innerMulti) {
+ value = parser._parseMultiValue(value, innerMulti, type, [], null, designSet, structuredValue);
+ } else {
+ value = parser._parseValue(value, type, designSet, structuredValue);
+ }
+ result.push(value);
+
+ return result.length == 1 ? result[0] : result;
+ };
+
+ /**
+ * Process a complete buffer of iCalendar/vCard data line by line, correctly
+ * unfolding content. Each line will be processed with the given callback
+ *
+ * @private
+ * @function ICAL.parse._eachLine
+ * @param {String} buffer The buffer to process
+ * @param {function(?String, String)} callback The callback for each line
+ */
+ parser._eachLine = function(buffer, callback) {
+ var len = buffer.length;
+ var lastPos = buffer.search(CHAR);
+ var pos = lastPos;
+ var line;
+ var firstChar;
+
+ var newlineOffset;
+
+ do {
+ pos = buffer.indexOf('\n', lastPos) + 1;
+
+ if (pos > 1 && buffer[pos - 2] === '\r') {
+ newlineOffset = 2;
+ } else {
+ newlineOffset = 1;
+ }
+
+ if (pos === 0) {
+ pos = len;
+ newlineOffset = 0;
+ }
+
+ firstChar = buffer[lastPos];
+
+ if (firstChar === ' ' || firstChar === '\t') {
+ // add to line
+ line += buffer.substr(
+ lastPos + 1,
+ pos - lastPos - (newlineOffset + 1)
+ );
+ } else {
+ if (line)
+ callback(null, line);
+ // push line
+ line = buffer.substr(
+ lastPos,
+ pos - lastPos - newlineOffset
+ );
+ }
+
+ lastPos = pos;
+ } while (pos !== len);
+
+ // extra ending line
+ line = line.trim();
+
+ if (line.length)
+ callback(null, line);
+ };
+
+ return parser;
+
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Component = (function() {
+ 'use strict';
+
+ var PROPERTY_INDEX = 1;
+ var COMPONENT_INDEX = 2;
+ var NAME_INDEX = 0;
+
+ /**
+ * @classdesc
+ * Wraps a jCal component, adding convenience methods to add, remove and
+ * update subcomponents and properties.
+ *
+ * @class
+ * @alias ICAL.Component
+ * @param {Array|String} jCal Raw jCal component data OR name of new
+ * component
+ * @param {ICAL.Component} parent Parent component to associate
+ */
+ function Component(jCal, parent) {
+ if (typeof(jCal) === 'string') {
+ // jCal spec (name, properties, components)
+ jCal = [jCal, [], []];
+ }
+
+ // mostly for legacy reasons.
+ this.jCal = jCal;
+
+ this.parent = parent || null;
+
+ if (!this.parent && this.name === 'vcalendar') {
+ this._timezoneCache = new Map();
+ }
+ }
+
+ Component.prototype = {
+ /**
+ * Hydrated properties are inserted into the _properties array at the same
+ * position as in the jCal array, so it is possible that the array contains
+ * undefined values for unhydrdated properties. To avoid iterating the
+ * array when checking if all properties have been hydrated, we save the
+ * count here.
+ *
+ * @type {Number}
+ * @private
+ */
+ _hydratedPropertyCount: 0,
+
+ /**
+ * The same count as for _hydratedPropertyCount, but for subcomponents
+ *
+ * @type {Number}
+ * @private
+ */
+ _hydratedComponentCount: 0,
+
+ /**
+ * A cache of hydrated time zone objects which may be used by consumers, keyed
+ * by time zone ID.
+ *
+ * @type {Map}
+ * @private
+ */
+ _timezoneCache: null,
+
+ /**
+ * The name of this component
+ * @readonly
+ */
+ get name() {
+ return this.jCal[NAME_INDEX];
+ },
+
+ /**
+ * The design set for this component, e.g. icalendar vs vcard
+ *
+ * @type {ICAL.design.designSet}
+ * @private
+ */
+ get _designSet() {
+ var parentDesign = this.parent && this.parent._designSet;
+ return parentDesign || ICAL.design.getDesignSet(this.name);
+ },
+
+ _hydrateComponent: function(index) {
+ if (!this._components) {
+ this._components = [];
+ this._hydratedComponentCount = 0;
+ }
+
+ if (this._components[index]) {
+ return this._components[index];
+ }
+
+ var comp = new Component(
+ this.jCal[COMPONENT_INDEX][index],
+ this
+ );
+
+ this._hydratedComponentCount++;
+ return (this._components[index] = comp);
+ },
+
+ _hydrateProperty: function(index) {
+ if (!this._properties) {
+ this._properties = [];
+ this._hydratedPropertyCount = 0;
+ }
+
+ if (this._properties[index]) {
+ return this._properties[index];
+ }
+
+ var prop = new ICAL.Property(
+ this.jCal[PROPERTY_INDEX][index],
+ this
+ );
+
+ this._hydratedPropertyCount++;
+ return (this._properties[index] = prop);
+ },
+
+ /**
+ * Finds first sub component, optionally filtered by name.
+ *
+ * @param {String=} name Optional name to filter by
+ * @return {?ICAL.Component} The found subcomponent
+ */
+ getFirstSubcomponent: function(name) {
+ if (name) {
+ var i = 0;
+ var comps = this.jCal[COMPONENT_INDEX];
+ var len = comps.length;
+
+ for (; i < len; i++) {
+ if (comps[i][NAME_INDEX] === name) {
+ var result = this._hydrateComponent(i);
+ return result;
+ }
+ }
+ } else {
+ if (this.jCal[COMPONENT_INDEX].length) {
+ return this._hydrateComponent(0);
+ }
+ }
+
+ // ensure we return a value (strict mode)
+ return null;
+ },
+
+ /**
+ * Finds all sub components, optionally filtering by name.
+ *
+ * @param {String=} name Optional name to filter by
+ * @return {ICAL.Component[]} The found sub components
+ */
+ getAllSubcomponents: function(name) {
+ var jCalLen = this.jCal[COMPONENT_INDEX].length;
+ var i = 0;
+
+ if (name) {
+ var comps = this.jCal[COMPONENT_INDEX];
+ var result = [];
+
+ for (; i < jCalLen; i++) {
+ if (name === comps[i][NAME_INDEX]) {
+ result.push(
+ this._hydrateComponent(i)
+ );
+ }
+ }
+ return result;
+ } else {
+ if (!this._components ||
+ (this._hydratedComponentCount !== jCalLen)) {
+ for (; i < jCalLen; i++) {
+ this._hydrateComponent(i);
+ }
+ }
+
+ return this._components || [];
+ }
+ },
+
+ /**
+ * Returns true when a named property exists.
+ *
+ * @param {String} name The property name
+ * @return {Boolean} True, when property is found
+ */
+ hasProperty: function(name) {
+ var props = this.jCal[PROPERTY_INDEX];
+ var len = props.length;
+
+ var i = 0;
+ for (; i < len; i++) {
+ // 0 is property name
+ if (props[i][NAME_INDEX] === name) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Finds the first property, optionally with the given name.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {?ICAL.Property} The found property
+ */
+ getFirstProperty: function(name) {
+ if (name) {
+ var i = 0;
+ var props = this.jCal[PROPERTY_INDEX];
+ var len = props.length;
+
+ for (; i < len; i++) {
+ if (props[i][NAME_INDEX] === name) {
+ var result = this._hydrateProperty(i);
+ return result;
+ }
+ }
+ } else {
+ if (this.jCal[PROPERTY_INDEX].length) {
+ return this._hydrateProperty(0);
+ }
+ }
+
+ return null;
+ },
+
+ /**
+ * Returns first property's value, if available.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {?String} The found property value.
+ */
+ getFirstPropertyValue: function(name) {
+ var prop = this.getFirstProperty(name);
+ if (prop) {
+ return prop.getFirstValue();
+ }
+
+ return null;
+ },
+
+ /**
+ * Get all properties in the component, optionally filtered by name.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {ICAL.Property[]} List of properties
+ */
+ getAllProperties: function(name) {
+ var jCalLen = this.jCal[PROPERTY_INDEX].length;
+ var i = 0;
+
+ if (name) {
+ var props = this.jCal[PROPERTY_INDEX];
+ var result = [];
+
+ for (; i < jCalLen; i++) {
+ if (name === props[i][NAME_INDEX]) {
+ result.push(
+ this._hydrateProperty(i)
+ );
+ }
+ }
+ return result;
+ } else {
+ if (!this._properties ||
+ (this._hydratedPropertyCount !== jCalLen)) {
+ for (; i < jCalLen; i++) {
+ this._hydrateProperty(i);
+ }
+ }
+
+ return this._properties || [];
+ }
+ },
+
+ _removeObjectByIndex: function(jCalIndex, cache, index) {
+ cache = cache || [];
+ // remove cached version
+ if (cache[index]) {
+ var obj = cache[index];
+ if ("parent" in obj) {
+ obj.parent = null;
+ }
+ }
+
+ cache.splice(index, 1);
+
+ // remove it from the jCal
+ this.jCal[jCalIndex].splice(index, 1);
+ },
+
+ _removeObject: function(jCalIndex, cache, nameOrObject) {
+ var i = 0;
+ var objects = this.jCal[jCalIndex];
+ var len = objects.length;
+ var cached = this[cache];
+
+ if (typeof(nameOrObject) === 'string') {
+ for (; i < len; i++) {
+ if (objects[i][NAME_INDEX] === nameOrObject) {
+ this._removeObjectByIndex(jCalIndex, cached, i);
+ return true;
+ }
+ }
+ } else if (cached) {
+ for (; i < len; i++) {
+ if (cached[i] && cached[i] === nameOrObject) {
+ this._removeObjectByIndex(jCalIndex, cached, i);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ },
+
+ _removeAllObjects: function(jCalIndex, cache, name) {
+ var cached = this[cache];
+
+ // Unfortunately we have to run through all children to reset their
+ // parent property.
+ var objects = this.jCal[jCalIndex];
+ var i = objects.length - 1;
+
+ // descending search required because splice
+ // is used and will effect the indices.
+ for (; i >= 0; i--) {
+ if (!name || objects[i][NAME_INDEX] === name) {
+ this._removeObjectByIndex(jCalIndex, cached, i);
+ }
+ }
+ },
+
+ /**
+ * Adds a single sub component.
+ *
+ * @param {ICAL.Component} component The component to add
+ * @return {ICAL.Component} The passed in component
+ */
+ addSubcomponent: function(component) {
+ if (!this._components) {
+ this._components = [];
+ this._hydratedComponentCount = 0;
+ }
+
+ if (component.parent) {
+ component.parent.removeSubcomponent(component);
+ }
+
+ var idx = this.jCal[COMPONENT_INDEX].push(component.jCal);
+ this._components[idx - 1] = component;
+ this._hydratedComponentCount++;
+ component.parent = this;
+ return component;
+ },
+
+ /**
+ * Removes a single component by name or the instance of a specific
+ * component.
+ *
+ * @param {ICAL.Component|String} nameOrComp Name of component, or component
+ * @return {Boolean} True when comp is removed
+ */
+ removeSubcomponent: function(nameOrComp) {
+ var removed = this._removeObject(COMPONENT_INDEX, '_components', nameOrComp);
+ if (removed) {
+ this._hydratedComponentCount--;
+ }
+ return removed;
+ },
+
+ /**
+ * Removes all components or (if given) all components by a particular
+ * name.
+ *
+ * @param {String=} name Lowercase component name
+ */
+ removeAllSubcomponents: function(name) {
+ var removed = this._removeAllObjects(COMPONENT_INDEX, '_components', name);
+ this._hydratedComponentCount = 0;
+ return removed;
+ },
+
+ /**
+ * Adds an {@link ICAL.Property} to the component.
+ *
+ * @param {ICAL.Property} property The property to add
+ * @return {ICAL.Property} The passed in property
+ */
+ addProperty: function(property) {
+ if (!(property instanceof ICAL.Property)) {
+ throw new TypeError('must be instance of ICAL.Property');
+ }
+
+ if (!this._properties) {
+ this._properties = [];
+ this._hydratedPropertyCount = 0;
+ }
+
+ if (property.parent) {
+ property.parent.removeProperty(property);
+ }
+
+ var idx = this.jCal[PROPERTY_INDEX].push(property.jCal);
+ this._properties[idx - 1] = property;
+ this._hydratedPropertyCount++;
+ property.parent = this;
+ return property;
+ },
+
+ /**
+ * Helper method to add a property with a value to the component.
+ *
+ * @param {String} name Property name to add
+ * @param {String|Number|Object} value Property value
+ * @return {ICAL.Property} The created property
+ */
+ addPropertyWithValue: function(name, value) {
+ var prop = new ICAL.Property(name);
+ prop.setValue(value);
+
+ this.addProperty(prop);
+
+ return prop;
+ },
+
+ /**
+ * Helper method that will update or create a property of the given name
+ * and sets its value. If multiple properties with the given name exist,
+ * only the first is updated.
+ *
+ * @param {String} name Property name to update
+ * @param {String|Number|Object} value Property value
+ * @return {ICAL.Property} The created property
+ */
+ updatePropertyWithValue: function(name, value) {
+ var prop = this.getFirstProperty(name);
+
+ if (prop) {
+ prop.setValue(value);
+ } else {
+ prop = this.addPropertyWithValue(name, value);
+ }
+
+ return prop;
+ },
+
+ /**
+ * Removes a single property by name or the instance of the specific
+ * property.
+ *
+ * @param {String|ICAL.Property} nameOrProp Property name or instance to remove
+ * @return {Boolean} True, when deleted
+ */
+ removeProperty: function(nameOrProp) {
+ var removed = this._removeObject(PROPERTY_INDEX, '_properties', nameOrProp);
+ if (removed) {
+ this._hydratedPropertyCount--;
+ }
+ return removed;
+ },
+
+ /**
+ * Removes all properties associated with this component, optionally
+ * filtered by name.
+ *
+ * @param {String=} name Lowercase property name
+ * @return {Boolean} True, when deleted
+ */
+ removeAllProperties: function(name) {
+ var removed = this._removeAllObjects(PROPERTY_INDEX, '_properties', name);
+ this._hydratedPropertyCount = 0;
+ return removed;
+ },
+
+ /**
+ * Returns the Object representation of this component. The returned object
+ * is a live jCal object and should be cloned if modified.
+ * @return {Object}
+ */
+ toJSON: function() {
+ return this.jCal;
+ },
+
+ /**
+ * The string representation of this component.
+ * @return {String}
+ */
+ toString: function() {
+ return ICAL.stringify.component(
+ this.jCal, this._designSet
+ );
+ },
+
+ /**
+ * Retrieve a time zone definition from the component tree, if any is present.
+ * If the tree contains no time zone definitions or the TZID cannot be
+ * matched, returns null.
+ *
+ * @param {String} tzid The ID of the time zone to retrieve
+ * @return {ICAL.Timezone} The time zone corresponding to the ID, or null
+ */
+ getTimeZoneByID: function(tzid) {
+ // VTIMEZONE components can only appear as a child of the VCALENDAR
+ // component; walk the tree if we're not the root.
+ if (this.parent) {
+ return this.parent.getTimeZoneByID(tzid);
+ }
+
+ // If there is no time zone cache, we are probably parsing an incomplete
+ // file and will have no time zone definitions.
+ if (!this._timezoneCache) {
+ return null;
+ }
+
+ if (this._timezoneCache.has(tzid)) {
+ return this._timezoneCache.get(tzid);
+ }
+
+ // If the time zone is not already cached, hydrate it from the
+ // subcomponents.
+ var zones = this.getAllSubcomponents('vtimezone');
+ for (var i = 0; i < zones.length; i++) {
+ var zone = zones[i];
+ if (zone.getFirstProperty('tzid').getFirstValue() === tzid) {
+ var hydratedZone = new ICAL.Timezone({
+ component: zone,
+ tzid: tzid,
+ });
+
+ this._timezoneCache.set(tzid, hydratedZone);
+
+ return hydratedZone;
+ }
+ }
+
+ // Per the standard, we should always have a time zone defined in a file
+ // for any referenced TZID, but don't blow up if the file is invalid.
+ return null;
+ }
+ };
+
+ /**
+ * Create an {@link ICAL.Component} by parsing the passed iCalendar string.
+ *
+ * @param {String} str The iCalendar string to parse
+ */
+ Component.fromString = function(str) {
+ return new Component(ICAL.parse.component(str));
+ };
+
+ return Component;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Property = (function() {
+ 'use strict';
+
+ var NAME_INDEX = 0;
+ var PROP_INDEX = 1;
+ var TYPE_INDEX = 2;
+ var VALUE_INDEX = 3;
+
+ var design = ICAL.design;
+
+ /**
+ * @classdesc
+ * Provides a layer on top of the raw jCal object for manipulating a single
+ * property, with its parameters and value.
+ *
+ * @description
+ * It is important to note that mutations done in the wrapper
+ * directly mutate the jCal object used to initialize.
+ *
+ * Can also be used to create new properties by passing
+ * the name of the property (as a String).
+ *
+ * @class
+ * @alias ICAL.Property
+ * @param {Array|String} jCal Raw jCal representation OR
+ * the new name of the property
+ *
+ * @param {ICAL.Component=} parent Parent component
+ */
+ function Property(jCal, parent) {
+ this._parent = parent || null;
+
+ if (typeof(jCal) === 'string') {
+ // We are creating the property by name and need to detect the type
+ this.jCal = [jCal, {}, design.defaultType];
+ this.jCal[TYPE_INDEX] = this.getDefaultType();
+ } else {
+ this.jCal = jCal;
+ }
+ this._updateType();
+ }
+
+ Property.prototype = {
+
+ /**
+ * The value type for this property
+ * @readonly
+ * @type {String}
+ */
+ get type() {
+ return this.jCal[TYPE_INDEX];
+ },
+
+ /**
+ * The name of this property, in lowercase.
+ * @readonly
+ * @type {String}
+ */
+ get name() {
+ return this.jCal[NAME_INDEX];
+ },
+
+ /**
+ * The parent component for this property.
+ * @type {ICAL.Component}
+ */
+ get parent() {
+ return this._parent;
+ },
+
+ set parent(p) {
+ // Before setting the parent, check if the design set has changed. If it
+ // has, we later need to update the type if it was unknown before.
+ var designSetChanged = !this._parent || (p && p._designSet != this._parent._designSet);
+
+ this._parent = p;
+
+ if (this.type == design.defaultType && designSetChanged) {
+ this.jCal[TYPE_INDEX] = this.getDefaultType();
+ this._updateType();
+ }
+
+ return p;
+ },
+
+ /**
+ * The design set for this property, e.g. icalendar vs vcard
+ *
+ * @type {ICAL.design.designSet}
+ * @private
+ */
+ get _designSet() {
+ return this.parent ? this.parent._designSet : design.defaultSet;
+ },
+
+ /**
+ * Updates the type metadata from the current jCal type and design set.
+ *
+ * @private
+ */
+ _updateType: function() {
+ var designSet = this._designSet;
+
+ if (this.type in designSet.value) {
+ var designType = designSet.value[this.type];
+
+ if ('decorate' in designSet.value[this.type]) {
+ this.isDecorated = true;
+ } else {
+ this.isDecorated = false;
+ }
+
+ if (this.name in designSet.property) {
+ this.isMultiValue = ('multiValue' in designSet.property[this.name]);
+ this.isStructuredValue = ('structuredValue' in designSet.property[this.name]);
+ }
+ }
+ },
+
+ /**
+ * Hydrate a single value. The act of hydrating means turning the raw jCal
+ * value into a potentially wrapped object, for example {@link ICAL.Time}.
+ *
+ * @private
+ * @param {Number} index The index of the value to hydrate
+ * @return {Object} The decorated value.
+ */
+ _hydrateValue: function(index) {
+ if (this._values && this._values[index]) {
+ return this._values[index];
+ }
+
+ // for the case where there is no value.
+ if (this.jCal.length <= (VALUE_INDEX + index)) {
+ return null;
+ }
+
+ if (this.isDecorated) {
+ if (!this._values) {
+ this._values = [];
+ }
+ return (this._values[index] = this._decorate(
+ this.jCal[VALUE_INDEX + index]
+ ));
+ } else {
+ return this.jCal[VALUE_INDEX + index];
+ }
+ },
+
+ /**
+ * Decorate a single value, returning its wrapped object. This is used by
+ * the hydrate function to actually wrap the value.
+ *
+ * @private
+ * @param {?} value The value to decorate
+ * @return {Object} The decorated value
+ */
+ _decorate: function(value) {
+ return this._designSet.value[this.type].decorate(value, this);
+ },
+
+ /**
+ * Undecorate a single value, returning its raw jCal data.
+ *
+ * @private
+ * @param {Object} value The value to undecorate
+ * @return {?} The undecorated value
+ */
+ _undecorate: function(value) {
+ return this._designSet.value[this.type].undecorate(value, this);
+ },
+
+ /**
+ * Sets the value at the given index while also hydrating it. The passed
+ * value can either be a decorated or undecorated value.
+ *
+ * @private
+ * @param {?} value The value to set
+ * @param {Number} index The index to set it at
+ */
+ _setDecoratedValue: function(value, index) {
+ if (!this._values) {
+ this._values = [];
+ }
+
+ if (typeof(value) === 'object' && 'icaltype' in value) {
+ // decorated value
+ this.jCal[VALUE_INDEX + index] = this._undecorate(value);
+ this._values[index] = value;
+ } else {
+ // undecorated value
+ this.jCal[VALUE_INDEX + index] = value;
+ this._values[index] = this._decorate(value);
+ }
+ },
+
+ /**
+ * Gets a parameter on the property.
+ *
+ * @param {String} name Parameter name (lowercase)
+ * @return {Array|String} Parameter value
+ */
+ getParameter: function(name) {
+ if (name in this.jCal[PROP_INDEX]) {
+ return this.jCal[PROP_INDEX][name];
+ } else {
+ return undefined;
+ }
+ },
+
+ /**
+ * Gets first parameter on the property.
+ *
+ * @param {String} name Parameter name (lowercase)
+ * @return {String} Parameter value
+ */
+ getFirstParameter: function(name) {
+ var parameters = this.getParameter(name);
+
+ if (Array.isArray(parameters)) {
+ return parameters[0];
+ }
+
+ return parameters;
+ },
+
+ /**
+ * Sets a parameter on the property.
+ *
+ * @param {String} name The parameter name
+ * @param {Array|String} value The parameter value
+ */
+ setParameter: function(name, value) {
+ var lcname = name.toLowerCase();
+ if (typeof value === "string" &&
+ lcname in this._designSet.param &&
+ 'multiValue' in this._designSet.param[lcname]) {
+ value = [value];
+ }
+ this.jCal[PROP_INDEX][name] = value;
+ },
+
+ /**
+ * Removes a parameter
+ *
+ * @param {String} name The parameter name
+ */
+ removeParameter: function(name) {
+ delete this.jCal[PROP_INDEX][name];
+ },
+
+ /**
+ * Get the default type based on this property's name.
+ *
+ * @return {String} The default type for this property
+ */
+ getDefaultType: function() {
+ var name = this.jCal[NAME_INDEX];
+ var designSet = this._designSet;
+
+ if (name in designSet.property) {
+ var details = designSet.property[name];
+ if ('defaultType' in details) {
+ return details.defaultType;
+ }
+ }
+ return design.defaultType;
+ },
+
+ /**
+ * Sets type of property and clears out any existing values of the current
+ * type.
+ *
+ * @param {String} type New iCAL type (see design.*.values)
+ */
+ resetType: function(type) {
+ this.removeAllValues();
+ this.jCal[TYPE_INDEX] = type;
+ this._updateType();
+ },
+
+ /**
+ * Finds the first property value.
+ *
+ * @return {String} First property value
+ */
+ getFirstValue: function() {
+ return this._hydrateValue(0);
+ },
+
+ /**
+ * Gets all values on the property.
+ *
+ * NOTE: this creates an array during each call.
+ *
+ * @return {Array} List of values
+ */
+ getValues: function() {
+ var len = this.jCal.length - VALUE_INDEX;
+
+ if (len < 1) {
+ // it is possible for a property to have no value.
+ return [];
+ }
+
+ var i = 0;
+ var result = [];
+
+ for (; i < len; i++) {
+ result[i] = this._hydrateValue(i);
+ }
+
+ return result;
+ },
+
+ /**
+ * Removes all values from this property
+ */
+ removeAllValues: function() {
+ if (this._values) {
+ this._values.length = 0;
+ }
+ this.jCal.length = 3;
+ },
+
+ /**
+ * Sets the values of the property. Will overwrite the existing values.
+ * This can only be used for multi-value properties.
+ *
+ * @param {Array} values An array of values
+ */
+ setValues: function(values) {
+ if (!this.isMultiValue) {
+ throw new Error(
+ this.name + ': does not not support mulitValue.\n' +
+ 'override isMultiValue'
+ );
+ }
+
+ var len = values.length;
+ var i = 0;
+ this.removeAllValues();
+
+ if (len > 0 &&
+ typeof(values[0]) === 'object' &&
+ 'icaltype' in values[0]) {
+ this.resetType(values[0].icaltype);
+ }
+
+ if (this.isDecorated) {
+ for (; i < len; i++) {
+ this._setDecoratedValue(values[i], i);
+ }
+ } else {
+ for (; i < len; i++) {
+ this.jCal[VALUE_INDEX + i] = values[i];
+ }
+ }
+ },
+
+ /**
+ * Sets the current value of the property. If this is a multi-value
+ * property, all other values will be removed.
+ *
+ * @param {String|Object} value New property value.
+ */
+ setValue: function(value) {
+ this.removeAllValues();
+ if (typeof(value) === 'object' && 'icaltype' in value) {
+ this.resetType(value.icaltype);
+ }
+
+ if (this.isDecorated) {
+ this._setDecoratedValue(value, 0);
+ } else {
+ this.jCal[VALUE_INDEX] = value;
+ }
+ },
+
+ /**
+ * Returns the Object representation of this component. The returned object
+ * is a live jCal object and should be cloned if modified.
+ * @return {Object}
+ */
+ toJSON: function() {
+ return this.jCal;
+ },
+
+ /**
+ * The string representation of this component.
+ * @return {String}
+ */
+ toICALString: function() {
+ return ICAL.stringify.property(
+ this.jCal, this._designSet, true
+ );
+ }
+ };
+
+ /**
+ * Create an {@link ICAL.Property} by parsing the passed iCalendar string.
+ *
+ * @param {String} str The iCalendar string to parse
+ * @param {ICAL.design.designSet=} designSet The design data to use for this property
+ * @return {ICAL.Property} The created iCalendar property
+ */
+ Property.fromString = function(str, designSet) {
+ return new Property(ICAL.parse.property(str, designSet));
+ };
+
+ return Property;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.UtcOffset = (function() {
+
+ /**
+ * @classdesc
+ * This class represents the "duration" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @class
+ * @alias ICAL.UtcOffset
+ * @param {Object} aData An object with members of the utc offset
+ * @param {Number=} aData.hours The hours for the utc offset
+ * @param {Number=} aData.minutes The minutes in the utc offset
+ * @param {Number=} aData.factor The factor for the utc-offset, either -1 or 1
+ */
+ function UtcOffset(aData) {
+ this.fromData(aData);
+ }
+
+ UtcOffset.prototype = {
+
+ /**
+ * The hours in the utc-offset
+ * @type {Number}
+ */
+ hours: 0,
+
+ /**
+ * The minutes in the utc-offset
+ * @type {Number}
+ */
+ minutes: 0,
+
+ /**
+ * The sign of the utc offset, 1 for positive offset, -1 for negative
+ * offsets.
+ * @type {Number}
+ */
+ factor: 1,
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "utc-offset"
+ */
+ icaltype: "utc-offset",
+
+ /**
+ * Returns a clone of the utc offset object.
+ *
+ * @return {ICAL.UtcOffset} The cloned object
+ */
+ clone: function() {
+ return ICAL.UtcOffset.fromSeconds(this.toSeconds());
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} aData An object with members of the utc offset
+ * @param {Number=} aData.hours The hours for the utc offset
+ * @param {Number=} aData.minutes The minutes in the utc offset
+ * @param {Number=} aData.factor The factor for the utc-offset, either -1 or 1
+ */
+ fromData: function(aData) {
+ if (aData) {
+ for (var key in aData) {
+ /* istanbul ignore else */
+ if (aData.hasOwnProperty(key)) {
+ this[key] = aData[key];
+ }
+ }
+ }
+ this._normalize();
+ },
+
+ /**
+ * Sets up the current instance from the given seconds value. The seconds
+ * value is truncated to the minute. Offsets are wrapped when the world
+ * ends, the hour after UTC+14:00 is UTC-12:00.
+ *
+ * @param {Number} aSeconds The seconds to convert into an offset
+ */
+ fromSeconds: function(aSeconds) {
+ var secs = Math.abs(aSeconds);
+
+ this.factor = aSeconds < 0 ? -1 : 1;
+ this.hours = ICAL.helpers.trunc(secs / 3600);
+
+ secs -= (this.hours * 3600);
+ this.minutes = ICAL.helpers.trunc(secs / 60);
+ return this;
+ },
+
+ /**
+ * Convert the current offset to a value in seconds
+ *
+ * @return {Number} The offset in seconds
+ */
+ toSeconds: function() {
+ return this.factor * (60 * this.minutes + 3600 * this.hours);
+ },
+
+ /**
+ * Compare this utc offset with another one.
+ *
+ * @param {ICAL.UtcOffset} other The other offset to compare with
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compare: function icaltime_compare(other) {
+ var a = this.toSeconds();
+ var b = other.toSeconds();
+ return (a > b) - (b > a);
+ },
+
+ _normalize: function() {
+ // Range: 97200 seconds (with 1 hour inbetween)
+ var secs = this.toSeconds();
+ var factor = this.factor;
+ while (secs < -43200) { // = UTC-12:00
+ secs += 97200;
+ }
+ while (secs > 50400) { // = UTC+14:00
+ secs -= 97200;
+ }
+
+ this.fromSeconds(secs);
+
+ // Avoid changing the factor when on zero seconds
+ if (secs == 0) {
+ this.factor = factor;
+ }
+ },
+
+ /**
+ * The iCalendar string representation of this utc-offset.
+ * @return {String}
+ */
+ toICALString: function() {
+ return ICAL.design.icalendar.value['utc-offset'].toICAL(this.toString());
+ },
+
+ /**
+ * The string representation of this utc-offset.
+ * @return {String}
+ */
+ toString: function toString() {
+ return (this.factor == 1 ? "+" : "-") +
+ ICAL.helpers.pad2(this.hours) + ':' +
+ ICAL.helpers.pad2(this.minutes);
+ }
+ };
+
+ /**
+ * Creates a new {@link ICAL.UtcOffset} instance from the passed string.
+ *
+ * @param {String} aString The string to parse
+ * @return {ICAL.Duration} The created utc-offset instance
+ */
+ UtcOffset.fromString = function(aString) {
+ // -05:00
+ var options = {};
+ //TODO: support seconds per rfc5545 ?
+ options.factor = (aString[0] === '+') ? 1 : -1;
+ options.hours = ICAL.helpers.strictParseInt(aString.substr(1, 2));
+ options.minutes = ICAL.helpers.strictParseInt(aString.substr(4, 2));
+
+ return new ICAL.UtcOffset(options);
+ };
+
+ /**
+ * Creates a new {@link ICAL.UtcOffset} instance from the passed seconds
+ * value.
+ *
+ * @param {Number} aSeconds The number of seconds to convert
+ */
+ UtcOffset.fromSeconds = function(aSeconds) {
+ var instance = new UtcOffset();
+ instance.fromSeconds(aSeconds);
+ return instance;
+ };
+
+ return UtcOffset;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Binary = (function() {
+
+ /**
+ * @classdesc
+ * Represents the BINARY value type, which contains extra methods for
+ * encoding and decoding.
+ *
+ * @class
+ * @alias ICAL.Binary
+ * @param {String} aValue The binary data for this value
+ */
+ function Binary(aValue) {
+ this.value = aValue;
+ }
+
+ Binary.prototype = {
+ /**
+ * The type name, to be used in the jCal object.
+ * @default "binary"
+ * @constant
+ */
+ icaltype: "binary",
+
+ /**
+ * Base64 decode the current value
+ *
+ * @return {String} The base64-decoded value
+ */
+ decodeValue: function decodeValue() {
+ return this._b64_decode(this.value);
+ },
+
+ /**
+ * Encodes the passed parameter with base64 and sets the internal
+ * value to the result.
+ *
+ * @param {String} aValue The raw binary value to encode
+ */
+ setEncodedValue: function setEncodedValue(aValue) {
+ this.value = this._b64_encode(aValue);
+ },
+
+ _b64_encode: function base64_encode(data) {
+ // http://kevin.vanzonneveld.net
+ // + original by: Tyler Akins (http://rumkin.com)
+ // + improved by: Bayron Guevara
+ // + improved by: Thunder.m
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + bugfixed by: Pellentesque Malesuada
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + improved by: Rafał Kukawski (http://kukawski.pl)
+ // * example 1: base64_encode('Kevin van Zonneveld');
+ // * returns 1: 'S2V2aW4gdmFuIFpvbm5ldmVsZA=='
+ // mozilla has this native
+ // - but breaks in 2.0.0.12!
+ //if (typeof this.window['atob'] == 'function') {
+ // return atob(data);
+ //}
+ var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
+ "abcdefghijklmnopqrstuvwxyz0123456789+/=";
+ var o1, o2, o3, h1, h2, h3, h4, bits, i = 0,
+ ac = 0,
+ enc = "",
+ tmp_arr = [];
+
+ if (!data) {
+ return data;
+ }
+
+ do { // pack three octets into four hexets
+ o1 = data.charCodeAt(i++);
+ o2 = data.charCodeAt(i++);
+ o3 = data.charCodeAt(i++);
+
+ bits = o1 << 16 | o2 << 8 | o3;
+
+ h1 = bits >> 18 & 0x3f;
+ h2 = bits >> 12 & 0x3f;
+ h3 = bits >> 6 & 0x3f;
+ h4 = bits & 0x3f;
+
+ // use hexets to index into b64, and append result to encoded string
+ tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4);
+ } while (i < data.length);
+
+ enc = tmp_arr.join('');
+
+ var r = data.length % 3;
+
+ return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3);
+
+ },
+
+ _b64_decode: function base64_decode(data) {
+ // http://kevin.vanzonneveld.net
+ // + original by: Tyler Akins (http://rumkin.com)
+ // + improved by: Thunder.m
+ // + input by: Aman Gupta
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + bugfixed by: Onno Marsman
+ // + bugfixed by: Pellentesque Malesuada
+ // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // + input by: Brett Zamir (http://brett-zamir.me)
+ // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
+ // * example 1: base64_decode('S2V2aW4gdmFuIFpvbm5ldmVsZA==');
+ // * returns 1: 'Kevin van Zonneveld'
+ // mozilla has this native
+ // - but breaks in 2.0.0.12!
+ //if (typeof this.window['btoa'] == 'function') {
+ // return btoa(data);
+ //}
+ var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
+ "abcdefghijklmnopqrstuvwxyz0123456789+/=";
+ var o1, o2, o3, h1, h2, h3, h4, bits, i = 0,
+ ac = 0,
+ dec = "",
+ tmp_arr = [];
+
+ if (!data) {
+ return data;
+ }
+
+ data += '';
+
+ do { // unpack four hexets into three octets using index points in b64
+ h1 = b64.indexOf(data.charAt(i++));
+ h2 = b64.indexOf(data.charAt(i++));
+ h3 = b64.indexOf(data.charAt(i++));
+ h4 = b64.indexOf(data.charAt(i++));
+
+ bits = h1 << 18 | h2 << 12 | h3 << 6 | h4;
+
+ o1 = bits >> 16 & 0xff;
+ o2 = bits >> 8 & 0xff;
+ o3 = bits & 0xff;
+
+ if (h3 == 64) {
+ tmp_arr[ac++] = String.fromCharCode(o1);
+ } else if (h4 == 64) {
+ tmp_arr[ac++] = String.fromCharCode(o1, o2);
+ } else {
+ tmp_arr[ac++] = String.fromCharCode(o1, o2, o3);
+ }
+ } while (i < data.length);
+
+ dec = tmp_arr.join('');
+
+ return dec;
+ },
+
+ /**
+ * The string representation of this value
+ * @return {String}
+ */
+ toString: function() {
+ return this.value;
+ }
+ };
+
+ /**
+ * Creates a binary value from the given string.
+ *
+ * @param {String} aString The binary value string
+ * @return {ICAL.Binary} The binary value instance
+ */
+ Binary.fromString = function(aString) {
+ return new Binary(aString);
+ };
+
+ return Binary;
+}());
+/* 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, 2011-2015 */
+
+
+
+(function() {
+ /**
+ * @classdesc
+ * This class represents the "period" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @description
+ * The passed data object cannot contain both and end date and a duration.
+ *
+ * @class
+ * @param {Object} aData An object with members of the period
+ * @param {ICAL.Time=} aData.start The start of the period
+ * @param {ICAL.Time=} aData.end The end of the period
+ * @param {ICAL.Duration=} aData.duration The duration of the period
+ */
+ ICAL.Period = function icalperiod(aData) {
+ this.wrappedJSObject = this;
+
+ if (aData && 'start' in aData) {
+ if (aData.start && !(aData.start instanceof ICAL.Time)) {
+ throw new TypeError('.start must be an instance of ICAL.Time');
+ }
+ this.start = aData.start;
+ }
+
+ if (aData && aData.end && aData.duration) {
+ throw new Error('cannot accept both end and duration');
+ }
+
+ if (aData && 'end' in aData) {
+ if (aData.end && !(aData.end instanceof ICAL.Time)) {
+ throw new TypeError('.end must be an instance of ICAL.Time');
+ }
+ this.end = aData.end;
+ }
+
+ if (aData && 'duration' in aData) {
+ if (aData.duration && !(aData.duration instanceof ICAL.Duration)) {
+ throw new TypeError('.duration must be an instance of ICAL.Duration');
+ }
+ this.duration = aData.duration;
+ }
+ };
+
+ ICAL.Period.prototype = {
+
+ /**
+ * The start of the period
+ * @type {ICAL.Time}
+ */
+ start: null,
+
+ /**
+ * The end of the period
+ * @type {ICAL.Time}
+ */
+ end: null,
+
+ /**
+ * The duration of the period
+ * @type {ICAL.Duration}
+ */
+ duration: null,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icalperiod"
+ */
+ icalclass: "icalperiod",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "period"
+ */
+ icaltype: "period",
+
+ /**
+ * Returns a clone of the duration object.
+ *
+ * @return {ICAL.Period} The cloned object
+ */
+ clone: function() {
+ return ICAL.Period.fromData({
+ start: this.start ? this.start.clone() : null,
+ end: this.end ? this.end.clone() : null,
+ duration: this.duration ? this.duration.clone() : null
+ });
+ },
+
+ /**
+ * Calculates the duration of the period, either directly or by subtracting
+ * start from end date.
+ *
+ * @return {ICAL.Duration} The calculated duration
+ */
+ getDuration: function duration() {
+ if (this.duration) {
+ return this.duration;
+ } else {
+ return this.end.subtractDate(this.start);
+ }
+ },
+
+ /**
+ * Calculates the end date of the period, either directly or by adding
+ * duration to start date.
+ *
+ * @return {ICAL.Time} The calculated end date
+ */
+ getEnd: function() {
+ if (this.end) {
+ return this.end;
+ } else {
+ var end = this.start.clone();
+ end.addDuration(this.duration);
+ return end;
+ }
+ },
+
+ /**
+ * The string representation of this period.
+ * @return {String}
+ */
+ toString: function toString() {
+ return this.start + "/" + (this.end || this.duration);
+ },
+
+ /**
+ * The jCal representation of this period type.
+ * @return {Object}
+ */
+ toJSON: function() {
+ return [this.start.toString(), (this.end || this.duration).toString()];
+ },
+
+ /**
+ * The iCalendar string representation of this period.
+ * @return {String}
+ */
+ toICALString: function() {
+ return this.start.toICALString() + "/" +
+ (this.end || this.duration).toICALString();
+ }
+ };
+
+ /**
+ * Creates a new {@link ICAL.Period} instance from the passed string.
+ *
+ * @param {String} str The string to parse
+ * @param {ICAL.Property} prop The property this period will be on
+ * @return {ICAL.Period} The created period instance
+ */
+ ICAL.Period.fromString = function fromString(str, prop) {
+ var parts = str.split('/');
+
+ if (parts.length !== 2) {
+ throw new Error(
+ 'Invalid string value: "' + str + '" must contain a "/" char.'
+ );
+ }
+
+ var options = {
+ start: ICAL.Time.fromDateTimeString(parts[0], prop)
+ };
+
+ var end = parts[1];
+
+ if (ICAL.Duration.isValueString(end)) {
+ options.duration = ICAL.Duration.fromString(end);
+ } else {
+ options.end = ICAL.Time.fromDateTimeString(end, prop);
+ }
+
+ return new ICAL.Period(options);
+ };
+
+ /**
+ * Creates a new {@link ICAL.Period} instance from the given data object.
+ * The passed data object cannot contain both and end date and a duration.
+ *
+ * @param {Object} aData An object with members of the period
+ * @param {ICAL.Time=} aData.start The start of the period
+ * @param {ICAL.Time=} aData.end The end of the period
+ * @param {ICAL.Duration=} aData.duration The duration of the period
+ * @return {ICAL.Period} The period instance
+ */
+ ICAL.Period.fromData = function fromData(aData) {
+ return new ICAL.Period(aData);
+ };
+
+ /**
+ * Returns a new period instance from the given jCal data array. The first
+ * member is always the start date string, the second member is either a
+ * duration or end date string.
+ *
+ * @param {Array<String,String>} aData The jCal data array
+ * @param {ICAL.Property} aProp The property this jCal data is on
+ * @param {Boolean} aLenient If true, data value can be both date and date-time
+ * @return {ICAL.Period} The period instance
+ */
+ ICAL.Period.fromJSON = function(aData, aProp, aLenient) {
+ function fromDateOrDateTimeString(aValue, aProp) {
+ if (aLenient) {
+ return ICAL.Time.fromString(aValue, aProp);
+ } else {
+ return ICAL.Time.fromDateTimeString(aValue, aProp);
+ }
+ }
+
+ if (ICAL.Duration.isValueString(aData[1])) {
+ return ICAL.Period.fromData({
+ start: fromDateOrDateTimeString(aData[0], aProp),
+ duration: ICAL.Duration.fromString(aData[1])
+ });
+ } else {
+ return ICAL.Period.fromData({
+ start: fromDateOrDateTimeString(aData[0], aProp),
+ end: fromDateOrDateTimeString(aData[1], aProp)
+ });
+ }
+ };
+})();
+/* 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, 2011-2015 */
+
+
+
+(function() {
+ var DURATION_LETTERS = /([PDWHMTS]{1,1})/;
+
+ /**
+ * @classdesc
+ * This class represents the "duration" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @class
+ * @alias ICAL.Duration
+ * @param {Object} data An object with members of the duration
+ * @param {Number} data.weeks Duration in weeks
+ * @param {Number} data.days Duration in days
+ * @param {Number} data.hours Duration in hours
+ * @param {Number} data.minutes Duration in minutes
+ * @param {Number} data.seconds Duration in seconds
+ * @param {Boolean} data.isNegative If true, the duration is negative
+ */
+ ICAL.Duration = function icalduration(data) {
+ this.wrappedJSObject = this;
+ this.fromData(data);
+ };
+
+ ICAL.Duration.prototype = {
+ /**
+ * The weeks in this duration
+ * @type {Number}
+ * @default 0
+ */
+ weeks: 0,
+
+ /**
+ * The days in this duration
+ * @type {Number}
+ * @default 0
+ */
+ days: 0,
+
+ /**
+ * The days in this duration
+ * @type {Number}
+ * @default 0
+ */
+ hours: 0,
+
+ /**
+ * The minutes in this duration
+ * @type {Number}
+ * @default 0
+ */
+ minutes: 0,
+
+ /**
+ * The seconds in this duration
+ * @type {Number}
+ * @default 0
+ */
+ seconds: 0,
+
+ /**
+ * The seconds in this duration
+ * @type {Boolean}
+ * @default false
+ */
+ isNegative: false,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icalduration"
+ */
+ icalclass: "icalduration",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "duration"
+ */
+ icaltype: "duration",
+
+ /**
+ * Returns a clone of the duration object.
+ *
+ * @return {ICAL.Duration} The cloned object
+ */
+ clone: function clone() {
+ return ICAL.Duration.fromData(this);
+ },
+
+ /**
+ * The duration value expressed as a number of seconds.
+ *
+ * @return {Number} The duration value in seconds
+ */
+ toSeconds: function toSeconds() {
+ var seconds = this.seconds + 60 * this.minutes + 3600 * this.hours +
+ 86400 * this.days + 7 * 86400 * this.weeks;
+ return (this.isNegative ? -seconds : seconds);
+ },
+
+ /**
+ * Reads the passed seconds value into this duration object. Afterwards,
+ * members like {@link ICAL.Duration#days days} and {@link ICAL.Duration#weeks weeks} will be set up
+ * accordingly.
+ *
+ * @param {Number} aSeconds The duration value in seconds
+ * @return {ICAL.Duration} Returns this instance
+ */
+ fromSeconds: function fromSeconds(aSeconds) {
+ var secs = Math.abs(aSeconds);
+
+ this.isNegative = (aSeconds < 0);
+ this.days = ICAL.helpers.trunc(secs / 86400);
+
+ // If we have a flat number of weeks, use them.
+ if (this.days % 7 == 0) {
+ this.weeks = this.days / 7;
+ this.days = 0;
+ } else {
+ this.weeks = 0;
+ }
+
+ secs -= (this.days + 7 * this.weeks) * 86400;
+
+ this.hours = ICAL.helpers.trunc(secs / 3600);
+ secs -= this.hours * 3600;
+
+ this.minutes = ICAL.helpers.trunc(secs / 60);
+ secs -= this.minutes * 60;
+
+ this.seconds = secs;
+ return this;
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} aData An object with members of the duration
+ * @param {Number} aData.weeks Duration in weeks
+ * @param {Number} aData.days Duration in days
+ * @param {Number} aData.hours Duration in hours
+ * @param {Number} aData.minutes Duration in minutes
+ * @param {Number} aData.seconds Duration in seconds
+ * @param {Boolean} aData.isNegative If true, the duration is negative
+ */
+ fromData: function fromData(aData) {
+ var propsToCopy = ["weeks", "days", "hours",
+ "minutes", "seconds", "isNegative"];
+ for (var key in propsToCopy) {
+ /* istanbul ignore if */
+ if (!propsToCopy.hasOwnProperty(key)) {
+ continue;
+ }
+ var prop = propsToCopy[key];
+ if (aData && prop in aData) {
+ this[prop] = aData[prop];
+ } else {
+ this[prop] = 0;
+ }
+ }
+ },
+
+ /**
+ * Resets the duration instance to the default values, i.e. PT0S
+ */
+ reset: function reset() {
+ this.isNegative = false;
+ this.weeks = 0;
+ this.days = 0;
+ this.hours = 0;
+ this.minutes = 0;
+ this.seconds = 0;
+ },
+
+ /**
+ * Compares the duration instance with another one.
+ *
+ * @param {ICAL.Duration} aOther The instance to compare with
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compare: function compare(aOther) {
+ var thisSeconds = this.toSeconds();
+ var otherSeconds = aOther.toSeconds();
+ return (thisSeconds > otherSeconds) - (thisSeconds < otherSeconds);
+ },
+
+ /**
+ * Normalizes the duration instance. For example, a duration with a value
+ * of 61 seconds will be normalized to 1 minute and 1 second.
+ */
+ normalize: function normalize() {
+ this.fromSeconds(this.toSeconds());
+ },
+
+ /**
+ * The string representation of this duration.
+ * @return {String}
+ */
+ toString: function toString() {
+ if (this.toSeconds() == 0) {
+ return "PT0S";
+ } else {
+ var str = "";
+ if (this.isNegative) str += "-";
+ str += "P";
+ if (this.weeks) str += this.weeks + "W";
+ if (this.days) str += this.days + "D";
+
+ if (this.hours || this.minutes || this.seconds) {
+ str += "T";
+ if (this.hours) str += this.hours + "H";
+ if (this.minutes) str += this.minutes + "M";
+ if (this.seconds) str += this.seconds + "S";
+ }
+ return str;
+ }
+ },
+
+ /**
+ * The iCalendar string representation of this duration.
+ * @return {String}
+ */
+ toICALString: function() {
+ return this.toString();
+ }
+ };
+
+ /**
+ * Returns a new ICAL.Duration instance from the passed seconds value.
+ *
+ * @param {Number} aSeconds The seconds to create the instance from
+ * @return {ICAL.Duration} The newly created duration instance
+ */
+ ICAL.Duration.fromSeconds = function icalduration_from_seconds(aSeconds) {
+ return (new ICAL.Duration()).fromSeconds(aSeconds);
+ };
+
+ /**
+ * Internal helper function to handle a chunk of a duration.
+ *
+ * @param {String} letter type of duration chunk
+ * @param {String} number numeric value or -/+
+ * @param {Object} dict target to assign values to
+ */
+ function parseDurationChunk(letter, number, object) {
+ var type;
+ switch (letter) {
+ case 'P':
+ if (number && number === '-') {
+ object.isNegative = true;
+ } else {
+ object.isNegative = false;
+ }
+ // period
+ break;
+ case 'D':
+ type = 'days';
+ break;
+ case 'W':
+ type = 'weeks';
+ break;
+ case 'H':
+ type = 'hours';
+ break;
+ case 'M':
+ type = 'minutes';
+ break;
+ case 'S':
+ type = 'seconds';
+ break;
+ default:
+ // Not a valid chunk
+ return 0;
+ }
+
+ if (type) {
+ if (!number && number !== 0) {
+ throw new Error(
+ 'invalid duration value: Missing number before "' + letter + '"'
+ );
+ }
+ var num = parseInt(number, 10);
+ if (ICAL.helpers.isStrictlyNaN(num)) {
+ throw new Error(
+ 'invalid duration value: Invalid number "' + number + '" before "' + letter + '"'
+ );
+ }
+ object[type] = num;
+ }
+
+ return 1;
+ }
+
+ /**
+ * Checks if the given string is an iCalendar duration value.
+ *
+ * @param {String} value The raw ical value
+ * @return {Boolean} True, if the given value is of the
+ * duration ical type
+ */
+ ICAL.Duration.isValueString = function(string) {
+ return (string[0] === 'P' || string[1] === 'P');
+ };
+
+ /**
+ * Creates a new {@link ICAL.Duration} instance from the passed string.
+ *
+ * @param {String} aStr The string to parse
+ * @return {ICAL.Duration} The created duration instance
+ */
+ ICAL.Duration.fromString = function icalduration_from_string(aStr) {
+ var pos = 0;
+ var dict = Object.create(null);
+ var chunks = 0;
+
+ while ((pos = aStr.search(DURATION_LETTERS)) !== -1) {
+ var type = aStr[pos];
+ var numeric = aStr.substr(0, pos);
+ aStr = aStr.substr(pos + 1);
+
+ chunks += parseDurationChunk(type, numeric, dict);
+ }
+
+ if (chunks < 2) {
+ // There must be at least a chunk with "P" and some unit chunk
+ throw new Error(
+ 'invalid duration value: Not enough duration components in "' + aStr + '"'
+ );
+ }
+
+ return new ICAL.Duration(dict);
+ };
+
+ /**
+ * Creates a new ICAL.Duration instance from the given data object.
+ *
+ * @param {Object} aData An object with members of the duration
+ * @param {Number} aData.weeks Duration in weeks
+ * @param {Number} aData.days Duration in days
+ * @param {Number} aData.hours Duration in hours
+ * @param {Number} aData.minutes Duration in minutes
+ * @param {Number} aData.seconds Duration in seconds
+ * @param {Boolean} aData.isNegative If true, the duration is negative
+ * @return {ICAL.Duration} The createad duration instance
+ */
+ ICAL.Duration.fromData = function icalduration_from_data(aData) {
+ return new ICAL.Duration(aData);
+ };
+})();
+/* 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, 2011-2012 */
+
+
+
+(function() {
+ var OPTIONS = ["tzid", "location", "tznames",
+ "latitude", "longitude"];
+
+ /**
+ * @classdesc
+ * Timezone representation, created by passing in a tzid and component.
+ *
+ * @example
+ * var vcalendar;
+ * var timezoneComp = vcalendar.getFirstSubcomponent('vtimezone');
+ * var tzid = timezoneComp.getFirstPropertyValue('tzid');
+ *
+ * var timezone = new ICAL.Timezone({
+ * component: timezoneComp,
+ * tzid
+ * });
+ *
+ * @class
+ * @param {ICAL.Component|Object} data options for class
+ * @param {String|ICAL.Component} data.component
+ * If data is a simple object, then this member can be set to either a
+ * string containing the component data, or an already parsed
+ * ICAL.Component
+ * @param {String} data.tzid The timezone identifier
+ * @param {String} data.location The timezone locationw
+ * @param {String} data.tznames An alternative string representation of the
+ * timezone
+ * @param {Number} data.latitude The latitude of the timezone
+ * @param {Number} data.longitude The longitude of the timezone
+ */
+ ICAL.Timezone = function icaltimezone(data) {
+ this.wrappedJSObject = this;
+ this.fromData(data);
+ };
+
+ ICAL.Timezone.prototype = {
+
+ /**
+ * Timezone identifier
+ * @type {String}
+ */
+ tzid: "",
+
+ /**
+ * Timezone location
+ * @type {String}
+ */
+ location: "",
+
+ /**
+ * Alternative timezone name, for the string representation
+ * @type {String}
+ */
+ tznames: "",
+
+ /**
+ * The primary latitude for the timezone.
+ * @type {Number}
+ */
+ latitude: 0.0,
+
+ /**
+ * The primary longitude for the timezone.
+ * @type {Number}
+ */
+ longitude: 0.0,
+
+ /**
+ * The vtimezone component for this timezone.
+ * @type {ICAL.Component}
+ */
+ component: null,
+
+ /**
+ * The year this timezone has been expanded to. All timezone transition
+ * dates until this year are known and can be used for calculation
+ *
+ * @private
+ * @type {Number}
+ */
+ expandedUntilYear: 0,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icaltimezone"
+ */
+ icalclass: "icaltimezone",
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {ICAL.Component|Object} aData options for class
+ * @param {String|ICAL.Component} aData.component
+ * If aData is a simple object, then this member can be set to either a
+ * string containing the component data, or an already parsed
+ * ICAL.Component
+ * @param {String} aData.tzid The timezone identifier
+ * @param {String} aData.location The timezone locationw
+ * @param {String} aData.tznames An alternative string representation of the
+ * timezone
+ * @param {Number} aData.latitude The latitude of the timezone
+ * @param {Number} aData.longitude The longitude of the timezone
+ */
+ fromData: function fromData(aData) {
+ this.expandedUntilYear = 0;
+ this.changes = [];
+
+ if (aData instanceof ICAL.Component) {
+ // Either a component is passed directly
+ this.component = aData;
+ } else {
+ // Otherwise the component may be in the data object
+ if (aData && "component" in aData) {
+ if (typeof aData.component == "string") {
+ // If a string was passed, parse it as a component
+ var jCal = ICAL.parse(aData.component);
+ this.component = new ICAL.Component(jCal);
+ } else if (aData.component instanceof ICAL.Component) {
+ // If it was a component already, then just set it
+ this.component = aData.component;
+ } else {
+ // Otherwise just null out the component
+ this.component = null;
+ }
+ }
+
+ // Copy remaining passed properties
+ for (var key in OPTIONS) {
+ /* istanbul ignore else */
+ if (OPTIONS.hasOwnProperty(key)) {
+ var prop = OPTIONS[key];
+ if (aData && prop in aData) {
+ this[prop] = aData[prop];
+ }
+ }
+ }
+ }
+
+ // If we have a component but no TZID, attempt to get it from the
+ // component's properties.
+ if (this.component instanceof ICAL.Component && !this.tzid) {
+ this.tzid = this.component.getFirstPropertyValue('tzid');
+ }
+
+ return this;
+ },
+
+ /**
+ * Finds the utcOffset the given time would occur in this timezone.
+ *
+ * @param {ICAL.Time} tt The time to check for
+ * @return {Number} utc offset in seconds
+ */
+ utcOffset: function utcOffset(tt) {
+ if (this == ICAL.Timezone.utcTimezone || this == ICAL.Timezone.localTimezone) {
+ return 0;
+ }
+
+ this._ensureCoverage(tt.year);
+
+ if (!this.changes.length) {
+ return 0;
+ }
+
+ var tt_change = {
+ year: tt.year,
+ month: tt.month,
+ day: tt.day,
+ hour: tt.hour,
+ minute: tt.minute,
+ second: tt.second
+ };
+
+ var change_num = this._findNearbyChange(tt_change);
+ var change_num_to_use = -1;
+ var step = 1;
+
+ // TODO: replace with bin search?
+ for (;;) {
+ var change = ICAL.helpers.clone(this.changes[change_num], true);
+ if (change.utcOffset < change.prevUtcOffset) {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0, change.utcOffset);
+ } else {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ change.prevUtcOffset);
+ }
+
+ var cmp = ICAL.Timezone._compare_change_fn(tt_change, change);
+
+ if (cmp >= 0) {
+ change_num_to_use = change_num;
+ } else {
+ step = -1;
+ }
+
+ if (step == -1 && change_num_to_use != -1) {
+ break;
+ }
+
+ change_num += step;
+
+ if (change_num < 0) {
+ return 0;
+ }
+
+ if (change_num >= this.changes.length) {
+ break;
+ }
+ }
+
+ var zone_change = this.changes[change_num_to_use];
+ var utcOffset_change = zone_change.utcOffset - zone_change.prevUtcOffset;
+
+ if (utcOffset_change < 0 && change_num_to_use > 0) {
+ var tmp_change = ICAL.helpers.clone(zone_change, true);
+ ICAL.Timezone.adjust_change(tmp_change, 0, 0, 0,
+ tmp_change.prevUtcOffset);
+
+ if (ICAL.Timezone._compare_change_fn(tt_change, tmp_change) < 0) {
+ var prev_zone_change = this.changes[change_num_to_use - 1];
+
+ var want_daylight = false; // TODO
+
+ if (zone_change.is_daylight != want_daylight &&
+ prev_zone_change.is_daylight == want_daylight) {
+ zone_change = prev_zone_change;
+ }
+ }
+ }
+
+ // TODO return is_daylight?
+ return zone_change.utcOffset;
+ },
+
+ _findNearbyChange: function icaltimezone_find_nearby_change(change) {
+ // find the closest match
+ var idx = ICAL.helpers.binsearchInsert(
+ this.changes,
+ change,
+ ICAL.Timezone._compare_change_fn
+ );
+
+ if (idx >= this.changes.length) {
+ return this.changes.length - 1;
+ }
+
+ return idx;
+ },
+
+ _ensureCoverage: function(aYear) {
+ if (ICAL.Timezone._minimumExpansionYear == -1) {
+ var today = ICAL.Time.now();
+ ICAL.Timezone._minimumExpansionYear = today.year;
+ }
+
+ var changesEndYear = aYear;
+ if (changesEndYear < ICAL.Timezone._minimumExpansionYear) {
+ changesEndYear = ICAL.Timezone._minimumExpansionYear;
+ }
+
+ changesEndYear += ICAL.Timezone.EXTRA_COVERAGE;
+
+ if (!this.changes.length || this.expandedUntilYear < aYear) {
+ var subcomps = this.component.getAllSubcomponents();
+ var compLen = subcomps.length;
+ var compIdx = 0;
+
+ for (; compIdx < compLen; compIdx++) {
+ this._expandComponent(
+ subcomps[compIdx], changesEndYear, this.changes
+ );
+ }
+
+ this.changes.sort(ICAL.Timezone._compare_change_fn);
+ this.expandedUntilYear = changesEndYear;
+ }
+ },
+
+ _expandComponent: function(aComponent, aYear, changes) {
+ if (!aComponent.hasProperty("dtstart") ||
+ !aComponent.hasProperty("tzoffsetto") ||
+ !aComponent.hasProperty("tzoffsetfrom")) {
+ return null;
+ }
+
+ var dtstart = aComponent.getFirstProperty("dtstart").getFirstValue();
+ var change;
+
+ function convert_tzoffset(offset) {
+ return offset.factor * (offset.hours * 3600 + offset.minutes * 60);
+ }
+
+ function init_changes() {
+ var changebase = {};
+ changebase.is_daylight = (aComponent.name == "daylight");
+ changebase.utcOffset = convert_tzoffset(
+ aComponent.getFirstProperty("tzoffsetto").getFirstValue()
+ );
+
+ changebase.prevUtcOffset = convert_tzoffset(
+ aComponent.getFirstProperty("tzoffsetfrom").getFirstValue()
+ );
+
+ return changebase;
+ }
+
+ if (!aComponent.hasProperty("rrule") && !aComponent.hasProperty("rdate")) {
+ change = init_changes();
+ change.year = dtstart.year;
+ change.month = dtstart.month;
+ change.day = dtstart.day;
+ change.hour = dtstart.hour;
+ change.minute = dtstart.minute;
+ change.second = dtstart.second;
+
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ changes.push(change);
+ } else {
+ var props = aComponent.getAllProperties("rdate");
+ for (var rdatekey in props) {
+ /* istanbul ignore if */
+ if (!props.hasOwnProperty(rdatekey)) {
+ continue;
+ }
+ var rdate = props[rdatekey];
+ var time = rdate.getFirstValue();
+ change = init_changes();
+
+ change.year = time.year;
+ change.month = time.month;
+ change.day = time.day;
+
+ if (time.isDate) {
+ change.hour = dtstart.hour;
+ change.minute = dtstart.minute;
+ change.second = dtstart.second;
+
+ if (dtstart.zone != ICAL.Timezone.utcTimezone) {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ }
+ } else {
+ change.hour = time.hour;
+ change.minute = time.minute;
+ change.second = time.second;
+
+ if (time.zone != ICAL.Timezone.utcTimezone) {
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ }
+ }
+
+ changes.push(change);
+ }
+
+ var rrule = aComponent.getFirstProperty("rrule");
+
+ if (rrule) {
+ rrule = rrule.getFirstValue();
+ change = init_changes();
+
+ if (rrule.until && rrule.until.zone == ICAL.Timezone.utcTimezone) {
+ rrule.until.adjust(0, 0, 0, change.prevUtcOffset);
+ rrule.until.zone = ICAL.Timezone.localTimezone;
+ }
+
+ var iterator = rrule.iterator(dtstart);
+
+ var occ;
+ while ((occ = iterator.next())) {
+ change = init_changes();
+ if (occ.year > aYear || !occ) {
+ break;
+ }
+
+ change.year = occ.year;
+ change.month = occ.month;
+ change.day = occ.day;
+ change.hour = occ.hour;
+ change.minute = occ.minute;
+ change.second = occ.second;
+ change.isDate = occ.isDate;
+
+ ICAL.Timezone.adjust_change(change, 0, 0, 0,
+ -change.prevUtcOffset);
+ changes.push(change);
+ }
+ }
+ }
+
+ return changes;
+ },
+
+ /**
+ * The string representation of this timezone.
+ * @return {String}
+ */
+ toString: function toString() {
+ return (this.tznames ? this.tznames : this.tzid);
+ }
+ };
+
+ ICAL.Timezone._compare_change_fn = function icaltimezone_compare_change_fn(a, b) {
+ if (a.year < b.year) return -1;
+ else if (a.year > b.year) return 1;
+
+ if (a.month < b.month) return -1;
+ else if (a.month > b.month) return 1;
+
+ if (a.day < b.day) return -1;
+ else if (a.day > b.day) return 1;
+
+ if (a.hour < b.hour) return -1;
+ else if (a.hour > b.hour) return 1;
+
+ if (a.minute < b.minute) return -1;
+ else if (a.minute > b.minute) return 1;
+
+ if (a.second < b.second) return -1;
+ else if (a.second > b.second) return 1;
+
+ return 0;
+ };
+
+ /**
+ * Convert the date/time from one zone to the next.
+ *
+ * @param {ICAL.Time} tt The time to convert
+ * @param {ICAL.Timezone} from_zone The source zone to convert from
+ * @param {ICAL.Timezone} to_zone The target zone to convert to
+ * @return {ICAL.Time} The converted date/time object
+ */
+ ICAL.Timezone.convert_time = function icaltimezone_convert_time(tt, from_zone, to_zone) {
+ if (tt.isDate ||
+ from_zone.tzid == to_zone.tzid ||
+ from_zone == ICAL.Timezone.localTimezone ||
+ to_zone == ICAL.Timezone.localTimezone) {
+ tt.zone = to_zone;
+ return tt;
+ }
+
+ var utcOffset = from_zone.utcOffset(tt);
+ tt.adjust(0, 0, 0, - utcOffset);
+
+ utcOffset = to_zone.utcOffset(tt);
+ tt.adjust(0, 0, 0, utcOffset);
+
+ return null;
+ };
+
+ /**
+ * Creates a new ICAL.Timezone instance from the passed data object.
+ *
+ * @param {ICAL.Component|Object} aData options for class
+ * @param {String|ICAL.Component} aData.component
+ * If aData is a simple object, then this member can be set to either a
+ * string containing the component data, or an already parsed
+ * ICAL.Component
+ * @param {String} aData.tzid The timezone identifier
+ * @param {String} aData.location The timezone locationw
+ * @param {String} aData.tznames An alternative string representation of the
+ * timezone
+ * @param {Number} aData.latitude The latitude of the timezone
+ * @param {Number} aData.longitude The longitude of the timezone
+ */
+ ICAL.Timezone.fromData = function icaltimezone_fromData(aData) {
+ var tt = new ICAL.Timezone();
+ return tt.fromData(aData);
+ };
+
+ /**
+ * The instance describing the UTC timezone
+ * @type {ICAL.Timezone}
+ * @constant
+ * @instance
+ */
+ ICAL.Timezone.utcTimezone = ICAL.Timezone.fromData({
+ tzid: "UTC"
+ });
+
+ /**
+ * The instance describing the local timezone
+ * @type {ICAL.Timezone}
+ * @constant
+ * @instance
+ */
+ ICAL.Timezone.localTimezone = ICAL.Timezone.fromData({
+ tzid: "floating"
+ });
+
+ /**
+ * Adjust a timezone change object.
+ * @private
+ * @param {Object} change The timezone change object
+ * @param {Number} days The extra amount of days
+ * @param {Number} hours The extra amount of hours
+ * @param {Number} minutes The extra amount of minutes
+ * @param {Number} seconds The extra amount of seconds
+ */
+ ICAL.Timezone.adjust_change = function icaltimezone_adjust_change(change, days, hours, minutes, seconds) {
+ return ICAL.Time.prototype.adjust.call(
+ change,
+ days,
+ hours,
+ minutes,
+ seconds,
+ change
+ );
+ };
+
+ ICAL.Timezone._minimumExpansionYear = -1;
+ ICAL.Timezone.EXTRA_COVERAGE = 5;
+})();
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.TimezoneService = (function() {
+ var zones;
+
+ /**
+ * @classdesc
+ * Singleton class to contain timezones. Right now it is all manual registry in
+ * the future we may use this class to download timezone information or handle
+ * loading pre-expanded timezones.
+ *
+ * @namespace
+ * @alias ICAL.TimezoneService
+ */
+ var TimezoneService = {
+ get count() {
+ return Object.keys(zones).length;
+ },
+
+ reset: function() {
+ zones = Object.create(null);
+ var utc = ICAL.Timezone.utcTimezone;
+
+ zones.Z = utc;
+ zones.UTC = utc;
+ zones.GMT = utc;
+ },
+
+ /**
+ * Checks if timezone id has been registered.
+ *
+ * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles)
+ * @return {Boolean} False, when not present
+ */
+ has: function(tzid) {
+ return !!zones[tzid];
+ },
+
+ /**
+ * Returns a timezone by its tzid if present.
+ *
+ * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles)
+ * @return {?ICAL.Timezone} The timezone, or null if not found
+ */
+ get: function(tzid) {
+ return zones[tzid];
+ },
+
+ /**
+ * Registers a timezone object or component.
+ *
+ * @param {String=} name
+ * The name of the timezone. Defaults to the component's TZID if not
+ * passed.
+ * @param {ICAL.Component|ICAL.Timezone} zone
+ * The initialized zone or vtimezone.
+ */
+ register: function(name, timezone) {
+ if (name instanceof ICAL.Component) {
+ if (name.name === 'vtimezone') {
+ timezone = new ICAL.Timezone(name);
+ name = timezone.tzid;
+ }
+ }
+
+ if (timezone instanceof ICAL.Timezone) {
+ zones[name] = timezone;
+ } else {
+ throw new TypeError('timezone must be ICAL.Timezone or ICAL.Component');
+ }
+ },
+
+ /**
+ * Removes a timezone by its tzid from the list.
+ *
+ * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles)
+ * @return {?ICAL.Timezone} The removed timezone, or null if not registered
+ */
+ remove: function(tzid) {
+ return (delete zones[tzid]);
+ }
+ };
+
+ // initialize defaults
+ TimezoneService.reset();
+
+ return TimezoneService;
+}());
+/* 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, 2011-2015 */
+
+
+
+(function() {
+
+ /**
+ * @classdesc
+ * iCalendar Time representation (similar to JS Date object). Fully
+ * independent of system (OS) timezone / time. Unlike JS Date, the month
+ * January is 1, not zero.
+ *
+ * @example
+ * var time = new ICAL.Time({
+ * year: 2012,
+ * month: 10,
+ * day: 11
+ * minute: 0,
+ * second: 0,
+ * isDate: false
+ * });
+ *
+ *
+ * @alias ICAL.Time
+ * @class
+ * @param {Object} data Time initialization
+ * @param {Number=} data.year The year for this date
+ * @param {Number=} data.month The month for this date
+ * @param {Number=} data.day The day for this date
+ * @param {Number=} data.hour The hour for this date
+ * @param {Number=} data.minute The minute for this date
+ * @param {Number=} data.second The second for this date
+ * @param {Boolean=} data.isDate If true, the instance represents a date (as
+ * opposed to a date-time)
+ * @param {ICAL.Timezone} zone timezone this position occurs in
+ */
+ ICAL.Time = function icaltime(data, zone) {
+ this.wrappedJSObject = this;
+ var time = this._time = Object.create(null);
+
+ /* time defaults */
+ time.year = 0;
+ time.month = 1;
+ time.day = 1;
+ time.hour = 0;
+ time.minute = 0;
+ time.second = 0;
+ time.isDate = false;
+
+ this.fromData(data, zone);
+ };
+
+ ICAL.Time._dowCache = {};
+ ICAL.Time._wnCache = {};
+
+ ICAL.Time.prototype = {
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icaltime"
+ */
+ icalclass: "icaltime",
+ _cachedUnixTime: null,
+
+ /**
+ * The type name, to be used in the jCal object. This value may change and
+ * is strictly defined by the {@link ICAL.Time#isDate isDate} member.
+ * @readonly
+ * @type {String}
+ * @default "date-time"
+ */
+ get icaltype() {
+ return this.isDate ? 'date' : 'date-time';
+ },
+
+ /**
+ * The timezone for this time.
+ * @type {ICAL.Timezone}
+ */
+ zone: null,
+
+ /**
+ * Internal uses to indicate that a change has been made and the next read
+ * operation must attempt to normalize the value (for example changing the
+ * day to 33).
+ *
+ * @type {Boolean}
+ * @private
+ */
+ _pendingNormalization: false,
+
+ /**
+ * Returns a clone of the time object.
+ *
+ * @return {ICAL.Time} The cloned object
+ */
+ clone: function() {
+ return new ICAL.Time(this._time, this.zone);
+ },
+
+ /**
+ * Reset the time instance to epoch time
+ */
+ reset: function icaltime_reset() {
+ this.fromData(ICAL.Time.epochTime);
+ this.zone = ICAL.Timezone.utcTimezone;
+ },
+
+ /**
+ * Reset the time instance to the given date/time values.
+ *
+ * @param {Number} year The year to set
+ * @param {Number} month The month to set
+ * @param {Number} day The day to set
+ * @param {Number} hour The hour to set
+ * @param {Number} minute The minute to set
+ * @param {Number} second The second to set
+ * @param {ICAL.Timezone} timezone The timezone to set
+ */
+ resetTo: function icaltime_resetTo(year, month, day,
+ hour, minute, second, timezone) {
+ this.fromData({
+ year: year,
+ month: month,
+ day: day,
+ hour: hour,
+ minute: minute,
+ second: second,
+ zone: timezone
+ });
+ },
+
+ /**
+ * Set up the current instance from the Javascript date value.
+ *
+ * @param {?Date} aDate The Javascript Date to read, or null to reset
+ * @param {Boolean} useUTC If true, the UTC values of the date will be used
+ */
+ fromJSDate: function icaltime_fromJSDate(aDate, useUTC) {
+ if (!aDate) {
+ this.reset();
+ } else {
+ if (useUTC) {
+ this.zone = ICAL.Timezone.utcTimezone;
+ this.year = aDate.getUTCFullYear();
+ this.month = aDate.getUTCMonth() + 1;
+ this.day = aDate.getUTCDate();
+ this.hour = aDate.getUTCHours();
+ this.minute = aDate.getUTCMinutes();
+ this.second = aDate.getUTCSeconds();
+ } else {
+ this.zone = ICAL.Timezone.localTimezone;
+ this.year = aDate.getFullYear();
+ this.month = aDate.getMonth() + 1;
+ this.day = aDate.getDate();
+ this.hour = aDate.getHours();
+ this.minute = aDate.getMinutes();
+ this.second = aDate.getSeconds();
+ }
+ }
+ this._cachedUnixTime = null;
+ return this;
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} aData Time initialization
+ * @param {Number=} aData.year The year for this date
+ * @param {Number=} aData.month The month for this date
+ * @param {Number=} aData.day The day for this date
+ * @param {Number=} aData.hour The hour for this date
+ * @param {Number=} aData.minute The minute for this date
+ * @param {Number=} aData.second The second for this date
+ * @param {Boolean=} aData.isDate If true, the instance represents a date
+ * (as opposed to a date-time)
+ * @param {ICAL.Timezone=} aZone Timezone this position occurs in
+ */
+ fromData: function fromData(aData, aZone) {
+ if (aData) {
+ for (var key in aData) {
+ /* istanbul ignore else */
+ if (Object.prototype.hasOwnProperty.call(aData, key)) {
+ // ical type cannot be set
+ if (key === 'icaltype') continue;
+ this[key] = aData[key];
+ }
+ }
+ }
+
+ if (aZone) {
+ this.zone = aZone;
+ }
+
+ if (aData && !("isDate" in aData)) {
+ this.isDate = !("hour" in aData);
+ } else if (aData && ("isDate" in aData)) {
+ this.isDate = aData.isDate;
+ }
+
+ if (aData && "timezone" in aData) {
+ var zone = ICAL.TimezoneService.get(
+ aData.timezone
+ );
+
+ this.zone = zone || ICAL.Timezone.localTimezone;
+ }
+
+ if (aData && "zone" in aData) {
+ this.zone = aData.zone;
+ }
+
+ if (!this.zone) {
+ this.zone = ICAL.Timezone.localTimezone;
+ }
+
+ this._cachedUnixTime = null;
+ return this;
+ },
+
+ /**
+ * Calculate the day of week.
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {ICAL.Time.weekDay}
+ */
+ dayOfWeek: function icaltime_dayOfWeek(aWeekStart) {
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var dowCacheKey = (this.year << 12) + (this.month << 8) + (this.day << 3) + firstDow;
+ if (dowCacheKey in ICAL.Time._dowCache) {
+ return ICAL.Time._dowCache[dowCacheKey];
+ }
+
+ // Using Zeller's algorithm
+ var q = this.day;
+ var m = this.month + (this.month < 3 ? 12 : 0);
+ var Y = this.year - (this.month < 3 ? 1 : 0);
+
+ var h = (q + Y + ICAL.helpers.trunc(((m + 1) * 26) / 10) + ICAL.helpers.trunc(Y / 4));
+ /* istanbul ignore else */
+ if (true /* gregorian */) {
+ h += ICAL.helpers.trunc(Y / 100) * 6 + ICAL.helpers.trunc(Y / 400);
+ } else {
+ h += 5;
+ }
+
+ // Normalize to 1 = wkst
+ h = ((h + 7 - firstDow) % 7) + 1;
+ ICAL.Time._dowCache[dowCacheKey] = h;
+ return h;
+ },
+
+ /**
+ * Calculate the day of year.
+ * @return {Number}
+ */
+ dayOfYear: function dayOfYear() {
+ var is_leap = (ICAL.Time.isLeapYear(this.year) ? 1 : 0);
+ var diypm = ICAL.Time.daysInYearPassedMonth;
+ return diypm[is_leap][this.month - 1] + this.day;
+ },
+
+ /**
+ * Returns a copy of the current date/time, rewound to the start of the
+ * week. The resulting ICAL.Time instance is of icaltype date, even if this
+ * is a date-time.
+ *
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {ICAL.Time} The start of the week (cloned)
+ */
+ startOfWeek: function startOfWeek(aWeekStart) {
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var result = this.clone();
+ result.day -= ((this.dayOfWeek() + 7 - firstDow) % 7);
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, shifted to the end of the week.
+ * The resulting ICAL.Time instance is of icaltype date, even if this is a
+ * date-time.
+ *
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {ICAL.Time} The end of the week (cloned)
+ */
+ endOfWeek: function endOfWeek(aWeekStart) {
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var result = this.clone();
+ result.day += (7 - this.dayOfWeek() + firstDow - ICAL.Time.SUNDAY) % 7;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, rewound to the start of the
+ * month. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The start of the month (cloned)
+ */
+ startOfMonth: function startOfMonth() {
+ var result = this.clone();
+ result.day = 1;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, shifted to the end of the
+ * month. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The end of the month (cloned)
+ */
+ endOfMonth: function endOfMonth() {
+ var result = this.clone();
+ result.day = ICAL.Time.daysInMonth(result.month, result.year);
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, rewound to the start of the
+ * year. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The start of the year (cloned)
+ */
+ startOfYear: function startOfYear() {
+ var result = this.clone();
+ result.day = 1;
+ result.month = 1;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * Returns a copy of the current date/time, shifted to the end of the
+ * year. The resulting ICAL.Time instance is of icaltype date, even if
+ * this is a date-time.
+ *
+ * @return {ICAL.Time} The end of the year (cloned)
+ */
+ endOfYear: function endOfYear() {
+ var result = this.clone();
+ result.day = 31;
+ result.month = 12;
+ result.isDate = true;
+ result.hour = 0;
+ result.minute = 0;
+ result.second = 0;
+ return result;
+ },
+
+ /**
+ * First calculates the start of the week, then returns the day of year for
+ * this date. If the day falls into the previous year, the day is zero or negative.
+ *
+ * @param {ICAL.Time.weekDay=} aFirstDayOfWeek
+ * The week start weekday, defaults to SUNDAY
+ * @return {Number} The calculated day of year
+ */
+ startDoyWeek: function startDoyWeek(aFirstDayOfWeek) {
+ var firstDow = aFirstDayOfWeek || ICAL.Time.SUNDAY;
+ var delta = this.dayOfWeek() - firstDow;
+ if (delta < 0) delta += 7;
+ return this.dayOfYear() - delta;
+ },
+
+ /**
+ * Get the dominical letter for the current year. Letters range from A - G
+ * for common years, and AG to GF for leap years.
+ *
+ * @param {Number} yr The year to retrieve the letter for
+ * @return {String} The dominical letter.
+ */
+ getDominicalLetter: function() {
+ return ICAL.Time.getDominicalLetter(this.year);
+ },
+
+ /**
+ * Finds the nthWeekDay relative to the current month (not day). The
+ * returned value is a day relative the month that this month belongs to so
+ * 1 would indicate the first of the month and 40 would indicate a day in
+ * the following month.
+ *
+ * @param {Number} aDayOfWeek Day of the week see the day name constants
+ * @param {Number} aPos Nth occurrence of a given week day values
+ * of 1 and 0 both indicate the first weekday of that type. aPos may
+ * be either positive or negative
+ *
+ * @return {Number} numeric value indicating a day relative
+ * to the current month of this time object
+ */
+ nthWeekDay: function icaltime_nthWeekDay(aDayOfWeek, aPos) {
+ var daysInMonth = ICAL.Time.daysInMonth(this.month, this.year);
+ var weekday;
+ var pos = aPos;
+
+ var start = 0;
+
+ var otherDay = this.clone();
+
+ if (pos >= 0) {
+ otherDay.day = 1;
+
+ // because 0 means no position has been given
+ // 1 and 0 indicate the same day.
+ if (pos != 0) {
+ // remove the extra numeric value
+ pos--;
+ }
+
+ // set current start offset to current day.
+ start = otherDay.day;
+
+ // find the current day of week
+ var startDow = otherDay.dayOfWeek();
+
+ // calculate the difference between current
+ // day of the week and desired day of the week
+ var offset = aDayOfWeek - startDow;
+
+
+ // if the offset goes into the past
+ // week we add 7 so it goes into the next
+ // week. We only want to go forward in time here.
+ if (offset < 0)
+ // this is really important otherwise we would
+ // end up with dates from in the past.
+ offset += 7;
+
+ // add offset to start so start is the same
+ // day of the week as the desired day of week.
+ start += offset;
+
+ // because we are going to add (and multiply)
+ // the numeric value of the day we subtract it
+ // from the start position so not to add it twice.
+ start -= aDayOfWeek;
+
+ // set week day
+ weekday = aDayOfWeek;
+ } else {
+
+ // then we set it to the last day in the current month
+ otherDay.day = daysInMonth;
+
+ // find the ends weekday
+ var endDow = otherDay.dayOfWeek();
+
+ pos++;
+
+ weekday = (endDow - aDayOfWeek);
+
+ if (weekday < 0) {
+ weekday += 7;
+ }
+
+ weekday = daysInMonth - weekday;
+ }
+
+ weekday += pos * 7;
+
+ return start + weekday;
+ },
+
+ /**
+ * Checks if current time is the nth weekday, relative to the current
+ * month. Will always return false when rule resolves outside of current
+ * month.
+ *
+ * @param {ICAL.Time.weekDay} aDayOfWeek Day of week to check
+ * @param {Number} aPos Relative position
+ * @return {Boolean} True, if it is the nth weekday
+ */
+ isNthWeekDay: function(aDayOfWeek, aPos) {
+ var dow = this.dayOfWeek();
+
+ if (aPos === 0 && dow === aDayOfWeek) {
+ return true;
+ }
+
+ // get pos
+ var day = this.nthWeekDay(aDayOfWeek, aPos);
+
+ if (day === this.day) {
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Calculates the ISO 8601 week number. The first week of a year is the
+ * week that contains the first Thursday. The year can have 53 weeks, if
+ * January 1st is a Friday.
+ *
+ * Note there are regions where the first week of the year is the one that
+ * starts on January 1st, which may offset the week number. Also, if a
+ * different week start is specified, this will also affect the week
+ * number.
+ *
+ * @see ICAL.Time.weekOneStarts
+ * @param {ICAL.Time.weekDay} aWeekStart The weekday the week starts with
+ * @return {Number} The ISO week number
+ */
+ weekNumber: function weekNumber(aWeekStart) {
+ var wnCacheKey = (this.year << 12) + (this.month << 8) + (this.day << 3) + aWeekStart;
+ if (wnCacheKey in ICAL.Time._wnCache) {
+ return ICAL.Time._wnCache[wnCacheKey];
+ }
+ // This function courtesty of Julian Bucknall, published under the MIT license
+ // http://www.boyet.com/articles/publishedarticles/calculatingtheisoweeknumb.html
+ // plus some fixes to be able to use different week starts.
+ var week1;
+
+ var dt = this.clone();
+ dt.isDate = true;
+ var isoyear = this.year;
+
+ if (dt.month == 12 && dt.day > 25) {
+ week1 = ICAL.Time.weekOneStarts(isoyear + 1, aWeekStart);
+ if (dt.compare(week1) < 0) {
+ week1 = ICAL.Time.weekOneStarts(isoyear, aWeekStart);
+ } else {
+ isoyear++;
+ }
+ } else {
+ week1 = ICAL.Time.weekOneStarts(isoyear, aWeekStart);
+ if (dt.compare(week1) < 0) {
+ week1 = ICAL.Time.weekOneStarts(--isoyear, aWeekStart);
+ }
+ }
+
+ var daysBetween = (dt.subtractDate(week1).toSeconds() / 86400);
+ var answer = ICAL.helpers.trunc(daysBetween / 7) + 1;
+ ICAL.Time._wnCache[wnCacheKey] = answer;
+ return answer;
+ },
+
+ /**
+ * Adds the duration to the current time. The instance is modified in
+ * place.
+ *
+ * @param {ICAL.Duration} aDuration The duration to add
+ */
+ addDuration: function icaltime_add(aDuration) {
+ var mult = (aDuration.isNegative ? -1 : 1);
+
+ // because of the duration optimizations it is much
+ // more efficient to grab all the values up front
+ // then set them directly (which will avoid a normalization call).
+ // So we don't actually normalize until we need it.
+ var second = this.second;
+ var minute = this.minute;
+ var hour = this.hour;
+ var day = this.day;
+
+ second += mult * aDuration.seconds;
+ minute += mult * aDuration.minutes;
+ hour += mult * aDuration.hours;
+ day += mult * aDuration.days;
+ day += mult * 7 * aDuration.weeks;
+
+ this.second = second;
+ this.minute = minute;
+ this.hour = hour;
+ this.day = day;
+
+ this._cachedUnixTime = null;
+ },
+
+ /**
+ * Subtract the date details (_excluding_ timezone). Useful for finding
+ * the relative difference between two time objects excluding their
+ * timezone differences.
+ *
+ * @param {ICAL.Time} aDate The date to subtract
+ * @return {ICAL.Duration} The difference as a duration
+ */
+ subtractDate: function icaltime_subtract(aDate) {
+ var unixTime = this.toUnixTime() + this.utcOffset();
+ var other = aDate.toUnixTime() + aDate.utcOffset();
+ return ICAL.Duration.fromSeconds(unixTime - other);
+ },
+
+ /**
+ * Subtract the date details, taking timezones into account.
+ *
+ * @param {ICAL.Time} aDate The date to subtract
+ * @return {ICAL.Duration} The difference in duration
+ */
+ subtractDateTz: function icaltime_subtract_abs(aDate) {
+ var unixTime = this.toUnixTime();
+ var other = aDate.toUnixTime();
+ return ICAL.Duration.fromSeconds(unixTime - other);
+ },
+
+ /**
+ * Compares the ICAL.Time instance with another one.
+ *
+ * @param {ICAL.Duration} aOther The instance to compare with
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compare: function icaltime_compare(other) {
+ var a = this.toUnixTime();
+ var b = other.toUnixTime();
+
+ if (a > b) return 1;
+ if (b > a) return -1;
+ return 0;
+ },
+
+ /**
+ * Compares only the date part of this instance with another one.
+ *
+ * @param {ICAL.Duration} other The instance to compare with
+ * @param {ICAL.Timezone} tz The timezone to compare in
+ * @return {Number} -1, 0 or 1 for less/equal/greater
+ */
+ compareDateOnlyTz: function icaltime_compareDateOnlyTz(other, tz) {
+ function cmp(attr) {
+ return ICAL.Time._cmp_attr(a, b, attr);
+ }
+ var a = this.convertToZone(tz);
+ var b = other.convertToZone(tz);
+ var rc = 0;
+
+ if ((rc = cmp("year")) != 0) return rc;
+ if ((rc = cmp("month")) != 0) return rc;
+ if ((rc = cmp("day")) != 0) return rc;
+
+ return rc;
+ },
+
+ /**
+ * Convert the instance into another timezone. The returned ICAL.Time
+ * instance is always a copy.
+ *
+ * @param {ICAL.Timezone} zone The zone to convert to
+ * @return {ICAL.Time} The copy, converted to the zone
+ */
+ convertToZone: function convertToZone(zone) {
+ var copy = this.clone();
+ var zone_equals = (this.zone.tzid == zone.tzid);
+
+ if (!this.isDate && !zone_equals) {
+ ICAL.Timezone.convert_time(copy, this.zone, zone);
+ }
+
+ copy.zone = zone;
+ return copy;
+ },
+
+ /**
+ * Calculates the UTC offset of the current date/time in the timezone it is
+ * in.
+ *
+ * @return {Number} UTC offset in seconds
+ */
+ utcOffset: function utc_offset() {
+ if (this.zone == ICAL.Timezone.localTimezone ||
+ this.zone == ICAL.Timezone.utcTimezone) {
+ return 0;
+ } else {
+ return this.zone.utcOffset(this);
+ }
+ },
+
+ /**
+ * Returns an RFC 5545 compliant ical representation of this object.
+ *
+ * @return {String} ical date/date-time
+ */
+ toICALString: function() {
+ var string = this.toString();
+
+ if (string.length > 10) {
+ return ICAL.design.icalendar.value['date-time'].toICAL(string);
+ } else {
+ return ICAL.design.icalendar.value.date.toICAL(string);
+ }
+ },
+
+ /**
+ * The string representation of this date/time, in jCal form
+ * (including : and - separators).
+ * @return {String}
+ */
+ toString: function toString() {
+ var result = this.year + '-' +
+ ICAL.helpers.pad2(this.month) + '-' +
+ ICAL.helpers.pad2(this.day);
+
+ if (!this.isDate) {
+ result += 'T' + ICAL.helpers.pad2(this.hour) + ':' +
+ ICAL.helpers.pad2(this.minute) + ':' +
+ ICAL.helpers.pad2(this.second);
+
+ if (this.zone === ICAL.Timezone.utcTimezone) {
+ result += 'Z';
+ }
+ }
+
+ return result;
+ },
+
+ /**
+ * Converts the current instance to a Javascript date
+ * @return {Date}
+ */
+ toJSDate: function toJSDate() {
+ if (this.zone == ICAL.Timezone.localTimezone) {
+ if (this.isDate) {
+ return new Date(this.year, this.month - 1, this.day);
+ } else {
+ return new Date(this.year, this.month - 1, this.day,
+ this.hour, this.minute, this.second, 0);
+ }
+ } else {
+ return new Date(this.toUnixTime() * 1000);
+ }
+ },
+
+ _normalize: function icaltime_normalize() {
+ var isDate = this._time.isDate;
+ if (this._time.isDate) {
+ this._time.hour = 0;
+ this._time.minute = 0;
+ this._time.second = 0;
+ }
+ this.adjust(0, 0, 0, 0);
+
+ return this;
+ },
+
+ /**
+ * Adjust the date/time by the given offset
+ *
+ * @param {Number} aExtraDays The extra amount of days
+ * @param {Number} aExtraHours The extra amount of hours
+ * @param {Number} aExtraMinutes The extra amount of minutes
+ * @param {Number} aExtraSeconds The extra amount of seconds
+ * @param {Number=} aTime The time to adjust, defaults to the
+ * current instance.
+ */
+ adjust: function icaltime_adjust(aExtraDays, aExtraHours,
+ aExtraMinutes, aExtraSeconds, aTime) {
+
+ var minutesOverflow, hoursOverflow,
+ daysOverflow = 0, yearsOverflow = 0;
+
+ var second, minute, hour, day;
+ var daysInMonth;
+
+ var time = aTime || this._time;
+
+ if (!time.isDate) {
+ second = time.second + aExtraSeconds;
+ time.second = second % 60;
+ minutesOverflow = ICAL.helpers.trunc(second / 60);
+ if (time.second < 0) {
+ time.second += 60;
+ minutesOverflow--;
+ }
+
+ minute = time.minute + aExtraMinutes + minutesOverflow;
+ time.minute = minute % 60;
+ hoursOverflow = ICAL.helpers.trunc(minute / 60);
+ if (time.minute < 0) {
+ time.minute += 60;
+ hoursOverflow--;
+ }
+
+ hour = time.hour + aExtraHours + hoursOverflow;
+
+ time.hour = hour % 24;
+ daysOverflow = ICAL.helpers.trunc(hour / 24);
+ if (time.hour < 0) {
+ time.hour += 24;
+ daysOverflow--;
+ }
+ }
+
+
+ // Adjust month and year first, because we need to know what month the day
+ // is in before adjusting it.
+ if (time.month > 12) {
+ yearsOverflow = ICAL.helpers.trunc((time.month - 1) / 12);
+ } else if (time.month < 1) {
+ yearsOverflow = ICAL.helpers.trunc(time.month / 12) - 1;
+ }
+
+ time.year += yearsOverflow;
+ time.month -= 12 * yearsOverflow;
+
+ // Now take care of the days (and adjust month if needed)
+ day = time.day + aExtraDays + daysOverflow;
+
+ if (day > 0) {
+ for (;;) {
+ daysInMonth = ICAL.Time.daysInMonth(time.month, time.year);
+ if (day <= daysInMonth) {
+ break;
+ }
+
+ time.month++;
+ if (time.month > 12) {
+ time.year++;
+ time.month = 1;
+ }
+
+ day -= daysInMonth;
+ }
+ } else {
+ while (day <= 0) {
+ if (time.month == 1) {
+ time.year--;
+ time.month = 12;
+ } else {
+ time.month--;
+ }
+
+ day += ICAL.Time.daysInMonth(time.month, time.year);
+ }
+ }
+
+ time.day = day;
+
+ this._cachedUnixTime = null;
+ return this;
+ },
+
+ /**
+ * Sets up the current instance from unix time, the number of seconds since
+ * January 1st, 1970.
+ *
+ * @param {Number} seconds The seconds to set up with
+ */
+ fromUnixTime: function fromUnixTime(seconds) {
+ this.zone = ICAL.Timezone.utcTimezone;
+ // We could use `fromJSDate` here, but this is about twice as fast.
+ // We could also clone `epochTime` and use `adjust` for a more
+ // ical.js-centric approach, but this is about 100 times as fast.
+ var date = new Date(seconds * 1000);
+ this.year = date.getUTCFullYear();
+ this.month = date.getUTCMonth() + 1;
+ this.day = date.getUTCDate();
+ if (this._time.isDate) {
+ this.hour = 0;
+ this.minute = 0;
+ this.second = 0;
+ } else {
+ this.hour = date.getUTCHours();
+ this.minute = date.getUTCMinutes();
+ this.second = date.getUTCSeconds();
+ }
+
+ this._cachedUnixTime = null;
+ },
+
+ /**
+ * Converts the current instance to seconds since January 1st 1970.
+ *
+ * @return {Number} Seconds since 1970
+ */
+ toUnixTime: function toUnixTime() {
+ if (this._cachedUnixTime !== null) {
+ return this._cachedUnixTime;
+ }
+ var offset = this.utcOffset();
+
+ // we use the offset trick to ensure
+ // that we are getting the actual UTC time
+ var ms = Date.UTC(
+ this.year,
+ this.month - 1,
+ this.day,
+ this.hour,
+ this.minute,
+ this.second - offset
+ );
+
+ // seconds
+ this._cachedUnixTime = ms / 1000;
+ return this._cachedUnixTime;
+ },
+
+ /**
+ * Converts time to into Object which can be serialized then re-created
+ * using the constructor.
+ *
+ * @example
+ * // toJSON will automatically be called
+ * var json = JSON.stringify(mytime);
+ *
+ * var deserialized = JSON.parse(json);
+ *
+ * var time = new ICAL.Time(deserialized);
+ *
+ * @return {Object}
+ */
+ toJSON: function() {
+ var copy = [
+ 'year',
+ 'month',
+ 'day',
+ 'hour',
+ 'minute',
+ 'second',
+ 'isDate'
+ ];
+
+ var result = Object.create(null);
+
+ var i = 0;
+ var len = copy.length;
+ var prop;
+
+ for (; i < len; i++) {
+ prop = copy[i];
+ result[prop] = this[prop];
+ }
+
+ if (this.zone) {
+ result.timezone = this.zone.tzid;
+ }
+
+ return result;
+ }
+
+ };
+
+ (function setupNormalizeAttributes() {
+ // This needs to run before any instances are created!
+ function defineAttr(attr) {
+ Object.defineProperty(ICAL.Time.prototype, attr, {
+ get: function getTimeAttr() {
+ if (this._pendingNormalization) {
+ this._normalize();
+ this._pendingNormalization = false;
+ }
+
+ return this._time[attr];
+ },
+ set: function setTimeAttr(val) {
+ // Check if isDate will be set and if was not set to normalize date.
+ // This avoids losing days when seconds, minutes and hours are zeroed
+ // what normalize will do when time is a date.
+ if (attr === "isDate" && val && !this._time.isDate) {
+ this.adjust(0, 0, 0, 0);
+ }
+ this._cachedUnixTime = null;
+ this._pendingNormalization = true;
+ this._time[attr] = val;
+
+ return val;
+ }
+ });
+
+ }
+
+ /* istanbul ignore else */
+ if ("defineProperty" in Object) {
+ defineAttr("year");
+ defineAttr("month");
+ defineAttr("day");
+ defineAttr("hour");
+ defineAttr("minute");
+ defineAttr("second");
+ defineAttr("isDate");
+ }
+ })();
+
+ /**
+ * Returns the days in the given month
+ *
+ * @param {Number} month The month to check
+ * @param {Number} year The year to check
+ * @return {Number} The number of days in the month
+ */
+ ICAL.Time.daysInMonth = function icaltime_daysInMonth(month, year) {
+ var _daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
+ var days = 30;
+
+ if (month < 1 || month > 12) return days;
+
+ days = _daysInMonth[month];
+
+ if (month == 2) {
+ days += ICAL.Time.isLeapYear(year);
+ }
+
+ return days;
+ };
+
+ /**
+ * Checks if the year is a leap year
+ *
+ * @param {Number} year The year to check
+ * @return {Boolean} True, if the year is a leap year
+ */
+ ICAL.Time.isLeapYear = function isLeapYear(year) {
+ if (year <= 1752) {
+ return ((year % 4) == 0);
+ } else {
+ return (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0));
+ }
+ };
+
+ /**
+ * Create a new ICAL.Time from the day of year and year. The date is returned
+ * in floating timezone.
+ *
+ * @param {Number} aDayOfYear The day of year
+ * @param {Number} aYear The year to create the instance in
+ * @return {ICAL.Time} The created instance with the calculated date
+ */
+ ICAL.Time.fromDayOfYear = function icaltime_fromDayOfYear(aDayOfYear, aYear) {
+ var year = aYear;
+ var doy = aDayOfYear;
+ var tt = new ICAL.Time();
+ tt.auto_normalize = false;
+ var is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0);
+
+ if (doy < 1) {
+ year--;
+ is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0);
+ doy += ICAL.Time.daysInYearPassedMonth[is_leap][12];
+ return ICAL.Time.fromDayOfYear(doy, year);
+ } else if (doy > ICAL.Time.daysInYearPassedMonth[is_leap][12]) {
+ is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0);
+ doy -= ICAL.Time.daysInYearPassedMonth[is_leap][12];
+ year++;
+ return ICAL.Time.fromDayOfYear(doy, year);
+ }
+
+ tt.year = year;
+ tt.isDate = true;
+
+ for (var month = 11; month >= 0; month--) {
+ if (doy > ICAL.Time.daysInYearPassedMonth[is_leap][month]) {
+ tt.month = month + 1;
+ tt.day = doy - ICAL.Time.daysInYearPassedMonth[is_leap][month];
+ break;
+ }
+ }
+
+ tt.auto_normalize = true;
+ return tt;
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date string, e.g 2015-01-02.
+ *
+ * @deprecated Use {@link ICAL.Time.fromDateString} instead
+ * @param {String} str The string to create from
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromStringv2 = function fromString(str) {
+ return new ICAL.Time({
+ year: parseInt(str.substr(0, 4), 10),
+ month: parseInt(str.substr(5, 2), 10),
+ day: parseInt(str.substr(8, 2), 10),
+ isDate: true
+ });
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date string, e.g 2015-01-02.
+ *
+ * @param {String} aValue The string to create from
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromDateString = function(aValue) {
+ // Dates should have no timezone.
+ // Google likes to sometimes specify Z on dates
+ // we specifically ignore that to avoid issues.
+
+ // YYYY-MM-DD
+ // 2012-10-10
+ return new ICAL.Time({
+ year: ICAL.helpers.strictParseInt(aValue.substr(0, 4)),
+ month: ICAL.helpers.strictParseInt(aValue.substr(5, 2)),
+ day: ICAL.helpers.strictParseInt(aValue.substr(8, 2)),
+ isDate: true
+ });
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date-time string, e.g
+ * 2015-01-02T03:04:05. If a property is specified, the timezone is set up
+ * from the property's TZID parameter.
+ *
+ * @param {String} aValue The string to create from
+ * @param {ICAL.Property=} prop The property the date belongs to
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromDateTimeString = function(aValue, prop) {
+ if (aValue.length < 19) {
+ throw new Error(
+ 'invalid date-time value: "' + aValue + '"'
+ );
+ }
+
+ var zone;
+ var zoneId;
+
+ if (aValue[19] && aValue[19] === 'Z') {
+ zone = ICAL.Timezone.utcTimezone;
+ } else if (prop) {
+ zoneId = prop.getParameter('tzid');
+
+ if (prop.parent) {
+ if (prop.parent.name === 'standard' || prop.parent.name === 'daylight') {
+ // Per RFC 5545 3.8.2.4 and 3.8.2.2, start/end date-times within
+ // these components MUST be specified in local time.
+ zone = ICAL.Timezone.floating;
+ } else if (zoneId) {
+ // If the desired time zone is defined within the component tree,
+ // fetch its definition and prefer that.
+ zone = prop.parent.getTimeZoneByID(zoneId);
+ }
+ }
+ }
+
+ var timeData = {
+ year: ICAL.helpers.strictParseInt(aValue.substr(0, 4)),
+ month: ICAL.helpers.strictParseInt(aValue.substr(5, 2)),
+ day: ICAL.helpers.strictParseInt(aValue.substr(8, 2)),
+ hour: ICAL.helpers.strictParseInt(aValue.substr(11, 2)),
+ minute: ICAL.helpers.strictParseInt(aValue.substr(14, 2)),
+ second: ICAL.helpers.strictParseInt(aValue.substr(17, 2)),
+ };
+
+ // Although RFC 5545 requires that all TZIDs used within a file have a
+ // corresponding time zone definition, we may not be parsing the full file
+ // or we may be dealing with a non-compliant file; in either case, we can
+ // check our own time zone service for the TZID in a last-ditch effort.
+ if (zoneId && !zone) {
+ timeData.timezone = zoneId;
+ }
+
+ // 2012-10-10T10:10:10(Z)?
+ return new ICAL.Time(timeData, zone);
+ };
+
+ /**
+ * Returns a new ICAL.Time instance from a date or date-time string,
+ *
+ * @param {String} aValue The string to create from
+ * @param {ICAL.Property=} prop The property the date belongs to
+ * @return {ICAL.Time} The date/time instance
+ */
+ ICAL.Time.fromString = function fromString(aValue, aProperty) {
+ if (aValue.length > 10) {
+ return ICAL.Time.fromDateTimeString(aValue, aProperty);
+ } else {
+ return ICAL.Time.fromDateString(aValue);
+ }
+ };
+
+ /**
+ * Creates a new ICAL.Time instance from the given Javascript Date.
+ *
+ * @param {?Date} aDate The Javascript Date to read, or null to reset
+ * @param {Boolean} useUTC If true, the UTC values of the date will be used
+ */
+ ICAL.Time.fromJSDate = function fromJSDate(aDate, useUTC) {
+ var tt = new ICAL.Time();
+ return tt.fromJSDate(aDate, useUTC);
+ };
+
+ /**
+ * Creates a new ICAL.Time instance from the the passed data object.
+ *
+ * @param {Object} aData Time initialization
+ * @param {Number=} aData.year The year for this date
+ * @param {Number=} aData.month The month for this date
+ * @param {Number=} aData.day The day for this date
+ * @param {Number=} aData.hour The hour for this date
+ * @param {Number=} aData.minute The minute for this date
+ * @param {Number=} aData.second The second for this date
+ * @param {Boolean=} aData.isDate If true, the instance represents a date
+ * (as opposed to a date-time)
+ * @param {ICAL.Timezone=} aZone Timezone this position occurs in
+ */
+ ICAL.Time.fromData = function fromData(aData, aZone) {
+ var t = new ICAL.Time();
+ return t.fromData(aData, aZone);
+ };
+
+ /**
+ * Creates a new ICAL.Time instance from the current moment.
+ * The instance is “floating” - has no timezone relation.
+ * To create an instance considering the time zone, call
+ * ICAL.Time.fromJSDate(new Date(), true)
+ * @return {ICAL.Time}
+ */
+ ICAL.Time.now = function icaltime_now() {
+ return ICAL.Time.fromJSDate(new Date(), false);
+ };
+
+ /**
+ * Returns the date on which ISO week number 1 starts.
+ *
+ * @see ICAL.Time#weekNumber
+ * @param {Number} aYear The year to search in
+ * @param {ICAL.Time.weekDay=} aWeekStart The week start weekday, used for calculation.
+ * @return {ICAL.Time} The date on which week number 1 starts
+ */
+ ICAL.Time.weekOneStarts = function weekOneStarts(aYear, aWeekStart) {
+ var t = ICAL.Time.fromData({
+ year: aYear,
+ month: 1,
+ day: 1,
+ isDate: true
+ });
+
+ var dow = t.dayOfWeek();
+ var wkst = aWeekStart || ICAL.Time.DEFAULT_WEEK_START;
+ if (dow > ICAL.Time.THURSDAY) {
+ t.day += 7;
+ }
+ if (wkst > ICAL.Time.THURSDAY) {
+ t.day -= 7;
+ }
+
+ t.day -= dow - wkst;
+
+ return t;
+ };
+
+ /**
+ * Get the dominical letter for the given year. Letters range from A - G for
+ * common years, and AG to GF for leap years.
+ *
+ * @param {Number} yr The year to retrieve the letter for
+ * @return {String} The dominical letter.
+ */
+ ICAL.Time.getDominicalLetter = function(yr) {
+ var LTRS = "GFEDCBA";
+ var dom = (yr + (yr / 4 | 0) + (yr / 400 | 0) - (yr / 100 | 0) - 1) % 7;
+ var isLeap = ICAL.Time.isLeapYear(yr);
+ if (isLeap) {
+ return LTRS[(dom + 6) % 7] + LTRS[dom];
+ } else {
+ return LTRS[dom];
+ }
+ };
+
+ /**
+ * January 1st, 1970 as an ICAL.Time.
+ * @type {ICAL.Time}
+ * @constant
+ * @instance
+ */
+ ICAL.Time.epochTime = ICAL.Time.fromData({
+ year: 1970,
+ month: 1,
+ day: 1,
+ hour: 0,
+ minute: 0,
+ second: 0,
+ isDate: false,
+ timezone: "Z"
+ });
+
+ ICAL.Time._cmp_attr = function _cmp_attr(a, b, attr) {
+ if (a[attr] > b[attr]) return 1;
+ if (a[attr] < b[attr]) return -1;
+ return 0;
+ };
+
+ /**
+ * The days that have passed in the year after a given month. The array has
+ * two members, one being an array of passed days for non-leap years, the
+ * other analog for leap years.
+ * @example
+ * var isLeapYear = ICAL.Time.isLeapYear(year);
+ * var passedDays = ICAL.Time.daysInYearPassedMonth[isLeapYear][month];
+ * @type {Array.<Array.<Number>>}
+ */
+ ICAL.Time.daysInYearPassedMonth = [
+ [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365],
+ [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]
+ ];
+
+ /**
+ * The weekday, 1 = SUNDAY, 7 = SATURDAY. Access via
+ * ICAL.Time.MONDAY, ICAL.Time.TUESDAY, ...
+ *
+ * @typedef {Number} weekDay
+ * @memberof ICAL.Time
+ */
+
+ ICAL.Time.SUNDAY = 1;
+ ICAL.Time.MONDAY = 2;
+ ICAL.Time.TUESDAY = 3;
+ ICAL.Time.WEDNESDAY = 4;
+ ICAL.Time.THURSDAY = 5;
+ ICAL.Time.FRIDAY = 6;
+ ICAL.Time.SATURDAY = 7;
+
+ /**
+ * The default weekday for the WKST part.
+ * @constant
+ * @default ICAL.Time.MONDAY
+ */
+ ICAL.Time.DEFAULT_WEEK_START = ICAL.Time.MONDAY;
+})();
+/* 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, 2015 */
+
+
+
+(function() {
+
+ /**
+ * Describes a vCard time, which has slight differences to the ICAL.Time.
+ * Properties can be null if not specified, for example for dates with
+ * reduced accuracy or truncation.
+ *
+ * Note that currently not all methods are correctly re-implemented for
+ * VCardTime. For example, comparison will have undefined results when some
+ * members are null.
+ *
+ * Also, normalization is not yet implemented for this class!
+ *
+ * @alias ICAL.VCardTime
+ * @class
+ * @extends {ICAL.Time}
+ * @param {Object} data The data for the time instance
+ * @param {Number=} data.year The year for this date
+ * @param {Number=} data.month The month for this date
+ * @param {Number=} data.day The day for this date
+ * @param {Number=} data.hour The hour for this date
+ * @param {Number=} data.minute The minute for this date
+ * @param {Number=} data.second The second for this date
+ * @param {ICAL.Timezone|ICAL.UtcOffset} zone The timezone to use
+ * @param {String} icaltype The type for this date/time object
+ */
+ ICAL.VCardTime = function(data, zone, icaltype) {
+ this.wrappedJSObject = this;
+ var time = this._time = Object.create(null);
+
+ time.year = null;
+ time.month = null;
+ time.day = null;
+ time.hour = null;
+ time.minute = null;
+ time.second = null;
+
+ this.icaltype = icaltype || "date-and-or-time";
+
+ this.fromData(data, zone);
+ };
+ ICAL.helpers.inherits(ICAL.Time, ICAL.VCardTime, /** @lends ICAL.VCardTime */ {
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "vcardtime"
+ */
+ icalclass: "vcardtime",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @type {String}
+ * @default "date-and-or-time"
+ */
+ icaltype: "date-and-or-time",
+
+ /**
+ * The timezone. This can either be floating, UTC, or an instance of
+ * ICAL.UtcOffset.
+ * @type {ICAL.Timezone|ICAL.UtcOFfset}
+ */
+ zone: null,
+
+ /**
+ * Returns a clone of the vcard date/time object.
+ *
+ * @return {ICAL.VCardTime} The cloned object
+ */
+ clone: function() {
+ return new ICAL.VCardTime(this._time, this.zone, this.icaltype);
+ },
+
+ _normalize: function() {
+ return this;
+ },
+
+ /**
+ * @inheritdoc
+ */
+ utcOffset: function() {
+ if (this.zone instanceof ICAL.UtcOffset) {
+ return this.zone.toSeconds();
+ } else {
+ return ICAL.Time.prototype.utcOffset.apply(this, arguments);
+ }
+ },
+
+ /**
+ * Returns an RFC 6350 compliant representation of this object.
+ *
+ * @return {String} vcard date/time string
+ */
+ toICALString: function() {
+ return ICAL.design.vcard.value[this.icaltype].toICAL(this.toString());
+ },
+
+ /**
+ * The string representation of this date/time, in jCard form
+ * (including : and - separators).
+ * @return {String}
+ */
+ toString: function toString() {
+ var p2 = ICAL.helpers.pad2;
+ var y = this.year, m = this.month, d = this.day;
+ var h = this.hour, mm = this.minute, s = this.second;
+
+ var hasYear = y !== null, hasMonth = m !== null, hasDay = d !== null;
+ var hasHour = h !== null, hasMinute = mm !== null, hasSecond = s !== null;
+
+ var datepart = (hasYear ? p2(y) + (hasMonth || hasDay ? '-' : '') : (hasMonth || hasDay ? '--' : '')) +
+ (hasMonth ? p2(m) : '') +
+ (hasDay ? '-' + p2(d) : '');
+ var timepart = (hasHour ? p2(h) : '-') + (hasHour && hasMinute ? ':' : '') +
+ (hasMinute ? p2(mm) : '') + (!hasHour && !hasMinute ? '-' : '') +
+ (hasMinute && hasSecond ? ':' : '') +
+ (hasSecond ? p2(s) : '');
+
+ var zone;
+ if (this.zone === ICAL.Timezone.utcTimezone) {
+ zone = 'Z';
+ } else if (this.zone instanceof ICAL.UtcOffset) {
+ zone = this.zone.toString();
+ } else if (this.zone === ICAL.Timezone.localTimezone) {
+ zone = '';
+ } else if (this.zone instanceof ICAL.Timezone) {
+ var offset = ICAL.UtcOffset.fromSeconds(this.zone.utcOffset(this));
+ zone = offset.toString();
+ } else {
+ zone = '';
+ }
+
+ switch (this.icaltype) {
+ case "time":
+ return timepart + zone;
+ case "date-and-or-time":
+ case "date-time":
+ return datepart + (timepart == '--' ? '' : 'T' + timepart + zone);
+ case "date":
+ return datepart;
+ }
+ return null;
+ }
+ });
+
+ /**
+ * Returns a new ICAL.VCardTime instance from a date and/or time string.
+ *
+ * @param {String} aValue The string to create from
+ * @param {String} aIcalType The type for this instance, e.g. date-and-or-time
+ * @return {ICAL.VCardTime} The date/time instance
+ */
+ ICAL.VCardTime.fromDateAndOrTimeString = function(aValue, aIcalType) {
+ function part(v, s, e) {
+ return v ? ICAL.helpers.strictParseInt(v.substr(s, e)) : null;
+ }
+ var parts = aValue.split('T');
+ var dt = parts[0], tmz = parts[1];
+ var splitzone = tmz ? ICAL.design.vcard.value.time._splitZone(tmz) : [];
+ var zone = splitzone[0], tm = splitzone[1];
+
+ var stoi = ICAL.helpers.strictParseInt;
+ var dtlen = dt ? dt.length : 0;
+ var tmlen = tm ? tm.length : 0;
+
+ var hasDashDate = dt && dt[0] == '-' && dt[1] == '-';
+ var hasDashTime = tm && tm[0] == '-';
+
+ var o = {
+ year: hasDashDate ? null : part(dt, 0, 4),
+ month: hasDashDate && (dtlen == 4 || dtlen == 7) ? part(dt, 2, 2) : dtlen == 7 ? part(dt, 5, 2) : dtlen == 10 ? part(dt, 5, 2) : null,
+ day: dtlen == 5 ? part(dt, 3, 2) : dtlen == 7 && hasDashDate ? part(dt, 5, 2) : dtlen == 10 ? part(dt, 8, 2) : null,
+
+ hour: hasDashTime ? null : part(tm, 0, 2),
+ minute: hasDashTime && tmlen == 3 ? part(tm, 1, 2) : tmlen > 4 ? hasDashTime ? part(tm, 1, 2) : part(tm, 3, 2) : null,
+ second: tmlen == 4 ? part(tm, 2, 2) : tmlen == 6 ? part(tm, 4, 2) : tmlen == 8 ? part(tm, 6, 2) : null
+ };
+
+ if (zone == 'Z') {
+ zone = ICAL.Timezone.utcTimezone;
+ } else if (zone && zone[3] == ':') {
+ zone = ICAL.UtcOffset.fromString(zone);
+ } else {
+ zone = null;
+ }
+
+ return new ICAL.VCardTime(o, zone, aIcalType);
+ };
+})();
+/* 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, 2011-2015 */
+
+
+
+(function() {
+ var DOW_MAP = {
+ SU: ICAL.Time.SUNDAY,
+ MO: ICAL.Time.MONDAY,
+ TU: ICAL.Time.TUESDAY,
+ WE: ICAL.Time.WEDNESDAY,
+ TH: ICAL.Time.THURSDAY,
+ FR: ICAL.Time.FRIDAY,
+ SA: ICAL.Time.SATURDAY
+ };
+
+ var REVERSE_DOW_MAP = {};
+ for (var key in DOW_MAP) {
+ /* istanbul ignore else */
+ if (DOW_MAP.hasOwnProperty(key)) {
+ REVERSE_DOW_MAP[DOW_MAP[key]] = key;
+ }
+ }
+
+ var COPY_PARTS = ["BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY",
+ "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO",
+ "BYMONTH", "BYSETPOS"];
+
+ /**
+ * @classdesc
+ * This class represents the "recur" value type, with various calculation
+ * and manipulation methods.
+ *
+ * @class
+ * @alias ICAL.Recur
+ * @param {Object} data An object with members of the recurrence
+ * @param {ICAL.Recur.frequencyValues=} data.freq The frequency value
+ * @param {Number=} data.interval The INTERVAL value
+ * @param {ICAL.Time.weekDay=} data.wkst The week start value
+ * @param {ICAL.Time=} data.until The end of the recurrence set
+ * @param {Number=} data.count The number of occurrences
+ * @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part
+ * @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part
+ * @param {Array.<Number>=} data.byhour The hours for the BYHOUR part
+ * @param {Array.<String>=} data.byday The BYDAY values
+ * @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part
+ * @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part
+ * @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part
+ * @param {Array.<Number>=} data.bymonth The month for the BYMONTH part
+ * @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part
+ */
+ ICAL.Recur = function icalrecur(data) {
+ this.wrappedJSObject = this;
+ this.parts = {};
+
+ if (data && typeof(data) === 'object') {
+ this.fromData(data);
+ }
+ };
+
+ ICAL.Recur.prototype = {
+ /**
+ * An object holding the BY-parts of the recurrence rule
+ * @type {Object}
+ */
+ parts: null,
+
+ /**
+ * The interval value for the recurrence rule.
+ * @type {Number}
+ */
+ interval: 1,
+
+ /**
+ * The week start day
+ *
+ * @type {ICAL.Time.weekDay}
+ * @default ICAL.Time.MONDAY
+ */
+ wkst: ICAL.Time.MONDAY,
+
+ /**
+ * The end of the recurrence
+ * @type {?ICAL.Time}
+ */
+ until: null,
+
+ /**
+ * The maximum number of occurrences
+ * @type {?Number}
+ */
+ count: null,
+
+ /**
+ * The frequency value.
+ * @type {ICAL.Recur.frequencyValues}
+ */
+ freq: null,
+
+ /**
+ * The class identifier.
+ * @constant
+ * @type {String}
+ * @default "icalrecur"
+ */
+ icalclass: "icalrecur",
+
+ /**
+ * The type name, to be used in the jCal object.
+ * @constant
+ * @type {String}
+ * @default "recur"
+ */
+ icaltype: "recur",
+
+ /**
+ * Create a new iterator for this recurrence rule. The passed start date
+ * must be the start date of the event, not the start of the range to
+ * search in.
+ *
+ * @example
+ * var recur = comp.getFirstPropertyValue('rrule');
+ * var dtstart = comp.getFirstPropertyValue('dtstart');
+ * var iter = recur.iterator(dtstart);
+ * for (var next = iter.next(); next; next = iter.next()) {
+ * if (next.compare(rangeStart) < 0) {
+ * continue;
+ * }
+ * console.log(next.toString());
+ * }
+ *
+ * @param {ICAL.Time} aStart The item's start date
+ * @return {ICAL.RecurIterator} The recurrence iterator
+ */
+ iterator: function(aStart) {
+ return new ICAL.RecurIterator({
+ rule: this,
+ dtstart: aStart
+ });
+ },
+
+ /**
+ * Returns a clone of the recurrence object.
+ *
+ * @return {ICAL.Recur} The cloned object
+ */
+ clone: function clone() {
+ return new ICAL.Recur(this.toJSON());
+ },
+
+ /**
+ * Checks if the current rule is finite, i.e. has a count or until part.
+ *
+ * @return {Boolean} True, if the rule is finite
+ */
+ isFinite: function isfinite() {
+ return !!(this.count || this.until);
+ },
+
+ /**
+ * Checks if the current rule has a count part, and not limited by an until
+ * part.
+ *
+ * @return {Boolean} True, if the rule is by count
+ */
+ isByCount: function isbycount() {
+ return !!(this.count && !this.until);
+ },
+
+ /**
+ * Adds a component (part) to the recurrence rule. This is not a component
+ * in the sense of {@link ICAL.Component}, but a part of the recurrence
+ * rule, i.e. BYMONTH.
+ *
+ * @param {String} aType The name of the component part
+ * @param {Array|String} aValue The component value
+ */
+ addComponent: function addPart(aType, aValue) {
+ var ucname = aType.toUpperCase();
+ if (ucname in this.parts) {
+ this.parts[ucname].push(aValue);
+ } else {
+ this.parts[ucname] = [aValue];
+ }
+ },
+
+ /**
+ * Sets the component value for the given by-part.
+ *
+ * @param {String} aType The component part name
+ * @param {Array} aValues The component values
+ */
+ setComponent: function setComponent(aType, aValues) {
+ this.parts[aType.toUpperCase()] = aValues.slice();
+ },
+
+ /**
+ * Gets (a copy) of the requested component value.
+ *
+ * @param {String} aType The component part name
+ * @return {Array} The component part value
+ */
+ getComponent: function getComponent(aType) {
+ var ucname = aType.toUpperCase();
+ return (ucname in this.parts ? this.parts[ucname].slice() : []);
+ },
+
+ /**
+ * Retrieves the next occurrence after the given recurrence id. See the
+ * guide on {@tutorial terminology} for more details.
+ *
+ * NOTE: Currently, this method iterates all occurrences from the start
+ * date. It should not be called in a loop for performance reasons. If you
+ * would like to get more than one occurrence, you can iterate the
+ * occurrences manually, see the example on the
+ * {@link ICAL.Recur#iterator iterator} method.
+ *
+ * @param {ICAL.Time} aStartTime The start of the event series
+ * @param {ICAL.Time} aRecurrenceId The date of the last occurrence
+ * @return {ICAL.Time} The next occurrence after
+ */
+ getNextOccurrence: function getNextOccurrence(aStartTime, aRecurrenceId) {
+ var iter = this.iterator(aStartTime);
+ var next, cdt;
+
+ do {
+ next = iter.next();
+ } while (next && next.compare(aRecurrenceId) <= 0);
+
+ if (next && aRecurrenceId.zone) {
+ next.zone = aRecurrenceId.zone;
+ }
+
+ return next;
+ },
+
+ /**
+ * Sets up the current instance using members from the passed data object.
+ *
+ * @param {Object} data An object with members of the recurrence
+ * @param {ICAL.Recur.frequencyValues=} data.freq The frequency value
+ * @param {Number=} data.interval The INTERVAL value
+ * @param {ICAL.Time.weekDay=} data.wkst The week start value
+ * @param {ICAL.Time=} data.until The end of the recurrence set
+ * @param {Number=} data.count The number of occurrences
+ * @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part
+ * @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part
+ * @param {Array.<Number>=} data.byhour The hours for the BYHOUR part
+ * @param {Array.<String>=} data.byday The BYDAY values
+ * @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part
+ * @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part
+ * @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part
+ * @param {Array.<Number>=} data.bymonth The month for the BYMONTH part
+ * @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part
+ */
+ fromData: function(data) {
+ for (var key in data) {
+ var uckey = key.toUpperCase();
+
+ if (uckey in partDesign) {
+ if (Array.isArray(data[key])) {
+ this.parts[uckey] = data[key];
+ } else {
+ this.parts[uckey] = [data[key]];
+ }
+ } else {
+ this[key] = data[key];
+ }
+ }
+
+ if (this.interval && typeof this.interval != "number") {
+ optionDesign.INTERVAL(this.interval, this);
+ }
+
+ if (this.wkst && typeof this.wkst != "number") {
+ this.wkst = ICAL.Recur.icalDayToNumericDay(this.wkst);
+ }
+
+ if (this.until && !(this.until instanceof ICAL.Time)) {
+ this.until = ICAL.Time.fromString(this.until);
+ }
+ },
+
+ /**
+ * The jCal representation of this recurrence type.
+ * @return {Object}
+ */
+ toJSON: function() {
+ var res = Object.create(null);
+ res.freq = this.freq;
+
+ if (this.count) {
+ res.count = this.count;
+ }
+
+ if (this.interval > 1) {
+ res.interval = this.interval;
+ }
+
+ for (var k in this.parts) {
+ /* istanbul ignore if */
+ if (!this.parts.hasOwnProperty(k)) {
+ continue;
+ }
+ var kparts = this.parts[k];
+ if (Array.isArray(kparts) && kparts.length == 1) {
+ res[k.toLowerCase()] = kparts[0];
+ } else {
+ res[k.toLowerCase()] = ICAL.helpers.clone(this.parts[k]);
+ }
+ }
+
+ if (this.until) {
+ res.until = this.until.toString();
+ }
+ if ('wkst' in this && this.wkst !== ICAL.Time.DEFAULT_WEEK_START) {
+ res.wkst = ICAL.Recur.numericDayToIcalDay(this.wkst);
+ }
+ return res;
+ },
+
+ /**
+ * The string representation of this recurrence rule.
+ * @return {String}
+ */
+ toString: function icalrecur_toString() {
+ // TODO retain order
+ var str = "FREQ=" + this.freq;
+ if (this.count) {
+ str += ";COUNT=" + this.count;
+ }
+ if (this.interval > 1) {
+ str += ";INTERVAL=" + this.interval;
+ }
+ for (var k in this.parts) {
+ /* istanbul ignore else */
+ if (this.parts.hasOwnProperty(k)) {
+ str += ";" + k + "=" + this.parts[k];
+ }
+ }
+ if (this.until) {
+ str += ';UNTIL=' + this.until.toICALString();
+ }
+ if ('wkst' in this && this.wkst !== ICAL.Time.DEFAULT_WEEK_START) {
+ str += ';WKST=' + ICAL.Recur.numericDayToIcalDay(this.wkst);
+ }
+ return str;
+ }
+ };
+
+ function parseNumericValue(type, min, max, value) {
+ var result = value;
+
+ if (value[0] === '+') {
+ result = value.substr(1);
+ }
+
+ result = ICAL.helpers.strictParseInt(result);
+
+ if (min !== undefined && value < min) {
+ throw new Error(
+ type + ': invalid value "' + value + '" must be > ' + min
+ );
+ }
+
+ if (max !== undefined && value > max) {
+ throw new Error(
+ type + ': invalid value "' + value + '" must be < ' + min
+ );
+ }
+
+ return result;
+ }
+
+ /**
+ * Convert an ical representation of a day (SU, MO, etc..)
+ * into a numeric value of that day.
+ *
+ * @param {String} string The iCalendar day name
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {Number} Numeric value of given day
+ */
+ ICAL.Recur.icalDayToNumericDay = function toNumericDay(string, aWeekStart) {
+ //XXX: this is here so we can deal
+ // with possibly invalid string values.
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ return ((DOW_MAP[string] - firstDow + 7) % 7) + 1;
+ };
+
+ /**
+ * Convert a numeric day value into its ical representation (SU, MO, etc..)
+ *
+ * @param {Number} num Numeric value of given day
+ * @param {ICAL.Time.weekDay=} aWeekStart
+ * The week start weekday, defaults to SUNDAY
+ * @return {String} The ICAL day value, e.g SU,MO,...
+ */
+ ICAL.Recur.numericDayToIcalDay = function toIcalDay(num, aWeekStart) {
+ //XXX: this is here so we can deal with possibly invalid number values.
+ // Also, this allows consistent mapping between day numbers and day
+ // names for external users.
+ var firstDow = aWeekStart || ICAL.Time.SUNDAY;
+ var dow = (num + firstDow - ICAL.Time.SUNDAY);
+ if (dow > 7) {
+ dow -= 7;
+ }
+ return REVERSE_DOW_MAP[dow];
+ };
+
+ var VALID_DAY_NAMES = /^(SU|MO|TU|WE|TH|FR|SA)$/;
+ var VALID_BYDAY_PART = /^([+-])?(5[0-3]|[1-4][0-9]|[1-9])?(SU|MO|TU|WE|TH|FR|SA)$/;
+
+ /**
+ * Possible frequency values for the FREQ part
+ * (YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY)
+ *
+ * @typedef {String} frequencyValues
+ * @memberof ICAL.Recur
+ */
+
+ var ALLOWED_FREQ = ['SECONDLY', 'MINUTELY', 'HOURLY',
+ 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'];
+
+ var optionDesign = {
+ FREQ: function(value, dict, fmtIcal) {
+ // yes this is actually equal or faster then regex.
+ // upside here is we can enumerate the valid values.
+ if (ALLOWED_FREQ.indexOf(value) !== -1) {
+ dict.freq = value;
+ } else {
+ throw new Error(
+ 'invalid frequency "' + value + '" expected: "' +
+ ALLOWED_FREQ.join(', ') + '"'
+ );
+ }
+ },
+
+ COUNT: function(value, dict, fmtIcal) {
+ dict.count = ICAL.helpers.strictParseInt(value);
+ },
+
+ INTERVAL: function(value, dict, fmtIcal) {
+ dict.interval = ICAL.helpers.strictParseInt(value);
+ if (dict.interval < 1) {
+ // 0 or negative values are not allowed, some engines seem to generate
+ // it though. Assume 1 instead.
+ dict.interval = 1;
+ }
+ },
+
+ UNTIL: function(value, dict, fmtIcal) {
+ if (value.length > 10) {
+ dict.until = ICAL.design.icalendar.value['date-time'].fromICAL(value);
+ } else {
+ dict.until = ICAL.design.icalendar.value.date.fromICAL(value);
+ }
+ if (!fmtIcal) {
+ dict.until = ICAL.Time.fromString(dict.until);
+ }
+ },
+
+ WKST: function(value, dict, fmtIcal) {
+ if (VALID_DAY_NAMES.test(value)) {
+ dict.wkst = ICAL.Recur.icalDayToNumericDay(value);
+ } else {
+ throw new Error('invalid WKST value "' + value + '"');
+ }
+ }
+ };
+
+ var partDesign = {
+ BYSECOND: parseNumericValue.bind(this, 'BYSECOND', 0, 60),
+ BYMINUTE: parseNumericValue.bind(this, 'BYMINUTE', 0, 59),
+ BYHOUR: parseNumericValue.bind(this, 'BYHOUR', 0, 23),
+ BYDAY: function(value) {
+ if (VALID_BYDAY_PART.test(value)) {
+ return value;
+ } else {
+ throw new Error('invalid BYDAY value "' + value + '"');
+ }
+ },
+ BYMONTHDAY: parseNumericValue.bind(this, 'BYMONTHDAY', -31, 31),
+ BYYEARDAY: parseNumericValue.bind(this, 'BYYEARDAY', -366, 366),
+ BYWEEKNO: parseNumericValue.bind(this, 'BYWEEKNO', -53, 53),
+ BYMONTH: parseNumericValue.bind(this, 'BYMONTH', 1, 12),
+ BYSETPOS: parseNumericValue.bind(this, 'BYSETPOS', -366, 366)
+ };
+
+
+ /**
+ * Creates a new {@link ICAL.Recur} instance from the passed string.
+ *
+ * @param {String} string The string to parse
+ * @return {ICAL.Recur} The created recurrence instance
+ */
+ ICAL.Recur.fromString = function(string) {
+ var data = ICAL.Recur._stringToData(string, false);
+ return new ICAL.Recur(data);
+ };
+
+ /**
+ * Creates a new {@link ICAL.Recur} instance using members from the passed
+ * data object.
+ *
+ * @param {Object} aData An object with members of the recurrence
+ * @param {ICAL.Recur.frequencyValues=} aData.freq The frequency value
+ * @param {Number=} aData.interval The INTERVAL value
+ * @param {ICAL.Time.weekDay=} aData.wkst The week start value
+ * @param {ICAL.Time=} aData.until The end of the recurrence set
+ * @param {Number=} aData.count The number of occurrences
+ * @param {Array.<Number>=} aData.bysecond The seconds for the BYSECOND part
+ * @param {Array.<Number>=} aData.byminute The minutes for the BYMINUTE part
+ * @param {Array.<Number>=} aData.byhour The hours for the BYHOUR part
+ * @param {Array.<String>=} aData.byday The BYDAY values
+ * @param {Array.<Number>=} aData.bymonthday The days for the BYMONTHDAY part
+ * @param {Array.<Number>=} aData.byyearday The days for the BYYEARDAY part
+ * @param {Array.<Number>=} aData.byweekno The weeks for the BYWEEKNO part
+ * @param {Array.<Number>=} aData.bymonth The month for the BYMONTH part
+ * @param {Array.<Number>=} aData.bysetpos The positionals for the BYSETPOS part
+ */
+ ICAL.Recur.fromData = function(aData) {
+ return new ICAL.Recur(aData);
+ };
+
+ /**
+ * Converts a recurrence string to a data object, suitable for the fromData
+ * method.
+ *
+ * @param {String} string The string to parse
+ * @param {Boolean} fmtIcal If true, the string is considered to be an
+ * iCalendar string
+ * @return {ICAL.Recur} The recurrence instance
+ */
+ ICAL.Recur._stringToData = function(string, fmtIcal) {
+ var dict = Object.create(null);
+
+ // split is slower in FF but fast enough.
+ // v8 however this is faster then manual split?
+ var values = string.split(';');
+ var len = values.length;
+
+ for (var i = 0; i < len; i++) {
+ var parts = values[i].split('=');
+ var ucname = parts[0].toUpperCase();
+ var lcname = parts[0].toLowerCase();
+ var name = (fmtIcal ? lcname : ucname);
+ var value = parts[1];
+
+ if (ucname in partDesign) {
+ var partArr = value.split(',');
+ var partArrIdx = 0;
+ var partArrLen = partArr.length;
+
+ for (; partArrIdx < partArrLen; partArrIdx++) {
+ partArr[partArrIdx] = partDesign[ucname](partArr[partArrIdx]);
+ }
+ dict[name] = (partArr.length == 1 ? partArr[0] : partArr);
+ } else if (ucname in optionDesign) {
+ optionDesign[ucname](value, dict, fmtIcal);
+ } else {
+ // Don't swallow unknown values. Just set them as they are.
+ dict[lcname] = value;
+ }
+ }
+
+ return dict;
+ };
+})();
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.RecurIterator = (function() {
+
+ /**
+ * @classdesc
+ * An iterator for a single recurrence rule. This class usually doesn't have
+ * to be instantiated directly, the convenience method
+ * {@link ICAL.Recur#iterator} can be used.
+ *
+ * @description
+ * The options object may contain additional members when resuming iteration from a previous run
+ *
+ * @description
+ * The options object may contain additional members when resuming iteration
+ * from a previous run.
+ *
+ * @class
+ * @alias ICAL.RecurIterator
+ * @param {Object} options The iterator options
+ * @param {ICAL.Recur} options.rule The rule to iterate.
+ * @param {ICAL.Time} options.dtstart The start date of the event.
+ * @param {Boolean=} options.initialized When true, assume that options are
+ * from a previously constructed iterator. Initialization will not be
+ * repeated.
+ */
+ function icalrecur_iterator(options) {
+ this.fromData(options);
+ }
+
+ icalrecur_iterator.prototype = {
+
+ /**
+ * True when iteration is finished.
+ * @type {Boolean}
+ */
+ completed: false,
+
+ /**
+ * The rule that is being iterated
+ * @type {ICAL.Recur}
+ */
+ rule: null,
+
+ /**
+ * The start date of the event being iterated.
+ * @type {ICAL.Time}
+ */
+ dtstart: null,
+
+ /**
+ * The last occurrence that was returned from the
+ * {@link ICAL.RecurIterator#next} method.
+ * @type {ICAL.Time}
+ */
+ last: null,
+
+ /**
+ * The sequence number from the occurrence
+ * @type {Number}
+ */
+ occurrence_number: 0,
+
+ /**
+ * The indices used for the {@link ICAL.RecurIterator#by_data} object.
+ * @type {Object}
+ * @private
+ */
+ by_indices: null,
+
+ /**
+ * If true, the iterator has already been initialized
+ * @type {Boolean}
+ * @private
+ */
+ initialized: false,
+
+ /**
+ * The initializd by-data.
+ * @type {Object}
+ * @private
+ */
+ by_data: null,
+
+ /**
+ * The expanded yeardays
+ * @type {Array}
+ * @private
+ */
+ days: null,
+
+ /**
+ * The index in the {@link ICAL.RecurIterator#days} array.
+ * @type {Number}
+ * @private
+ */
+ days_index: 0,
+
+ /**
+ * Initialize the recurrence iterator from the passed data object. This
+ * method is usually not called directly, you can initialize the iterator
+ * through the constructor.
+ *
+ * @param {Object} options The iterator options
+ * @param {ICAL.Recur} options.rule The rule to iterate.
+ * @param {ICAL.Time} options.dtstart The start date of the event.
+ * @param {Boolean=} options.initialized When true, assume that options are
+ * from a previously constructed iterator. Initialization will not be
+ * repeated.
+ */
+ fromData: function(options) {
+ this.rule = ICAL.helpers.formatClassType(options.rule, ICAL.Recur);
+
+ if (!this.rule) {
+ throw new Error('iterator requires a (ICAL.Recur) rule');
+ }
+
+ this.dtstart = ICAL.helpers.formatClassType(options.dtstart, ICAL.Time);
+
+ if (!this.dtstart) {
+ throw new Error('iterator requires a (ICAL.Time) dtstart');
+ }
+
+ if (options.by_data) {
+ this.by_data = options.by_data;
+ } else {
+ this.by_data = ICAL.helpers.clone(this.rule.parts, true);
+ }
+
+ if (options.occurrence_number)
+ this.occurrence_number = options.occurrence_number;
+
+ this.days = options.days || [];
+ if (options.last) {
+ this.last = ICAL.helpers.formatClassType(options.last, ICAL.Time);
+ }
+
+ this.by_indices = options.by_indices;
+
+ if (!this.by_indices) {
+ this.by_indices = {
+ "BYSECOND": 0,
+ "BYMINUTE": 0,
+ "BYHOUR": 0,
+ "BYDAY": 0,
+ "BYMONTH": 0,
+ "BYWEEKNO": 0,
+ "BYMONTHDAY": 0
+ };
+ }
+
+ this.initialized = options.initialized || false;
+
+ if (!this.initialized) {
+ try {
+ this.init();
+ } catch (e) {
+ // Init may error if there are no possible recurrence instances from
+ // the rule, but we don't want to bubble this error up. Instead, we
+ // create an empty iterator.
+ this.completed = true;
+ }
+ }
+ },
+
+ /**
+ * Initialize the iterator
+ * @private
+ */
+ init: function icalrecur_iterator_init() {
+ this.initialized = true;
+ this.last = this.dtstart.clone();
+ var parts = this.by_data;
+
+ if ("BYDAY" in parts) {
+ // libical does this earlier when the rule is loaded, but we postpone to
+ // now so we can preserve the original order.
+ this.sort_byday_rules(parts.BYDAY);
+ }
+
+ // If the BYYEARDAY appares, no other date rule part may appear
+ if ("BYYEARDAY" in parts) {
+ if ("BYMONTH" in parts || "BYWEEKNO" in parts ||
+ "BYMONTHDAY" in parts || "BYDAY" in parts) {
+ throw new Error("Invalid BYYEARDAY rule");
+ }
+ }
+
+ // BYWEEKNO and BYMONTHDAY rule parts may not both appear
+ if ("BYWEEKNO" in parts && "BYMONTHDAY" in parts) {
+ throw new Error("BYWEEKNO does not fit to BYMONTHDAY");
+ }
+
+ // For MONTHLY recurrences (FREQ=MONTHLY) neither BYYEARDAY nor
+ // BYWEEKNO may appear.
+ if (this.rule.freq == "MONTHLY" &&
+ ("BYYEARDAY" in parts || "BYWEEKNO" in parts)) {
+ throw new Error("For MONTHLY recurrences neither BYYEARDAY nor BYWEEKNO may appear");
+ }
+
+ // For WEEKLY recurrences (FREQ=WEEKLY) neither BYMONTHDAY nor
+ // BYYEARDAY may appear.
+ if (this.rule.freq == "WEEKLY" &&
+ ("BYYEARDAY" in parts || "BYMONTHDAY" in parts)) {
+ throw new Error("For WEEKLY recurrences neither BYMONTHDAY nor BYYEARDAY may appear");
+ }
+
+ // BYYEARDAY may only appear in YEARLY rules
+ if (this.rule.freq != "YEARLY" && "BYYEARDAY" in parts) {
+ throw new Error("BYYEARDAY may only appear in YEARLY rules");
+ }
+
+ this.last.second = this.setup_defaults("BYSECOND", "SECONDLY", this.dtstart.second);
+ this.last.minute = this.setup_defaults("BYMINUTE", "MINUTELY", this.dtstart.minute);
+ this.last.hour = this.setup_defaults("BYHOUR", "HOURLY", this.dtstart.hour);
+ var dayOffset = this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day);
+ this.last.month = this.setup_defaults("BYMONTH", "MONTHLY", this.dtstart.month);
+
+ if (this.rule.freq == "WEEKLY") {
+ if ("BYDAY" in parts) {
+ var bydayParts = this.ruleDayOfWeek(parts.BYDAY[0], this.rule.wkst);
+ var pos = bydayParts[0];
+ var dow = bydayParts[1];
+ var wkdy = dow - this.last.dayOfWeek(this.rule.wkst);
+ if ((this.last.dayOfWeek(this.rule.wkst) < dow && wkdy >= 0) || wkdy < 0) {
+ // Initial time is after first day of BYDAY data
+ this.last.day += wkdy;
+ }
+ } else {
+ var dayName = ICAL.Recur.numericDayToIcalDay(this.dtstart.dayOfWeek());
+ parts.BYDAY = [dayName];
+ }
+ }
+
+ if (this.rule.freq == "YEARLY") {
+ // Some yearly recurrence rules may be specific enough to not actually
+ // occur on a yearly basis, e.g. the 29th day of February or the fifth
+ // Monday of a given month. The standard isn't clear on the intended
+ // behavior in these cases, but `libical` at least will iterate until it
+ // finds a matching year.
+ // CAREFUL: Some rules may specify an occurrence that can never happen,
+ // e.g. the first Monday of April so long as it falls on the 15th
+ // through the 21st. Detecting these is non-trivial, so ensure that we
+ // stop iterating at some point.
+ var untilYear = this.rule.until ? this.rule.until.year : 20000;
+ while (this.last.year <= untilYear) {
+ this.expand_year_days(this.last.year);
+ if (this.days.length > 0) {
+ break;
+ }
+ this.increment_year(this.rule.interval);
+ }
+
+ if (this.days.length == 0) {
+ throw new Error("No possible occurrences");
+ }
+
+ this._nextByYearDay();
+ }
+
+ if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) {
+ var tempLast = null;
+ var initLast = this.last.clone();
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+
+ // Check every weekday in BYDAY with relative dow and pos.
+ for (var i in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(i)) {
+ continue;
+ }
+ this.last = initLast.clone();
+ var bydayParts = this.ruleDayOfWeek(this.by_data.BYDAY[i]);
+ var pos = bydayParts[0];
+ var dow = bydayParts[1];
+ var dayOfMonth = this.last.nthWeekDay(dow, pos);
+
+ // If |pos| >= 6, the byday is invalid for a monthly rule.
+ if (pos >= 6 || pos <= -6) {
+ throw new Error("Malformed values in BYDAY part");
+ }
+
+ // If a Byday with pos=+/-5 is not in the current month it
+ // must be searched in the next months.
+ if (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
+ // Skip if we have already found a "last" in this month.
+ if (tempLast && tempLast.month == initLast.month) {
+ continue;
+ }
+ while (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
+ this.increment_month();
+ daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ dayOfMonth = this.last.nthWeekDay(dow, pos);
+ }
+ }
+
+ this.last.day = dayOfMonth;
+ if (!tempLast || this.last.compare(tempLast) < 0) {
+ tempLast = this.last.clone();
+ }
+ }
+ this.last = tempLast.clone();
+
+ //XXX: This feels like a hack, but we need to initialize
+ // the BYMONTHDAY case correctly and byDayAndMonthDay handles
+ // this case. It accepts a special flag which will avoid incrementing
+ // the initial value without the flag days that match the start time
+ // would be missed.
+ if (this.has_by_data('BYMONTHDAY')) {
+ this._byDayAndMonthDay(true);
+ }
+
+ if (this.last.day > daysInMonth || this.last.day == 0) {
+ throw new Error("Malformed values in BYDAY part");
+ }
+
+ } else if (this.has_by_data("BYMONTHDAY")) {
+ // Attempting to access `this.last.day` will cause the date to be normalised.
+ // So it will never be a negative value or more than the number of days in the month.
+ // We keep the value in a separate variable instead.
+
+ // Change the day value so that normalisation won't change the month.
+ this.last.day = 1;
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+
+ if (dayOffset < 0) {
+ // A negative value represents days before the end of the month.
+ this.last.day = daysInMonth + dayOffset + 1;
+ } else if (this.by_data.BYMONTHDAY[0] > daysInMonth) {
+ // There's no occurrence in this month, find the next valid month.
+ // The longest possible sequence of skipped months is February-April-June,
+ // so we might need to call next_month up to three times.
+ if (!this.next_month() && !this.next_month() && !this.next_month()) {
+ throw new Error("No possible occurrences");
+ }
+ } else {
+ // Otherwise, reset the day.
+ this.last.day = dayOffset;
+ }
+ }
+ },
+
+ /**
+ * Retrieve the next occurrence from the iterator.
+ * @return {ICAL.Time}
+ */
+ next: function icalrecur_iterator_next() {
+ if ((this.rule.count && this.occurrence_number >= this.rule.count) ||
+ (this.rule.until && this.last.compare(this.rule.until) > 0)) {
+ this.completed = true;
+ }
+
+ if (this.completed) {
+ return null;
+ }
+
+ if (this.occurrence_number == 0 && this.last.compare(this.dtstart) >= 0) {
+ // First of all, give the instance that was initialized
+ this.occurrence_number++;
+ return this.last;
+ }
+
+ var valid;
+ do {
+ valid = 1;
+
+ switch (this.rule.freq) {
+ case "SECONDLY":
+ this.next_second();
+ break;
+ case "MINUTELY":
+ this.next_minute();
+ break;
+ case "HOURLY":
+ this.next_hour();
+ break;
+ case "DAILY":
+ this.next_day();
+ break;
+ case "WEEKLY":
+ this.next_week();
+ break;
+ case "MONTHLY":
+ valid = this.next_month();
+ break;
+ case "YEARLY":
+ this.next_year();
+ break;
+
+ default:
+ return null;
+ }
+ } while (!this.check_contracting_rules() ||
+ this.last.compare(this.dtstart) < 0 ||
+ !valid);
+
+ if (this.rule.until && this.last.compare(this.rule.until) > 0) {
+ this.completed = true;
+ return null;
+ } else {
+ this.occurrence_number++;
+ return this.last;
+ }
+ },
+
+ next_second: function next_second() {
+ return this.next_generic("BYSECOND", "SECONDLY", "second", "minute");
+ },
+
+ increment_second: function increment_second(inc) {
+ return this.increment_generic(inc, "second", 60, "minute");
+ },
+
+ next_minute: function next_minute() {
+ return this.next_generic("BYMINUTE", "MINUTELY",
+ "minute", "hour", "next_second");
+ },
+
+ increment_minute: function increment_minute(inc) {
+ return this.increment_generic(inc, "minute", 60, "hour");
+ },
+
+ next_hour: function next_hour() {
+ return this.next_generic("BYHOUR", "HOURLY", "hour",
+ "monthday", "next_minute");
+ },
+
+ increment_hour: function increment_hour(inc) {
+ this.increment_generic(inc, "hour", 24, "monthday");
+ },
+
+ next_day: function next_day() {
+ var has_by_day = ("BYDAY" in this.by_data);
+ var this_freq = (this.rule.freq == "DAILY");
+
+ if (this.next_hour() == 0) {
+ return 0;
+ }
+
+ if (this_freq) {
+ this.increment_monthday(this.rule.interval);
+ } else {
+ this.increment_monthday(1);
+ }
+
+ return 0;
+ },
+
+ next_week: function next_week() {
+ var end_of_data = 0;
+
+ if (this.next_weekday_by_week() == 0) {
+ return end_of_data;
+ }
+
+ if (this.has_by_data("BYWEEKNO")) {
+ var idx = ++this.by_indices.BYWEEKNO;
+
+ if (this.by_indices.BYWEEKNO == this.by_data.BYWEEKNO.length) {
+ this.by_indices.BYWEEKNO = 0;
+ end_of_data = 1;
+ }
+
+ // HACK should be first month of the year
+ this.last.month = 1;
+ this.last.day = 1;
+
+ var week_no = this.by_data.BYWEEKNO[this.by_indices.BYWEEKNO];
+
+ this.last.day += 7 * week_no;
+
+ if (end_of_data) {
+ this.increment_year(1);
+ }
+ } else {
+ // Jump to the next week
+ this.increment_monthday(7 * this.rule.interval);
+ }
+
+ return end_of_data;
+ },
+
+ /**
+ * Normalize each by day rule for a given year/month.
+ * Takes into account ordering and negative rules
+ *
+ * @private
+ * @param {Number} year Current year.
+ * @param {Number} month Current month.
+ * @param {Array} rules Array of rules.
+ *
+ * @return {Array} sorted and normalized rules.
+ * Negative rules will be expanded to their
+ * correct positive values for easier processing.
+ */
+ normalizeByMonthDayRules: function(year, month, rules) {
+ var daysInMonth = ICAL.Time.daysInMonth(month, year);
+
+ // XXX: This is probably bad for performance to allocate
+ // a new array for each month we scan, if possible
+ // we should try to optimize this...
+ var newRules = [];
+
+ var ruleIdx = 0;
+ var len = rules.length;
+ var rule;
+
+ for (; ruleIdx < len; ruleIdx++) {
+ rule = rules[ruleIdx];
+
+ // if this rule falls outside of given
+ // month discard it.
+ if (Math.abs(rule) > daysInMonth) {
+ continue;
+ }
+
+ // negative case
+ if (rule < 0) {
+ // we add (not subtract it is a negative number)
+ // one from the rule because 1 === last day of month
+ rule = daysInMonth + (rule + 1);
+ } else if (rule === 0) {
+ // skip zero: it is invalid.
+ continue;
+ }
+
+ // only add unique items...
+ if (newRules.indexOf(rule) === -1) {
+ newRules.push(rule);
+ }
+
+ }
+
+ // unique and sort
+ return newRules.sort(function(a, b) { return a - b; });
+ },
+
+ /**
+ * NOTES:
+ * We are given a list of dates in the month (BYMONTHDAY) (23, etc..)
+ * Also we are given a list of days (BYDAY) (MO, 2SU, etc..) when
+ * both conditions match a given date (this.last.day) iteration stops.
+ *
+ * @private
+ * @param {Boolean=} isInit When given true will not increment the
+ * current day (this.last).
+ */
+ _byDayAndMonthDay: function(isInit) {
+ var byMonthDay; // setup in initMonth
+ var byDay = this.by_data.BYDAY;
+
+ var date;
+ var dateIdx = 0;
+ var dateLen; // setup in initMonth
+ var dayLen = byDay.length;
+
+ // we are not valid by default
+ var dataIsValid = 0;
+
+ var daysInMonth;
+ var self = this;
+ // we need a copy of this, because a DateTime gets normalized
+ // automatically if the day is out of range. At some points we
+ // set the last day to 0 to start counting.
+ var lastDay = this.last.day;
+
+ function initMonth() {
+ daysInMonth = ICAL.Time.daysInMonth(
+ self.last.month, self.last.year
+ );
+
+ byMonthDay = self.normalizeByMonthDayRules(
+ self.last.year,
+ self.last.month,
+ self.by_data.BYMONTHDAY
+ );
+
+ dateLen = byMonthDay.length;
+
+ // For the case of more than one occurrence in one month
+ // we have to be sure to start searching after the last
+ // found date or at the last BYMONTHDAY, unless we are
+ // initializing the iterator because in this case we have
+ // to consider the last found date too.
+ while (byMonthDay[dateIdx] <= lastDay &&
+ !(isInit && byMonthDay[dateIdx] == lastDay) &&
+ dateIdx < dateLen - 1) {
+ dateIdx++;
+ }
+ }
+
+ function nextMonth() {
+ // since the day is incremented at the start
+ // of the loop below, we need to start at 0
+ lastDay = 0;
+ self.increment_month();
+ dateIdx = 0;
+ initMonth();
+ }
+
+ initMonth();
+
+ // should come after initMonth
+ if (isInit) {
+ lastDay -= 1;
+ }
+
+ // Use a counter to avoid an infinite loop with malformed rules.
+ // Stop checking after 4 years so we consider also a leap year.
+ var monthsCounter = 48;
+
+ while (!dataIsValid && monthsCounter) {
+ monthsCounter--;
+ // increment the current date. This is really
+ // important otherwise we may fall into the infinite
+ // loop trap. The initial date takes care of the case
+ // where the current date is the date we are looking
+ // for.
+ date = lastDay + 1;
+
+ if (date > daysInMonth) {
+ nextMonth();
+ continue;
+ }
+
+ // find next date
+ var next = byMonthDay[dateIdx++];
+
+ // this logic is dependent on the BYMONTHDAYS
+ // being in order (which is done by #normalizeByMonthDayRules)
+ if (next >= date) {
+ // if the next month day is in the future jump to it.
+ lastDay = next;
+ } else {
+ // in this case the 'next' monthday has past
+ // we must move to the month.
+ nextMonth();
+ continue;
+ }
+
+ // Now we can loop through the day rules to see
+ // if one matches the current month date.
+ for (var dayIdx = 0; dayIdx < dayLen; dayIdx++) {
+ var parts = this.ruleDayOfWeek(byDay[dayIdx]);
+ var pos = parts[0];
+ var dow = parts[1];
+
+ this.last.day = lastDay;
+ if (this.last.isNthWeekDay(dow, pos)) {
+ // when we find the valid one we can mark
+ // the conditions as met and break the loop.
+ // (Because we have this condition above
+ // it will also break the parent loop).
+ dataIsValid = 1;
+ break;
+ }
+ }
+
+ // It is completely possible that the combination
+ // cannot be matched in the current month.
+ // When we reach the end of possible combinations
+ // in the current month we iterate to the next one.
+ // since dateIdx is incremented right after getting
+ // "next", we don't need dateLen -1 here.
+ if (!dataIsValid && dateIdx === dateLen) {
+ nextMonth();
+ continue;
+ }
+ }
+
+ if (monthsCounter <= 0) {
+ // Checked 4 years without finding a Byday that matches
+ // a Bymonthday. Maybe the rule is not correct.
+ throw new Error("Malformed values in BYDAY combined with BYMONTHDAY parts");
+ }
+
+
+ return dataIsValid;
+ },
+
+ next_month: function next_month() {
+ var this_freq = (this.rule.freq == "MONTHLY");
+ var data_valid = 1;
+
+ if (this.next_hour() == 0) {
+ return data_valid;
+ }
+
+ if (this.has_by_data("BYDAY") && this.has_by_data("BYMONTHDAY")) {
+ data_valid = this._byDayAndMonthDay();
+ } else if (this.has_by_data("BYDAY")) {
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ var setpos = 0;
+ var setpos_total = 0;
+
+ if (this.has_by_data("BYSETPOS")) {
+ var last_day = this.last.day;
+ for (var day = 1; day <= daysInMonth; day++) {
+ this.last.day = day;
+ if (this.is_day_in_byday(this.last)) {
+ setpos_total++;
+ if (day <= last_day) {
+ setpos++;
+ }
+ }
+ }
+ this.last.day = last_day;
+ }
+
+ data_valid = 0;
+ for (var day = this.last.day + 1; day <= daysInMonth; day++) {
+ this.last.day = day;
+
+ if (this.is_day_in_byday(this.last)) {
+ if (!this.has_by_data("BYSETPOS") ||
+ this.check_set_position(++setpos) ||
+ this.check_set_position(setpos - setpos_total - 1)) {
+
+ data_valid = 1;
+ break;
+ }
+ }
+ }
+
+ if (day > daysInMonth) {
+ this.last.day = 1;
+ this.increment_month();
+
+ if (this.is_day_in_byday(this.last)) {
+ if (!this.has_by_data("BYSETPOS") || this.check_set_position(1)) {
+ data_valid = 1;
+ }
+ } else {
+ data_valid = 0;
+ }
+ }
+ } else if (this.has_by_data("BYMONTHDAY")) {
+ this.by_indices.BYMONTHDAY++;
+
+ if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
+ this.by_indices.BYMONTHDAY = 0;
+ this.increment_month();
+ }
+
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ var day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY];
+
+ if (day < 0) {
+ day = daysInMonth + day + 1;
+ }
+
+ if (day > daysInMonth) {
+ this.last.day = 1;
+ data_valid = this.is_day_in_byday(this.last);
+ } else {
+ this.last.day = day;
+ }
+
+ } else {
+ this.increment_month();
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ if (this.by_data.BYMONTHDAY[0] > daysInMonth) {
+ data_valid = 0;
+ } else {
+ this.last.day = this.by_data.BYMONTHDAY[0];
+ }
+ }
+
+ return data_valid;
+ },
+
+ next_weekday_by_week: function next_weekday_by_week() {
+ var end_of_data = 0;
+
+ if (this.next_hour() == 0) {
+ return end_of_data;
+ }
+
+ if (!this.has_by_data("BYDAY")) {
+ return 1;
+ }
+
+ for (;;) {
+ var tt = new ICAL.Time();
+ this.by_indices.BYDAY++;
+
+ if (this.by_indices.BYDAY == Object.keys(this.by_data.BYDAY).length) {
+ this.by_indices.BYDAY = 0;
+ end_of_data = 1;
+ }
+
+ var coded_day = this.by_data.BYDAY[this.by_indices.BYDAY];
+ var parts = this.ruleDayOfWeek(coded_day);
+ var dow = parts[1];
+
+ dow -= this.rule.wkst;
+
+ if (dow < 0) {
+ dow += 7;
+ }
+
+ tt.year = this.last.year;
+ tt.month = this.last.month;
+ tt.day = this.last.day;
+
+ var startOfWeek = tt.startDoyWeek(this.rule.wkst);
+
+ if (dow + startOfWeek < 1) {
+ // The selected date is in the previous year
+ if (!end_of_data) {
+ continue;
+ }
+ }
+
+ var next = ICAL.Time.fromDayOfYear(startOfWeek + dow,
+ this.last.year);
+
+ /**
+ * The normalization horrors below are due to
+ * the fact that when the year/month/day changes
+ * it can effect the other operations that come after.
+ */
+ this.last.year = next.year;
+ this.last.month = next.month;
+ this.last.day = next.day;
+
+ return end_of_data;
+ }
+ },
+
+ next_year: function next_year() {
+
+ if (this.next_hour() == 0) {
+ return 0;
+ }
+
+ if (++this.days_index == this.days.length) {
+ this.days_index = 0;
+ do {
+ this.increment_year(this.rule.interval);
+ this.expand_year_days(this.last.year);
+ } while (this.days.length == 0);
+ }
+
+ this._nextByYearDay();
+
+ return 1;
+ },
+
+ _nextByYearDay: function _nextByYearDay() {
+ var doy = this.days[this.days_index];
+ var year = this.last.year;
+ if (doy < 1) {
+ // Time.fromDayOfYear(doy, year) indexes relative to the
+ // start of the given year. That is different from the
+ // semantics of BYYEARDAY where negative indexes are an
+ // offset from the end of the given year.
+ doy += 1;
+ year += 1;
+ }
+ var next = ICAL.Time.fromDayOfYear(doy, year);
+ this.last.day = next.day;
+ this.last.month = next.month;
+ },
+
+ /**
+ * @param dow (eg: '1TU', '-1MO')
+ * @param {ICAL.Time.weekDay=} aWeekStart The week start weekday
+ * @return [pos, numericDow] (eg: [1, 3]) numericDow is relative to aWeekStart
+ */
+ ruleDayOfWeek: function ruleDayOfWeek(dow, aWeekStart) {
+ var matches = dow.match(/([+-]?[0-9])?(MO|TU|WE|TH|FR|SA|SU)/);
+ if (matches) {
+ var pos = parseInt(matches[1] || 0, 10);
+ dow = ICAL.Recur.icalDayToNumericDay(matches[2], aWeekStart);
+ return [pos, dow];
+ } else {
+ return [0, 0];
+ }
+ },
+
+ next_generic: function next_generic(aRuleType, aInterval, aDateAttr,
+ aFollowingAttr, aPreviousIncr) {
+ var has_by_rule = (aRuleType in this.by_data);
+ var this_freq = (this.rule.freq == aInterval);
+ var end_of_data = 0;
+
+ if (aPreviousIncr && this[aPreviousIncr]() == 0) {
+ return end_of_data;
+ }
+
+ if (has_by_rule) {
+ this.by_indices[aRuleType]++;
+ var idx = this.by_indices[aRuleType];
+ var dta = this.by_data[aRuleType];
+
+ if (this.by_indices[aRuleType] == dta.length) {
+ this.by_indices[aRuleType] = 0;
+ end_of_data = 1;
+ }
+ this.last[aDateAttr] = dta[this.by_indices[aRuleType]];
+ } else if (this_freq) {
+ this["increment_" + aDateAttr](this.rule.interval);
+ }
+
+ if (has_by_rule && end_of_data && this_freq) {
+ this["increment_" + aFollowingAttr](1);
+ }
+
+ return end_of_data;
+ },
+
+ increment_monthday: function increment_monthday(inc) {
+ for (var i = 0; i < inc; i++) {
+ var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year);
+ this.last.day++;
+
+ if (this.last.day > daysInMonth) {
+ this.last.day -= daysInMonth;
+ this.increment_month();
+ }
+ }
+ },
+
+ increment_month: function increment_month() {
+ this.last.day = 1;
+ if (this.has_by_data("BYMONTH")) {
+ this.by_indices.BYMONTH++;
+
+ if (this.by_indices.BYMONTH == this.by_data.BYMONTH.length) {
+ this.by_indices.BYMONTH = 0;
+ this.increment_year(1);
+ }
+
+ this.last.month = this.by_data.BYMONTH[this.by_indices.BYMONTH];
+ } else {
+ if (this.rule.freq == "MONTHLY") {
+ this.last.month += this.rule.interval;
+ } else {
+ this.last.month++;
+ }
+
+ this.last.month--;
+ var years = ICAL.helpers.trunc(this.last.month / 12);
+ this.last.month %= 12;
+ this.last.month++;
+
+ if (years != 0) {
+ this.increment_year(years);
+ }
+ }
+ },
+
+ increment_year: function increment_year(inc) {
+ this.last.year += inc;
+ },
+
+ increment_generic: function increment_generic(inc, aDateAttr,
+ aFactor, aNextIncrement) {
+ this.last[aDateAttr] += inc;
+ var nextunit = ICAL.helpers.trunc(this.last[aDateAttr] / aFactor);
+ this.last[aDateAttr] %= aFactor;
+ if (nextunit != 0) {
+ this["increment_" + aNextIncrement](nextunit);
+ }
+ },
+
+ has_by_data: function has_by_data(aRuleType) {
+ return (aRuleType in this.rule.parts);
+ },
+
+ expand_year_days: function expand_year_days(aYear) {
+ var t = new ICAL.Time();
+ this.days = [];
+
+ // We need our own copy with a few keys set
+ var parts = {};
+ var rules = ["BYDAY", "BYWEEKNO", "BYMONTHDAY", "BYMONTH", "BYYEARDAY"];
+ for (var p in rules) {
+ /* istanbul ignore else */
+ if (rules.hasOwnProperty(p)) {
+ var part = rules[p];
+ if (part in this.rule.parts) {
+ parts[part] = this.rule.parts[part];
+ }
+ }
+ }
+
+ if ("BYMONTH" in parts && "BYWEEKNO" in parts) {
+ var valid = 1;
+ var validWeeks = {};
+ t.year = aYear;
+ t.isDate = true;
+
+ for (var monthIdx = 0; monthIdx < this.by_data.BYMONTH.length; monthIdx++) {
+ var month = this.by_data.BYMONTH[monthIdx];
+ t.month = month;
+ t.day = 1;
+ var first_week = t.weekNumber(this.rule.wkst);
+ t.day = ICAL.Time.daysInMonth(month, aYear);
+ var last_week = t.weekNumber(this.rule.wkst);
+ for (monthIdx = first_week; monthIdx < last_week; monthIdx++) {
+ validWeeks[monthIdx] = 1;
+ }
+ }
+
+ for (var weekIdx = 0; weekIdx < this.by_data.BYWEEKNO.length && valid; weekIdx++) {
+ var weekno = this.by_data.BYWEEKNO[weekIdx];
+ if (weekno < 52) {
+ valid &= validWeeks[weekIdx];
+ } else {
+ valid = 0;
+ }
+ }
+
+ if (valid) {
+ delete parts.BYMONTH;
+ } else {
+ delete parts.BYWEEKNO;
+ }
+ }
+
+ var partCount = Object.keys(parts).length;
+
+ if (partCount == 0) {
+ var t1 = this.dtstart.clone();
+ t1.year = this.last.year;
+ this.days.push(t1.dayOfYear());
+ } else if (partCount == 1 && "BYMONTH" in parts) {
+ for (var monthkey in this.by_data.BYMONTH) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) {
+ continue;
+ }
+ var t2 = this.dtstart.clone();
+ t2.year = aYear;
+ t2.month = this.by_data.BYMONTH[monthkey];
+ t2.isDate = true;
+ this.days.push(t2.dayOfYear());
+ }
+ } else if (partCount == 1 && "BYMONTHDAY" in parts) {
+ for (var monthdaykey in this.by_data.BYMONTHDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTHDAY.hasOwnProperty(monthdaykey)) {
+ continue;
+ }
+ var t3 = this.dtstart.clone();
+ var day_ = this.by_data.BYMONTHDAY[monthdaykey];
+ if (day_ < 0) {
+ var daysInMonth = ICAL.Time.daysInMonth(t3.month, aYear);
+ day_ = day_ + daysInMonth + 1;
+ }
+ t3.day = day_;
+ t3.year = aYear;
+ t3.isDate = true;
+ this.days.push(t3.dayOfYear());
+ }
+ } else if (partCount == 2 &&
+ "BYMONTHDAY" in parts &&
+ "BYMONTH" in parts) {
+ for (var monthkey in this.by_data.BYMONTH) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) {
+ continue;
+ }
+ var month_ = this.by_data.BYMONTH[monthkey];
+ var daysInMonth = ICAL.Time.daysInMonth(month_, aYear);
+ for (var monthdaykey in this.by_data.BYMONTHDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTHDAY.hasOwnProperty(monthdaykey)) {
+ continue;
+ }
+ var day_ = this.by_data.BYMONTHDAY[monthdaykey];
+ if (day_ < 0) {
+ day_ = day_ + daysInMonth + 1;
+ }
+ t.day = day_;
+ t.month = month_;
+ t.year = aYear;
+ t.isDate = true;
+
+ this.days.push(t.dayOfYear());
+ }
+ }
+ } else if (partCount == 1 && "BYWEEKNO" in parts) {
+ // TODO unimplemented in libical
+ } else if (partCount == 2 &&
+ "BYWEEKNO" in parts &&
+ "BYMONTHDAY" in parts) {
+ // TODO unimplemented in libical
+ } else if (partCount == 1 && "BYDAY" in parts) {
+ this.days = this.days.concat(this.expand_by_day(aYear));
+ } else if (partCount == 2 && "BYDAY" in parts && "BYMONTH" in parts) {
+ for (var monthkey in this.by_data.BYMONTH) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) {
+ continue;
+ }
+ var month = this.by_data.BYMONTH[monthkey];
+ var daysInMonth = ICAL.Time.daysInMonth(month, aYear);
+
+ t.year = aYear;
+ t.month = this.by_data.BYMONTH[monthkey];
+ t.day = 1;
+ t.isDate = true;
+
+ var first_dow = t.dayOfWeek();
+ var doy_offset = t.dayOfYear() - 1;
+
+ t.day = daysInMonth;
+ var last_dow = t.dayOfWeek();
+
+ if (this.has_by_data("BYSETPOS")) {
+ var set_pos_counter = 0;
+ var by_month_day = [];
+ for (var day = 1; day <= daysInMonth; day++) {
+ t.day = day;
+ if (this.is_day_in_byday(t)) {
+ by_month_day.push(day);
+ }
+ }
+
+ for (var spIndex = 0; spIndex < by_month_day.length; spIndex++) {
+ if (this.check_set_position(spIndex + 1) ||
+ this.check_set_position(spIndex - by_month_day.length)) {
+ this.days.push(doy_offset + by_month_day[spIndex]);
+ }
+ }
+ } else {
+ for (var daycodedkey in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(daycodedkey)) {
+ continue;
+ }
+ var coded_day = this.by_data.BYDAY[daycodedkey];
+ var bydayParts = this.ruleDayOfWeek(coded_day);
+ var pos = bydayParts[0];
+ var dow = bydayParts[1];
+ var month_day;
+
+ var first_matching_day = ((dow + 7 - first_dow) % 7) + 1;
+ var last_matching_day = daysInMonth - ((last_dow + 7 - dow) % 7);
+
+ if (pos == 0) {
+ for (var day = first_matching_day; day <= daysInMonth; day += 7) {
+ this.days.push(doy_offset + day);
+ }
+ } else if (pos > 0) {
+ month_day = first_matching_day + (pos - 1) * 7;
+
+ if (month_day <= daysInMonth) {
+ this.days.push(doy_offset + month_day);
+ }
+ } else {
+ month_day = last_matching_day + (pos + 1) * 7;
+
+ if (month_day > 0) {
+ this.days.push(doy_offset + month_day);
+ }
+ }
+ }
+ }
+ }
+ // Return dates in order of occurrence (1,2,3,...) instead
+ // of by groups of weekdays (1,8,15,...,2,9,16,...).
+ this.days.sort(function(a, b) { return a - b; }); // Comparator function allows to sort numbers.
+ } else if (partCount == 2 && "BYDAY" in parts && "BYMONTHDAY" in parts) {
+ var expandedDays = this.expand_by_day(aYear);
+
+ for (var daykey in expandedDays) {
+ /* istanbul ignore if */
+ if (!expandedDays.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = expandedDays[daykey];
+ var tt = ICAL.Time.fromDayOfYear(day, aYear);
+ if (this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) {
+ this.days.push(day);
+ }
+ }
+ } else if (partCount == 3 &&
+ "BYDAY" in parts &&
+ "BYMONTHDAY" in parts &&
+ "BYMONTH" in parts) {
+ var expandedDays = this.expand_by_day(aYear);
+
+ for (var daykey in expandedDays) {
+ /* istanbul ignore if */
+ if (!expandedDays.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = expandedDays[daykey];
+ var tt = ICAL.Time.fromDayOfYear(day, aYear);
+
+ if (this.by_data.BYMONTH.indexOf(tt.month) >= 0 &&
+ this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) {
+ this.days.push(day);
+ }
+ }
+ } else if (partCount == 2 && "BYDAY" in parts && "BYWEEKNO" in parts) {
+ var expandedDays = this.expand_by_day(aYear);
+
+ for (var daykey in expandedDays) {
+ /* istanbul ignore if */
+ if (!expandedDays.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = expandedDays[daykey];
+ var tt = ICAL.Time.fromDayOfYear(day, aYear);
+ var weekno = tt.weekNumber(this.rule.wkst);
+
+ if (this.by_data.BYWEEKNO.indexOf(weekno)) {
+ this.days.push(day);
+ }
+ }
+ } else if (partCount == 3 &&
+ "BYDAY" in parts &&
+ "BYWEEKNO" in parts &&
+ "BYMONTHDAY" in parts) {
+ // TODO unimplemted in libical
+ } else if (partCount == 1 && "BYYEARDAY" in parts) {
+ this.days = this.days.concat(this.by_data.BYYEARDAY);
+ } else {
+ this.days = [];
+ }
+ return 0;
+ },
+
+ expand_by_day: function expand_by_day(aYear) {
+
+ var days_list = [];
+ var tmp = this.last.clone();
+
+ tmp.year = aYear;
+ tmp.month = 1;
+ tmp.day = 1;
+ tmp.isDate = true;
+
+ var start_dow = tmp.dayOfWeek();
+
+ tmp.month = 12;
+ tmp.day = 31;
+ tmp.isDate = true;
+
+ var end_dow = tmp.dayOfWeek();
+ var end_year_day = tmp.dayOfYear();
+
+ for (var daykey in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = this.by_data.BYDAY[daykey];
+ var parts = this.ruleDayOfWeek(day);
+ var pos = parts[0];
+ var dow = parts[1];
+
+ if (pos == 0) {
+ var tmp_start_doy = ((dow + 7 - start_dow) % 7) + 1;
+
+ for (var doy = tmp_start_doy; doy <= end_year_day; doy += 7) {
+ days_list.push(doy);
+ }
+
+ } else if (pos > 0) {
+ var first;
+ if (dow >= start_dow) {
+ first = dow - start_dow + 1;
+ } else {
+ first = dow - start_dow + 8;
+ }
+
+ days_list.push(first + (pos - 1) * 7);
+ } else {
+ var last;
+ pos = -pos;
+
+ if (dow <= end_dow) {
+ last = end_year_day - end_dow + dow;
+ } else {
+ last = end_year_day - end_dow + dow - 7;
+ }
+
+ days_list.push(last - (pos - 1) * 7);
+ }
+ }
+ return days_list;
+ },
+
+ is_day_in_byday: function is_day_in_byday(tt) {
+ for (var daykey in this.by_data.BYDAY) {
+ /* istanbul ignore if */
+ if (!this.by_data.BYDAY.hasOwnProperty(daykey)) {
+ continue;
+ }
+ var day = this.by_data.BYDAY[daykey];
+ var parts = this.ruleDayOfWeek(day);
+ var pos = parts[0];
+ var dow = parts[1];
+ var this_dow = tt.dayOfWeek();
+
+ if ((pos == 0 && dow == this_dow) ||
+ (tt.nthWeekDay(dow, pos) == tt.day)) {
+ return 1;
+ }
+ }
+
+ return 0;
+ },
+
+ /**
+ * Checks if given value is in BYSETPOS.
+ *
+ * @private
+ * @param {Numeric} aPos position to check for.
+ * @return {Boolean} false unless BYSETPOS rules exist
+ * and the given value is present in rules.
+ */
+ check_set_position: function check_set_position(aPos) {
+ if (this.has_by_data('BYSETPOS')) {
+ var idx = this.by_data.BYSETPOS.indexOf(aPos);
+ // negative numbers are not false-y
+ return idx !== -1;
+ }
+ return false;
+ },
+
+ sort_byday_rules: function icalrecur_sort_byday_rules(aRules) {
+ for (var i = 0; i < aRules.length; i++) {
+ for (var j = 0; j < i; j++) {
+ var one = this.ruleDayOfWeek(aRules[j], this.rule.wkst)[1];
+ var two = this.ruleDayOfWeek(aRules[i], this.rule.wkst)[1];
+
+ if (one > two) {
+ var tmp = aRules[i];
+ aRules[i] = aRules[j];
+ aRules[j] = tmp;
+ }
+ }
+ }
+ },
+
+ check_contract_restriction: function check_contract_restriction(aRuleType, v) {
+ var indexMapValue = icalrecur_iterator._indexMap[aRuleType];
+ var ruleMapValue = icalrecur_iterator._expandMap[this.rule.freq][indexMapValue];
+ var pass = false;
+
+ if (aRuleType in this.by_data &&
+ ruleMapValue == icalrecur_iterator.CONTRACT) {
+
+ var ruleType = this.by_data[aRuleType];
+
+ for (var bydatakey in ruleType) {
+ /* istanbul ignore else */
+ if (ruleType.hasOwnProperty(bydatakey)) {
+ if (ruleType[bydatakey] == v) {
+ pass = true;
+ break;
+ }
+ }
+ }
+ } else {
+ // Not a contracting byrule or has no data, test passes
+ pass = true;
+ }
+ return pass;
+ },
+
+ check_contracting_rules: function check_contracting_rules() {
+ var dow = this.last.dayOfWeek();
+ var weekNo = this.last.weekNumber(this.rule.wkst);
+ var doy = this.last.dayOfYear();
+
+ return (this.check_contract_restriction("BYSECOND", this.last.second) &&
+ this.check_contract_restriction("BYMINUTE", this.last.minute) &&
+ this.check_contract_restriction("BYHOUR", this.last.hour) &&
+ this.check_contract_restriction("BYDAY", ICAL.Recur.numericDayToIcalDay(dow)) &&
+ this.check_contract_restriction("BYWEEKNO", weekNo) &&
+ this.check_contract_restriction("BYMONTHDAY", this.last.day) &&
+ this.check_contract_restriction("BYMONTH", this.last.month) &&
+ this.check_contract_restriction("BYYEARDAY", doy));
+ },
+
+ setup_defaults: function setup_defaults(aRuleType, req, deftime) {
+ var indexMapValue = icalrecur_iterator._indexMap[aRuleType];
+ var ruleMapValue = icalrecur_iterator._expandMap[this.rule.freq][indexMapValue];
+
+ if (ruleMapValue != icalrecur_iterator.CONTRACT) {
+ if (!(aRuleType in this.by_data)) {
+ this.by_data[aRuleType] = [deftime];
+ }
+ if (this.rule.freq != req) {
+ return this.by_data[aRuleType][0];
+ }
+ }
+ return deftime;
+ },
+
+ /**
+ * Convert iterator into a serialize-able object. Will preserve current
+ * iteration sequence to ensure the seamless continuation of the recurrence
+ * rule.
+ * @return {Object}
+ */
+ toJSON: function() {
+ var result = Object.create(null);
+
+ result.initialized = this.initialized;
+ result.rule = this.rule.toJSON();
+ result.dtstart = this.dtstart.toJSON();
+ result.by_data = this.by_data;
+ result.days = this.days;
+ result.last = this.last.toJSON();
+ result.by_indices = this.by_indices;
+ result.occurrence_number = this.occurrence_number;
+
+ return result;
+ }
+ };
+
+ icalrecur_iterator._indexMap = {
+ "BYSECOND": 0,
+ "BYMINUTE": 1,
+ "BYHOUR": 2,
+ "BYDAY": 3,
+ "BYMONTHDAY": 4,
+ "BYYEARDAY": 5,
+ "BYWEEKNO": 6,
+ "BYMONTH": 7,
+ "BYSETPOS": 8
+ };
+
+ icalrecur_iterator._expandMap = {
+ "SECONDLY": [1, 1, 1, 1, 1, 1, 1, 1],
+ "MINUTELY": [2, 1, 1, 1, 1, 1, 1, 1],
+ "HOURLY": [2, 2, 1, 1, 1, 1, 1, 1],
+ "DAILY": [2, 2, 2, 1, 1, 1, 1, 1],
+ "WEEKLY": [2, 2, 2, 2, 3, 3, 1, 1],
+ "MONTHLY": [2, 2, 2, 2, 2, 3, 3, 1],
+ "YEARLY": [2, 2, 2, 2, 2, 2, 2, 2]
+ };
+ icalrecur_iterator.UNKNOWN = 0;
+ icalrecur_iterator.CONTRACT = 1;
+ icalrecur_iterator.EXPAND = 2;
+ icalrecur_iterator.ILLEGAL = 3;
+
+ return icalrecur_iterator;
+
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.RecurExpansion = (function() {
+ function formatTime(item) {
+ return ICAL.helpers.formatClassType(item, ICAL.Time);
+ }
+
+ function compareTime(a, b) {
+ return a.compare(b);
+ }
+
+ function isRecurringComponent(comp) {
+ return comp.hasProperty('rdate') ||
+ comp.hasProperty('rrule') ||
+ comp.hasProperty('recurrence-id');
+ }
+
+ /**
+ * @classdesc
+ * 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 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));
+ *
+ * @description
+ * 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.
+ *
+ * @class
+ * @alias ICAL.RecurExpansion
+ * @param {Object} options
+ * Recurrence expansion options
+ * @param {ICAL.Time} options.dtstart
+ * Start time of the event
+ * @param {ICAL.Component=} options.component
+ * Component for expansion, required if not resuming.
+ */
+ function RecurExpansion(options) {
+ this.ruleDates = [];
+ this.exDates = [];
+ this.fromData(options);
+ }
+
+ RecurExpansion.prototype = {
+ /**
+ * True when iteration is fully completed.
+ * @type {Boolean}
+ */
+ complete: false,
+
+ /**
+ * Array of rrule iterators.
+ *
+ * @type {ICAL.RecurIterator[]}
+ * @private
+ */
+ ruleIterators: null,
+
+ /**
+ * Array of rdate instances.
+ *
+ * @type {ICAL.Time[]}
+ * @private
+ */
+ ruleDates: null,
+
+ /**
+ * Array of exdate instances.
+ *
+ * @type {ICAL.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 {ICAL.Time}
+ * @private
+ */
+ exDate: null,
+
+ /**
+ * Current additional date.
+ *
+ * @type {ICAL.Time}
+ * @private
+ */
+ ruleDate: null,
+
+ /**
+ * Start date of recurring rules.
+ *
+ * @type {ICAL.Time}
+ */
+ dtstart: null,
+
+ /**
+ * Last expanded time
+ *
+ * @type {ICAL.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 {ICAL.Time} options.dtstart
+ * Start time of the event
+ * @param {ICAL.Component=} options.component
+ * Component for expansion, required if not resuming.
+ */
+ fromData: function(options) {
+ var start = ICAL.helpers.formatClassType(options.dtstart, ICAL.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 = formatTime(options.last) || start.clone();
+
+ if (!options.ruleIterators) {
+ throw new Error('.ruleIterators or .component must be given');
+ }
+
+ this.ruleIterators = options.ruleIterators.map(function(item) {
+ return ICAL.helpers.formatClassType(item, ICAL.RecurIterator);
+ });
+
+ this.ruleDateInc = options.ruleDateInc;
+ this.exDateInc = options.exDateInc;
+
+ if (options.ruleDates) {
+ this.ruleDates = options.ruleDates.map(formatTime);
+ this.ruleDate = this.ruleDates[this.ruleDateInc];
+ }
+
+ if (options.exDates) {
+ this.exDates = options.exDates.map(formatTime);
+ this.exDate = this.exDates[this.exDateInc];
+ }
+
+ if (typeof(options.complete) !== 'undefined') {
+ this.complete = options.complete;
+ }
+ }
+ },
+
+ /**
+ * Retrieve the next occurrence in the series.
+ * @return {ICAL.Time}
+ */
+ next: function() {
+ var iter;
+ var ruleOfDay;
+ var next;
+ var compare;
+
+ var maxTries = 500;
+ var 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() {
+ function toJSON(item) {
+ return item.toJSON();
+ }
+
+ var 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 {ICAL.Component} component The component to search in
+ * @param {String} propertyName The property name to search for
+ * @return {ICAL.Time[]} The extracted dates.
+ */
+ _extractDates: function(component, propertyName) {
+ function handleProp(prop) {
+ idx = ICAL.helpers.binsearchInsert(
+ result,
+ prop,
+ compareTime
+ );
+
+ // ordered insert
+ result.splice(idx, 0, prop);
+ }
+
+ var result = [];
+ var props = component.getAllProperties(propertyName);
+ var len = props.length;
+ var i = 0;
+ var prop;
+
+ var idx;
+
+ for (; i < len; i++) {
+ props[i].getValues().forEach(handleProp);
+ }
+
+ return result;
+ },
+
+ /**
+ * Initialize the recurrence expansion.
+ *
+ * @private
+ * @param {ICAL.Component} component The component to initialize from.
+ */
+ _init: function(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 (!isRecurringComponent(component)) {
+ 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 = ICAL.helpers.binsearchInsert(
+ this.ruleDates,
+ this.last,
+ compareTime
+ );
+ }
+
+ this.ruleDate = this.ruleDates[this.ruleDateInc];
+ }
+
+ if (component.hasProperty('rrule')) {
+ var rules = component.getAllProperties('rrule');
+ var i = 0;
+ var len = rules.length;
+
+ var rule;
+ var 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 = ICAL.helpers.binsearchInsert(
+ this.exDates,
+ this.last,
+ compareTime
+ );
+
+ this.exDate = this.exDates[this.exDateInc];
+ }
+ },
+
+ /**
+ * Advance to the next exdate
+ * @private
+ */
+ _nextExDay: function() {
+ this.exDate = this.exDates[++this.exDateInc];
+ },
+
+ /**
+ * Advance to the next rule date
+ * @private
+ */
+ _nextRuleDay: function() {
+ this.ruleDate = this.ruleDates[++this.ruleDateInc];
+ },
+
+ /**
+ * Find and return the recurrence rule with the most recent event and
+ * return it.
+ *
+ * @private
+ * @return {?ICAL.RecurIterator} Found iterator.
+ */
+ _nextRecurrenceIter: function() {
+ var iters = this.ruleIterators;
+
+ if (iters.length === 0) {
+ return null;
+ }
+
+ var len = iters.length;
+ var iter;
+ var iterTime;
+ var iterIdx = 0;
+ var 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;
+ }
+ };
+
+ return RecurExpansion;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.Event = (function() {
+
+ /**
+ * @classdesc
+ * 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.
+ *
+ * @class
+ * @alias ICAL.Event
+ * @param {ICAL.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<ICAL.Component|ICAL.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
+ */
+ function Event(component, options) {
+ if (!(component instanceof ICAL.Component)) {
+ options = component;
+ component = null;
+ }
+
+ if (component) {
+ this.component = component;
+ } else {
+ this.component = new ICAL.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);
+ }
+ }
+
+ Event.prototype = {
+
+ THISANDFUTURE: 'THISANDFUTURE',
+
+ /**
+ * List of related event exceptions.
+ *
+ * @type {ICAL.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 {ICAL.Component|ICAL.Event} obj Component or event
+ */
+ relateException: function(obj) {
+ if (this.isRecurrenceException()) {
+ throw new Error('cannot relate exception to exceptions');
+ }
+
+ if (obj instanceof ICAL.Component) {
+ obj = new ICAL.Event(obj);
+ }
+
+ if (this.strictExceptions && obj.uid !== this.uid) {
+ throw new Error('attempted to relate unrelated exception');
+ }
+
+ var 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()) {
+ var item = [
+ obj.recurrenceId.toUnixTime(), id
+ ];
+
+ // we keep them sorted so we can find the nearest
+ // value later on...
+ var idx = ICAL.helpers.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: function() {
+ if (!this.component.hasProperty('recurrence-id')) {
+ return false;
+ }
+
+ var range = this.component.getFirstProperty('recurrence-id').getParameter('range');
+ return range === this.THISANDFUTURE;
+ },
+
+ /**
+ * Finds the range exception nearest to the given date.
+ *
+ * @param {ICAL.Time} time usually an occurrence time of an event
+ * @return {?ICAL.Event} the related event/exception or null
+ */
+ findRangeException: function(time) {
+ if (!this.rangeExceptions.length) {
+ return null;
+ }
+
+ var utc = time.toUnixTime();
+ var idx = ICAL.helpers.binsearchInsert(
+ this.rangeExceptions,
+ [utc],
+ compareRangeException
+ );
+
+ idx -= 1;
+
+ // occurs before
+ if (idx < 0) {
+ return null;
+ }
+
+ var rangeItem = this.rangeExceptions[idx];
+
+ /* istanbul ignore next: sanity check only */
+ if (utc < rangeItem[0]) {
+ return null;
+ }
+
+ return rangeItem[1];
+ },
+
+ /**
+ * This object is returned by {@link ICAL.Event#getOccurrenceDetails getOccurrenceDetails}
+ *
+ * @typedef {Object} occurrenceDetails
+ * @memberof ICAL.Event
+ * @property {ICAL.Time} recurrenceId The passed in recurrence id
+ * @property {ICAL.Event} item The occurrence
+ * @property {ICAL.Time} startDate The start of the occurrence
+ * @property {ICAL.Time} endDate The end of the occurrence
+ */
+
+ /**
+ * 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 {ICAL.Time} occurrence time occurrence
+ * @return {ICAL.Event.occurrenceDetails} Information about the occurrence
+ */
+ getOccurrenceDetails: function(occurrence) {
+ var id = occurrence.toString();
+ var utcId = occurrence.convertToZone(ICAL.Timezone.utcTimezone).toString();
+ var item;
+ var 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.
+ var rangeExceptionId = this.findRangeException(
+ occurrence
+ );
+ var end;
+
+ if (rangeExceptionId) {
+ var exception = this.exceptions[rangeExceptionId];
+
+ // range exception must modify standard time
+ // by the difference (if any) in start/end times.
+ result.item = exception;
+
+ var startDiff = this._rangeExceptionCache[rangeExceptionId];
+
+ if (!startDiff) {
+ var original = exception.recurrenceId.clone();
+ var 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;
+ }
+
+ var 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 {ICAL.Time} startTime Starting point for expansion
+ * @return {ICAL.RecurExpansion} Expansion object
+ */
+ iterator: function(startTime) {
+ return new ICAL.RecurExpansion({
+ component: this.component,
+ dtstart: startTime || this.startDate
+ });
+ },
+
+ /**
+ * Checks if the event is recurring
+ *
+ * @return {Boolean} True, if event is recurring
+ */
+ isRecurring: function() {
+ var 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: function() {
+ 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.<ICAL.Recur.frequencyValues, Boolean>}
+ * Object of recurrence flags
+ */
+ getRecurrenceTypes: function() {
+ var rules = this.component.getAllProperties('rrule');
+ var i = 0;
+ var len = rules.length;
+ var result = Object.create(null);
+
+ for (; i < len; i++) {
+ var 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 {ICAL.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 {ICAL.Time}
+ */
+ get endDate() {
+ var endDate = this._firstProp('dtend');
+ if (!endDate) {
+ var 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 {ICAL.Duration}
+ */
+ get duration() {
+ var 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) {
+ return this._setProp('location', value);
+ },
+
+ /**
+ * The attendees in the event
+ * @type {ICAL.Property[]}
+ * @readonly
+ */
+ 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 {ICAL.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 {ICAL.Time} time The time to set
+ */
+ _setTime: function(propName, time) {
+ var prop = this.component.getFirstProperty(propName);
+
+ if (!prop) {
+ prop = new ICAL.Property(propName);
+ this.component.addProperty(prop);
+ }
+
+ // utc and local don't get a tzid
+ if (
+ time.zone === ICAL.Timezone.localTimezone ||
+ time.zone === ICAL.Timezone.utcTimezone
+ ) {
+ // remove the tzid
+ prop.removeParameter('tzid');
+ } else {
+ prop.setParameter('tzid', time.zone.tzid);
+ }
+
+ prop.setValue(time);
+ },
+
+ _setProp: function(name, value) {
+ this.component.updatePropertyWithValue(name, value);
+ },
+
+ _firstProp: function(name) {
+ return this.component.getFirstPropertyValue(name);
+ },
+
+ /**
+ * The string representation of this event.
+ * @return {String}
+ */
+ toString: function() {
+ return this.component.toString();
+ }
+
+ };
+
+ function compareRangeException(a, b) {
+ if (a[0] > b[0]) return 1;
+ if (b[0] > a[0]) return -1;
+ return 0;
+ }
+
+ return Event;
+}());
+/* 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, 2011-2015 */
+
+
+/**
+ * This symbol is further described later on
+ * @ignore
+ */
+ICAL.ComponentParser = (function() {
+ /**
+ * @classdesc
+ * The ComponentParser is used to process a String or jCal Object,
+ * firing callbacks for various found components, as well as completion.
+ *
+ * @example
+ * var options = {
+ * // when false no events will be emitted for type
+ * parseEvent: true,
+ * parseTimezone: true
+ * };
+ *
+ * var parser = new ICAL.ComponentParser(options);
+ *
+ * parser.onevent(eventComponent) {
+ * //...
+ * }
+ *
+ * // ontimezone, etc...
+ *
+ * parser.oncomplete = function() {
+ *
+ * };
+ *
+ * parser.process(stringOrComponent);
+ *
+ * @class
+ * @alias ICAL.ComponentParser
+ * @param {Object=} options Component parser options
+ * @param {Boolean} options.parseEvent Whether events should be parsed
+ * @param {Boolean} options.parseTimezeone Whether timezones should be parsed
+ */
+ function ComponentParser(options) {
+ if (typeof(options) === 'undefined') {
+ options = {};
+ }
+
+ var key;
+ for (key in options) {
+ /* istanbul ignore else */
+ if (options.hasOwnProperty(key)) {
+ this[key] = options[key];
+ }
+ }
+ }
+
+ ComponentParser.prototype = {
+
+ /**
+ * When true, parse events
+ *
+ * @type {Boolean}
+ */
+ parseEvent: true,
+
+ /**
+ * When true, parse timezones
+ *
+ * @type {Boolean}
+ */
+ parseTimezone: true,
+
+
+ /* SAX like events here for reference */
+
+ /**
+ * Fired when parsing is complete
+ * @callback
+ */
+ oncomplete: /* istanbul ignore next */ function() {},
+
+ /**
+ * Fired if an error occurs during parsing.
+ *
+ * @callback
+ * @param {Error} err details of error
+ */
+ onerror: /* istanbul ignore next */ function(err) {},
+
+ /**
+ * Fired when a top level component (VTIMEZONE) is found
+ *
+ * @callback
+ * @param {ICAL.Timezone} component Timezone object
+ */
+ ontimezone: /* istanbul ignore next */ function(component) {},
+
+ /**
+ * Fired when a top level component (VEVENT) is found.
+ *
+ * @callback
+ * @param {ICAL.Event} component Top level component
+ */
+ onevent: /* istanbul ignore next */ function(component) {},
+
+ /**
+ * Process a string or parse ical object. This function itself will return
+ * nothing but will start the parsing process.
+ *
+ * Events must be registered prior to calling this method.
+ *
+ * @param {ICAL.Component|String|Object} ical The component to process,
+ * either in its final form, as a jCal Object, or string representation
+ */
+ process: function(ical) {
+ //TODO: this is sync now in the future we will have a incremental parser.
+ if (typeof(ical) === 'string') {
+ ical = ICAL.parse(ical);
+ }
+
+ if (!(ical instanceof ICAL.Component)) {
+ ical = new ICAL.Component(ical);
+ }
+
+ var components = ical.getAllSubcomponents();
+ var i = 0;
+ var len = components.length;
+ var component;
+
+ for (; i < len; i++) {
+ component = components[i];
+
+ switch (component.name) {
+ case 'vtimezone':
+ if (this.parseTimezone) {
+ var tzid = component.getFirstPropertyValue('tzid');
+ if (tzid) {
+ this.ontimezone(new ICAL.Timezone({
+ tzid: tzid,
+ component: component
+ }));
+ }
+ }
+ break;
+ case 'vevent':
+ if (this.parseEvent) {
+ this.onevent(new ICAL.Event(component));
+ }
+ break;
+ default:
+ continue;
+ }
+ }
+
+ //XXX: ideally we should do a "nextTick" here
+ // so in all cases this is actually async.
+ this.oncomplete();
+ }
+ };
+
+ return ComponentParser;
+}());
diff --git a/comm/calendar/base/modules/calCalendarDeactivator.jsm b/comm/calendar/base/modules/calCalendarDeactivator.jsm
new file mode 100644
index 0000000000..c987d17728
--- /dev/null
+++ b/comm/calendar/base/modules/calCalendarDeactivator.jsm
@@ -0,0 +1,171 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["calendarDeactivator"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Handles deactivation of calendar UI and background processes/services (such
+ * as the alarms service) when users do not want to use calendar functionality.
+ * Also handles re-activation when users change their mind.
+ *
+ * If all of a user's calendars are disabled (e.g. calendar > properties >
+ * "turn this calendar on") then full calendar functionality is deactivated.
+ * If one or more calendars are enabled then full calendar functionality is
+ * activated.
+ *
+ * Note we use "disabled"/"enabled" for a user's individual calendars and
+ * "deactivated"/"activated" for the calendar component as a whole.
+ *
+ * @implements {calICalendarManagerObserver}
+ * @implements {calIObserver}
+ */
+var calendarDeactivator = {
+ windows: new Set(),
+ calendars: null,
+ isCalendarActivated: null,
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver", "calIObserver"]),
+
+ initializeDeactivator() {
+ this.calendars = new Set(cal.manager.getCalendars());
+ cal.manager.addObserver(this);
+ cal.manager.addCalendarObserver(this);
+ this.isCalendarActivated = this.checkCalendarsEnabled();
+ },
+
+ /**
+ * Register a window to allow future modifications, and set up the window's
+ * deactivated/activated state. Deregistration is not required.
+ *
+ * @param {ChromeWindow} window - A ChromeWindow object.
+ */
+ registerWindow(window) {
+ if (this.calendars === null) {
+ this.initializeDeactivator();
+ }
+ this.windows.add(window);
+ window.addEventListener("unload", () => this.windows.delete(window));
+
+ if (this.isCalendarActivated) {
+ window.document.documentElement.removeAttribute("calendar-deactivated");
+ } else {
+ this.refreshNotificationBoxes(window, false);
+ }
+ },
+
+ /**
+ * Check the enabled state of all of the user's calendars.
+ *
+ * @returns {boolean} True if any calendars are enabled, false if all are disabled.
+ */
+ checkCalendarsEnabled() {
+ for (let calendar of this.calendars) {
+ if (!calendar.getProperty("disabled")) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * If needed, change the calendar activated/deactivated state and update the
+ * UI and background processes/services accordingly.
+ */
+ refreshDeactivatedState() {
+ let someCalsEnabled = this.checkCalendarsEnabled();
+
+ if (someCalsEnabled == this.isCalendarActivated) {
+ return;
+ }
+
+ for (let window of this.windows) {
+ if (someCalsEnabled) {
+ window.document.documentElement.removeAttribute("calendar-deactivated");
+ } else {
+ window.document.documentElement.setAttribute("calendar-deactivated", "");
+ }
+ this.refreshNotificationBoxes(window, someCalsEnabled);
+ }
+
+ if (someCalsEnabled) {
+ Services.prefs.setBoolPref("calendar.itip.showImipBar", true);
+ }
+
+ this.isCalendarActivated = someCalsEnabled;
+ },
+
+ /**
+ * Show or hide the notification boxes that appear at the top of the calendar
+ * and tasks tabs when calendar functionality is deactivated.
+ *
+ * @param {ChromeWindow} window - A ChromeWindow object.
+ * @param {boolean} isEnabled - Whether any calendars are enabled.
+ */
+ refreshNotificationBoxes(window, isEnabled) {
+ let notificationboxes = [
+ [
+ window.calendarTabType.modes.calendar.notificationbox,
+ "calendar-deactivated-notification-events",
+ ],
+ [
+ window.calendarTabType.modes.tasks.notificationbox,
+ "calendar-deactivated-notification-tasks",
+ ],
+ ];
+
+ let value = "calendarDeactivated";
+ for (let [notificationbox, l10nId] of notificationboxes) {
+ let existingNotification = notificationbox.getNotificationWithValue(value);
+
+ if (isEnabled) {
+ notificationbox.removeNotification(existingNotification);
+ } else if (!existingNotification) {
+ notificationbox.appendNotification(
+ value,
+ {
+ label: { "l10n-id": l10nId },
+ priority: notificationbox.PRIORITY_WARNING_MEDIUM,
+ },
+ null
+ );
+ }
+ }
+ },
+
+ // calICalendarManagerObserver methods
+ onCalendarRegistered(calendar) {
+ this.calendars.add(calendar);
+
+ if (!this.isCalendarActivated && !calendar.getProperty("disabled")) {
+ this.refreshDeactivatedState();
+ }
+ },
+
+ onCalendarUnregistering(calendar) {
+ this.calendars.delete(calendar);
+
+ if (!calendar.getProperty("disabled")) {
+ this.refreshDeactivatedState();
+ }
+ },
+ onCalendarDeleting(calendar) {},
+
+ // calIObserver methods
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem(item) {},
+ onModifyItem(newItem, oldItem) {},
+ onDeleteItem(deletedItem) {},
+ onError(calendar, errNo, message) {},
+
+ onPropertyChanged(calendar, name, value, oldValue) {
+ if (name == "disabled") {
+ this.refreshDeactivatedState();
+ }
+ },
+
+ onPropertyDeleting(calendar, name) {},
+};
diff --git a/comm/calendar/base/modules/calExtract.jsm b/comm/calendar/base/modules/calExtract.jsm
new file mode 100644
index 0000000000..4bb68cf77b
--- /dev/null
+++ b/comm/calendar/base/modules/calExtract.jsm
@@ -0,0 +1,1417 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["Extractor"];
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Initializes extraction
+ *
+ * @param fallbackLocale locale to use when others are not found or
+ * detection is disabled
+ * @param dayStart ambiguous hours earlier than this are considered to
+ * be in the afternoon, when null then by default
+ * set to 6
+ * @param fixedLang whether to use only fallbackLocale for extraction
+ */
+function Extractor(fallbackLocale, dayStart, fixedLang) {
+ this.bundleUrl = "resource:///chrome/LOCALE/locale/LOCALE/calendar/calendar-extract.properties";
+ this.fallbackLocale = fallbackLocale;
+ this.email = "";
+ this.marker = "--MARK--";
+ // this should never be found in an email
+ this.defPattern = "061dc19c-719f-47f3-b2b5-e767e6f02b7a";
+ this.collected = [];
+ this.numbers = [];
+ this.hourlyNumbers = [];
+ this.dailyNumbers = [];
+ this.allMonths = "";
+ this.months = [];
+ this.dayStart = 6;
+ this.now = new Date();
+ this.bundle = "";
+ this.overrides = {};
+ this.fixedLang = true;
+
+ if (dayStart != null) {
+ this.dayStart = dayStart;
+ }
+
+ if (fixedLang != null) {
+ this.fixedLang = fixedLang;
+ }
+
+ if (!this.checkBundle(fallbackLocale)) {
+ cal.WARN(
+ "Your installed Lightning only includes a single locale, extracting event info from other languages is likely inaccurate. You can install Lightning from addons.mozilla.org manually for multiple locale support."
+ );
+ }
+}
+
+Extractor.prototype = {
+ /**
+ * Removes confusing data like urls, timezones and phone numbers from email
+ * Also removes standard signatures and quoted content from previous emails
+ */
+ cleanup() {
+ // XXX remove earlier correspondence
+ // ideally this should be considered with lower certainty to fill in
+ // missing information
+
+ // remove last line preceding quoted message and first line of the quote
+ this.email = this.email.replace(/\r?\n[^>].*\r?\n>+.*$/m, "");
+ // remove the rest of quoted content
+ this.email = this.email.replace(/^>+.*$/gm, "");
+
+ // urls often contain dates dates that can confuse extraction
+ this.email = this.email.replace(/https?:\/\/[^\s]+\s/gm, "");
+ this.email = this.email.replace(/www\.[^\s]+\s/gm, "");
+
+ // remove phone numbers
+ // TODO allow locale specific configuration of formats
+ this.email = this.email.replace(/\d-\d\d\d-\d\d\d-\d\d\d\d/gm, "");
+
+ // remove standard signature
+ this.email = this.email.replace(/\r?\n-- \r?\n[\S\s]+$/, "");
+
+ // XXX remove timezone info, for now
+ this.email = this.email.replace(/gmt[+-]\d{2}:\d{2}/gi, "");
+ },
+
+ checkBundle(locale) {
+ let path = this.bundleUrl.replace(/LOCALE/g, locale);
+ let bundle = Services.strings.createBundle(path);
+
+ try {
+ bundle.GetStringFromName("from.today");
+ return true;
+ } catch (ex) {
+ return false;
+ }
+ },
+
+ avgNonAsciiCharCode() {
+ let sum = 0;
+ let cnt = 0;
+
+ for (let i = 0; i < this.email.length; i++) {
+ let char = this.email.charCodeAt(i);
+ if (char > 128) {
+ sum += char;
+ cnt++;
+ }
+ }
+
+ let nonAscii = sum / cnt || 0;
+ cal.LOG("[calExtract] Average non-ascii charcode: " + nonAscii);
+ return nonAscii;
+ },
+
+ setLanguage() {
+ let path;
+
+ if (this.fixedLang) {
+ if (this.checkBundle(this.fallbackLocale)) {
+ cal.LOG(
+ "[calExtract] Fixed locale was used to choose " + this.fallbackLocale + " patterns."
+ );
+ } else {
+ cal.LOG(
+ "[calExtract] " + this.fallbackLocale + " patterns were not found. Using en-US instead"
+ );
+ this.fallbackLocale = "en-US";
+ }
+
+ path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale);
+
+ let pref = "calendar.patterns.last.used.languages";
+ let lastUsedLangs = Services.prefs.getStringPref(pref, "");
+ if (lastUsedLangs == "") {
+ Services.prefs.setStringPref(pref, this.fallbackLocale);
+ } else {
+ let langs = lastUsedLangs.split(",");
+ let idx = langs.indexOf(this.fallbackLocale);
+ if (idx == -1) {
+ Services.prefs.setStringPref(pref, this.fallbackLocale + "," + lastUsedLangs);
+ } else {
+ langs.splice(idx, 1);
+ Services.prefs.setStringPref(pref, this.fallbackLocale + "," + langs.join(","));
+ }
+ }
+ } else {
+ let spellchecker = Cc["@mozilla.org/spellchecker/engine;1"].getService(
+ Ci.mozISpellCheckingEngine
+ );
+
+ let dicts = spellchecker.getDictionaryList();
+
+ if (dicts.length == 0) {
+ cal.LOG(
+ "[calExtract] There are no dictionaries installed and " +
+ "enabled. You might want to add some if date and time " +
+ "extraction from emails seems inaccurate."
+ );
+ }
+
+ let patterns;
+ let words = this.email.split(/\s+/);
+ let most = 0;
+ let mostLocale;
+ for (let dict in dicts) {
+ // dictionary locale and patterns locale match
+ if (this.checkBundle(dicts[dict])) {
+ let time1 = new Date().getTime();
+ spellchecker.dictionaries = [dicts[dict]];
+ let dur = new Date().getTime() - time1;
+ cal.LOG("[calExtract] Loading " + dicts[dict] + " dictionary took " + dur + "ms");
+ patterns = dicts[dict];
+ // beginning of dictionary locale matches patterns locale
+ } else if (this.checkBundle(dicts[dict].substring(0, 2))) {
+ let time1 = new Date().getTime();
+ spellchecker.dictionaries = [dicts[dict]];
+ let dur = new Date().getTime() - time1;
+ cal.LOG("[calExtract] Loading " + dicts[dict] + " dictionary took " + dur + "ms");
+ patterns = dicts[dict].substring(0, 2);
+ // dictionary for which patterns aren't present
+ } else {
+ cal.LOG("[calExtract] Dictionary present, rules missing: " + dicts[dict]);
+ continue;
+ }
+
+ let correct = 0;
+ let total = 0;
+ for (let word in words) {
+ words[word] = words[word].replace(/[()\d,;:?!#.]/g, "");
+ if (words[word].length >= 2) {
+ total++;
+ if (spellchecker.check(words[word])) {
+ correct++;
+ }
+ }
+ }
+
+ let percentage = (correct / total) * 100.0;
+ cal.LOG("[calExtract] " + dicts[dict] + " dictionary matches " + percentage + "% of words");
+
+ if (percentage > 50.0 && percentage > most) {
+ mostLocale = patterns;
+ most = percentage;
+ }
+ }
+
+ let avgCharCode = this.avgNonAsciiCharCode();
+
+ // using dictionaries for language recognition with non-latin letters doesn't work
+ // very well, possibly because of bug 471799
+ if (avgCharCode > 48000 && avgCharCode < 50000) {
+ cal.LOG("[calExtract] Using ko patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "ko");
+ // is it possible to differentiate zh-TW and zh-CN?
+ } else if (avgCharCode > 24000 && avgCharCode < 32000) {
+ cal.LOG("[calExtract] Using zh-TW patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "zh-TW");
+ } else if (avgCharCode > 14000 && avgCharCode < 24000) {
+ cal.LOG("[calExtract] Using ja patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "ja");
+ // Bulgarian also looks like that
+ } else if (avgCharCode > 1000 && avgCharCode < 1200) {
+ cal.LOG("[calExtract] Using ru patterns based on charcodes");
+ path = this.bundleUrl.replace(/LOCALE/g, "ru");
+ // dictionary based
+ } else if (most > 0) {
+ cal.LOG("[calExtract] Using " + mostLocale + " patterns based on dictionary");
+ path = this.bundleUrl.replace(/LOCALE/g, mostLocale);
+ // fallbackLocale matches patterns exactly
+ } else if (this.checkBundle(this.fallbackLocale)) {
+ cal.LOG("[calExtract] Falling back to " + this.fallbackLocale);
+ path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale);
+ // beginning of fallbackLocale matches patterns
+ } else if (this.checkBundle(this.fallbackLocale.substring(0, 2))) {
+ this.fallbackLocale = this.fallbackLocale.substring(0, 2);
+ cal.LOG("[calExtract] Falling back to " + this.fallbackLocale);
+ path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale);
+ } else {
+ cal.LOG("[calExtract] Using en-US");
+ path = this.bundleUrl.replace(/LOCALE/g, "en-US");
+ }
+ }
+ this.bundle = Services.strings.createBundle(path);
+ },
+
+ /**
+ * Extracts dates, times and durations from email
+ *
+ * @param body email body
+ * @param now reference time against which relative times are interpreted,
+ * when null current time is used
+ * @param sel selection object of email content, when defined times
+ * outside selection are discarded
+ * @param title email title
+ * @returns sorted list of extracted datetime objects
+ */
+ extract(title, body, now, sel) {
+ let initial = {};
+ this.collected = [];
+ this.email = title + "\r\n" + body;
+ if (now != null) {
+ this.now = now;
+ }
+
+ initial.year = now.getFullYear();
+ initial.month = now.getMonth() + 1;
+ initial.day = now.getDate();
+ initial.hour = now.getHours();
+ initial.minute = now.getMinutes();
+
+ this.collected.push({
+ year: initial.year,
+ month: initial.month,
+ day: initial.day,
+ hour: initial.hour,
+ minute: initial.minute,
+ relation: "start",
+ });
+
+ this.cleanup();
+ cal.LOG("[calExtract] Email after processing for extraction: \n" + this.email);
+
+ this.overrides = JSON.parse(Services.prefs.getStringPref("calendar.patterns.override", "{}"));
+ this.setLanguage();
+
+ for (let i = 0; i <= 31; i++) {
+ this.numbers[i] = this.getPatterns("number." + i);
+ }
+ this.dailyNumbers = this.numbers.join(this.marker);
+
+ this.hourlyNumbers = this.numbers[0] + this.marker;
+ for (let i = 1; i <= 22; i++) {
+ this.hourlyNumbers += this.numbers[i] + this.marker;
+ }
+ this.hourlyNumbers += this.numbers[23];
+
+ this.hourlyNumbers = this.hourlyNumbers.replace(/\|/g, this.marker);
+ this.dailyNumbers = this.dailyNumbers.replace(/\|/g, this.marker);
+
+ for (let i = 0; i < 12; i++) {
+ this.months[i] = this.getPatterns("month." + (i + 1));
+ }
+ this.allMonths = this.months.join(this.marker).replace(/\|/g, this.marker);
+
+ // time
+ this.extractTime("from.noon", "start", 12, 0);
+ this.extractTime("until.noon", "end", 12, 0);
+
+ this.extractHour("from.hour", "start", "none");
+ this.extractHour("from.hour.am", "start", "ante");
+ this.extractHour("from.hour.pm", "start", "post");
+ this.extractHour("until.hour", "end", "none");
+ this.extractHour("until.hour.am", "end", "ante");
+ this.extractHour("until.hour.pm", "end", "post");
+
+ this.extractHalfHour("from.half.hour.before", "start", "ante");
+ this.extractHalfHour("until.half.hour.before", "end", "ante");
+ this.extractHalfHour("from.half.hour.after", "start", "post");
+ this.extractHalfHour("until.half.hour.after", "end", "post");
+
+ this.extractHourMinutes("from.hour.minutes", "start", "none");
+ this.extractHourMinutes("from.hour.minutes.am", "start", "ante");
+ this.extractHourMinutes("from.hour.minutes.pm", "start", "post");
+ this.extractHourMinutes("until.hour.minutes", "end", "none");
+ this.extractHourMinutes("until.hour.minutes.am", "end", "ante");
+ this.extractHourMinutes("until.hour.minutes.pm", "end", "post");
+
+ // date
+ this.extractRelativeDay("from.today", "start", 0);
+ this.extractRelativeDay("from.tomorrow", "start", 1);
+ this.extractRelativeDay("until.tomorrow", "end", 1);
+ this.extractWeekDay("from.weekday.", "start");
+ this.extractWeekDay("until.weekday.", "end");
+ this.extractDate("from.ordinal.date", "start");
+ this.extractDate("until.ordinal.date", "end");
+
+ this.extractDayMonth("from.month.day", "start");
+ this.extractDayMonthYear("from.year.month.day", "start");
+ this.extractDayMonth("until.month.day", "end");
+ this.extractDayMonthYear("until.year.month.day", "end");
+ this.extractDayMonthName("from.monthname.day", "start");
+ this.extractDayMonthNameYear("from.year.monthname.day", "start");
+ this.extractDayMonthName("until.monthname.day", "end");
+ this.extractDayMonthNameYear("until.year.monthname.day", "end");
+
+ // duration
+ this.extractDuration("duration.minutes", 1);
+ this.extractDuration("duration.hours", 60);
+ this.extractDuration("duration.days", 60 * 24);
+
+ if (sel !== undefined && sel !== null) {
+ this.markSelected(sel, title);
+ }
+ this.markContained();
+ this.collected = this.collected.sort(this.sort);
+
+ return this.collected;
+ },
+
+ extractDayMonthYear(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{1,2})", "(\\d{2,4})"]);
+
+ let res;
+ for (let alt in alts) {
+ let positions = alts[alt].positions;
+ let re = new RegExp(alts[alt].pattern, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = parseInt(res[positions[1]], 10);
+ let month = parseInt(res[positions[2]], 10);
+ let year = parseInt(this.normalizeYear(res[positions[3]]), 10);
+
+ if (this.isValidDay(day) && this.isValidMonth(month) && this.isValidYear(year)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ year,
+ month,
+ day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractDayMonthNameYear(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, [
+ "(\\d{1,2})",
+ "(" + this.allMonths + ")",
+ "(\\d{2,4})",
+ ]);
+
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let positions = alts[alt].positions;
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = parseInt(res[positions[1]], 10);
+ let month = res[positions[2]];
+ let year = parseInt(this.normalizeYear(res[positions[3]]), 10);
+
+ if (this.isValidDay(day)) {
+ for (let i = 0; i < 12; i++) {
+ if (this.months[i].split("|").includes(month.toLowerCase())) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ year,
+ i + 1,
+ day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ extractRelativeDay(pattern, relation, offset) {
+ let re = new RegExp(this.getPatterns(pattern), "ig");
+ let res;
+ if ((res = re.exec(this.email)) != null) {
+ if (!this.limitChars(res, this.email)) {
+ let item = new Date(this.now.getTime() + 60 * 60 * 24 * 1000 * offset);
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ item.getFullYear(),
+ item.getMonth() + 1,
+ item.getDate(),
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ },
+
+ extractDayMonthName(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, [
+ "(\\d{1,2}" + this.marker + this.dailyNumbers + ")",
+ "(" + this.allMonths + ")",
+ ]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let positions = alts[alt].positions;
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = this.parseNumber(res[positions[1]], this.numbers);
+ let month = res[positions[2]];
+
+ if (this.isValidDay(day)) {
+ for (let i = 0; i < 12; i++) {
+ let months = this.unescape(this.months[i]).split("|");
+ if (months.includes(month.toLowerCase())) {
+ let date = { year: this.now.getFullYear(), month: i + 1, day };
+ if (this.isPastDate(date, this.now)) {
+ // find next such date
+ let item = new Date(this.now.getTime());
+ while (true) {
+ item.setDate(item.getDate() + 1);
+ if (item.getMonth() == date.month - 1 && item.getDate() == date.day) {
+ date.year = item.getFullYear();
+ break;
+ }
+ }
+ }
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ date.year,
+ date.month,
+ date.day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+
+ extractDayMonth(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{1,2})"]);
+ let res;
+ for (let alt in alts) {
+ let re = new RegExp(alts[alt].pattern, "ig");
+ let positions = alts[alt].positions;
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = parseInt(res[positions[1]], 10);
+ let month = parseInt(res[positions[2]], 10);
+
+ if (this.isValidMonth(month) && this.isValidDay(day)) {
+ let date = { year: this.now.getFullYear(), month, day };
+
+ if (this.isPastDate(date, this.now)) {
+ // find next such date
+ let item = new Date(this.now.getTime());
+ while (true) {
+ item.setDate(item.getDate() + 1);
+ if (item.getMonth() == date.month - 1 && item.getDate() == date.day) {
+ date.year = item.getFullYear();
+ break;
+ }
+ }
+ }
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ date.year,
+ date.month,
+ date.day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractDate(pattern, relation) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let day = this.parseNumber(res[1], this.numbers);
+ if (this.isValidDay(day)) {
+ let item = new Date(this.now.getTime());
+ if (this.now.getDate() != day) {
+ // find next nth date
+ while (true) {
+ item.setDate(item.getDate() + 1);
+ if (item.getDate() == day) {
+ break;
+ }
+ }
+ }
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ item.getFullYear(),
+ item.getMonth() + 1,
+ day,
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern,
+ true
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractWeekDay(pattern, relation) {
+ let days = [];
+ for (let i = 0; i < 7; i++) {
+ days[i] = this.getPatterns(pattern + i);
+ let re = new RegExp(days[i], "ig");
+ let res = re.exec(this.email);
+ if (res) {
+ if (!this.limitChars(res, this.email)) {
+ let date = new Date();
+ date.setDate(this.now.getDate());
+ date.setMonth(this.now.getMonth());
+ date.setYear(this.now.getFullYear());
+
+ let diff = (i - date.getDay() + 7) % 7;
+ date.setDate(date.getDate() + diff);
+
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ date.getFullYear(),
+ date.getMonth() + 1,
+ date.getDate(),
+ null,
+ null,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern + i,
+ true
+ );
+ }
+ }
+ }
+ },
+
+ extractHour(pattern, relation, meridiem) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.hourlyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let hour = this.parseNumber(res[1], this.numbers);
+
+ if (meridiem == "ante" && hour == 12) {
+ hour = hour - 12;
+ } else if (meridiem == "post" && hour != 12) {
+ hour = hour + 12;
+ } else {
+ hour = this.normalizeHour(hour);
+ }
+
+ if (this.isValidHour(res[1])) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ 0,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern,
+ true
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractHalfHour(pattern, relation, direction) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.hourlyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let hour = this.parseNumber(res[1], this.numbers);
+
+ hour = this.normalizeHour(hour);
+ if (direction == "ante") {
+ if (hour == 1) {
+ hour = 12;
+ } else {
+ hour = hour - 1;
+ }
+ }
+
+ if (this.isValidHour(hour)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ 30,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern,
+ true
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractHourMinutes(pattern, relation, meridiem) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{2})"]);
+ let res;
+ for (let alt in alts) {
+ let positions = alts[alt].positions;
+ let re = new RegExp(alts[alt].pattern, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let hour = parseInt(res[positions[1]], 10);
+ let minute = parseInt(res[positions[2]], 10);
+
+ if (meridiem == "ante" && hour == 12) {
+ hour = hour - 12;
+ } else if (meridiem == "post" && hour != 12) {
+ hour = hour + 12;
+ } else {
+ hour = this.normalizeHour(hour);
+ }
+
+ if (this.isValidHour(hour) && this.isValidMinute(hour)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ minute,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ }
+ }
+ },
+
+ extractTime(pattern, relation, hour, minute) {
+ let re = new RegExp(this.getPatterns(pattern), "ig");
+ let res;
+ if ((res = re.exec(this.email)) != null) {
+ if (!this.limitChars(res, this.email)) {
+ let rev = this.prefixSuffixStartEnd(res, relation, this.email);
+ this.guess(
+ null,
+ null,
+ null,
+ hour,
+ minute,
+ rev.start,
+ rev.end,
+ rev.pattern,
+ rev.relation,
+ pattern
+ );
+ }
+ }
+ },
+
+ extractDuration(pattern, unit) {
+ let alts = this.getRepPatterns(pattern, ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")"]);
+ let res;
+ for (let alt in alts) {
+ let exp = alts[alt].pattern.split(this.marker).join("|");
+ let re = new RegExp(exp, "ig");
+
+ while ((res = re.exec(this.email)) != null) {
+ if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) {
+ let length = this.parseNumber(res[1], this.numbers);
+ let guess = {};
+ let rev = this.prefixSuffixStartEnd(res, "duration", this.email);
+ guess.duration = length * unit;
+ guess.start = rev.start;
+ guess.end = rev.end;
+ guess.str = rev.pattern;
+ guess.relation = rev.relation;
+ guess.pattern = pattern;
+ this.collected.push(guess);
+ }
+ }
+ }
+ },
+
+ markContained() {
+ for (let outer = 0; outer < this.collected.length; outer++) {
+ for (let inner = 0; inner < this.collected.length; inner++) {
+ // included but not exactly the same
+ if (
+ outer != inner &&
+ this.collected[outer].start &&
+ this.collected[outer].end &&
+ this.collected[inner].start &&
+ this.collected[inner].end &&
+ this.collected[inner].start >= this.collected[outer].start &&
+ this.collected[inner].end <= this.collected[outer].end &&
+ !(
+ this.collected[inner].start == this.collected[outer].start &&
+ this.collected[inner].end == this.collected[outer].end
+ )
+ ) {
+ cal.LOG(
+ "[calExtract] " +
+ this.collected[outer].str +
+ " found as well, disgarding " +
+ this.collected[inner].str
+ );
+ this.collected[inner].relation = "notadatetime";
+ }
+ }
+ }
+ },
+
+ markSelected(sel, title) {
+ if (sel.rangeCount > 0) {
+ // mark the ones to not use
+ for (let i = 0; i < sel.rangeCount; i++) {
+ cal.LOG("[calExtract] Selection " + i + " is " + sel);
+ for (let j = 0; j < this.collected.length; j++) {
+ let selection = sel.getRangeAt(i).toString();
+
+ if (
+ !selection.includes(this.collected[j].str) &&
+ !title.includes(this.collected[j].str) &&
+ this.collected[j].start != null
+ ) {
+ // always keep email date, needed for tasks
+ cal.LOG(
+ "[calExtract] Marking " + JSON.stringify(this.collected[j]) + " as notadatetime"
+ );
+ this.collected[j].relation = "notadatetime";
+ }
+ }
+ }
+ }
+ },
+
+ sort(one, two) {
+ let rc;
+ // sort the guess from email date as the last one
+ if (one.start == null && two.start != null) {
+ return 1;
+ } else if (one.start != null && two.start == null) {
+ return -1;
+ } else if (one.start == null && two.start == null) {
+ return 0;
+ // sort dates before times
+ } else if (one.year != null && two.year == null) {
+ return -1;
+ } else if (one.year == null && two.year != null) {
+ return 1;
+ } else if (one.year != null && two.year != null) {
+ rc = (one.year > two.year) - (one.year < two.year);
+ if (rc == 0) {
+ rc = (one.month > two.month) - (one.month < two.month);
+ if (rc == 0) {
+ rc = (one.day > two.day) - (one.day < two.day);
+ }
+ }
+ return rc;
+ }
+ rc = (one.hour > two.hour) - (one.hour < two.hour);
+ if (rc == 0) {
+ rc = (one.minute > two.minute) - (one.minute < two.minute);
+ }
+ return rc;
+ },
+
+ /**
+ * Guesses start time from list of guessed datetimes
+ *
+ * @param isTask whether start time should be guessed for task or event
+ * @returns datetime object for start time
+ */
+ guessStart(isTask) {
+ let startTimes = this.collected.filter(val => val.relation == "start");
+ if (startTimes.length == 0) {
+ return {};
+ }
+
+ for (let val in startTimes) {
+ cal.LOG("[calExtract] Start: " + JSON.stringify(startTimes[val]));
+ }
+
+ let guess = {};
+ let wDayInit = startTimes.filter(val => val.day != null && val.start === undefined);
+
+ // with tasks we don't try to guess start but assume email date
+ if (isTask) {
+ guess.year = wDayInit[0].year;
+ guess.month = wDayInit[0].month;
+ guess.day = wDayInit[0].day;
+ guess.hour = wDayInit[0].hour;
+ guess.minute = wDayInit[0].minute;
+ return guess;
+ }
+
+ let wDay = startTimes.filter(val => val.day != null && val.start !== undefined);
+ let wDayNA = wDay.filter(val => val.ambiguous === undefined);
+
+ let wMinute = startTimes.filter(val => val.minute != null && val.start !== undefined);
+ let wMinuteNA = wMinute.filter(val => val.ambiguous === undefined);
+
+ if (wMinuteNA.length != 0) {
+ guess.hour = wMinuteNA[0].hour;
+ guess.minute = wMinuteNA[0].minute;
+ } else if (wMinute.length != 0) {
+ guess.hour = wMinute[0].hour;
+ guess.minute = wMinute[0].minute;
+ }
+
+ // first use unambiguous guesses
+ if (wDayNA.length != 0) {
+ guess.year = wDayNA[0].year;
+ guess.month = wDayNA[0].month;
+ guess.day = wDayNA[0].day;
+ // then also ambiguous ones
+ } else if (wDay.length != 0) {
+ guess.year = wDay[0].year;
+ guess.month = wDay[0].month;
+ guess.day = wDay[0].day;
+ // next possible day considering time
+ } else if (
+ guess.hour != null &&
+ (wDayInit[0].hour > guess.hour ||
+ (wDayInit[0].hour == guess.hour && wDayInit[0].minute > guess.minute))
+ ) {
+ let nextDay = new Date(wDayInit[0].year, wDayInit[0].month - 1, wDayInit[0].day);
+ nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000);
+ guess.year = nextDay.getFullYear();
+ guess.month = nextDay.getMonth() + 1;
+ guess.day = nextDay.getDate();
+ // and finally when nothing was found then use initial guess from send time
+ } else {
+ guess.year = wDayInit[0].year;
+ guess.month = wDayInit[0].month;
+ guess.day = wDayInit[0].day;
+ }
+
+ cal.LOG("[calExtract] Start picked: " + JSON.stringify(guess));
+ return guess;
+ },
+
+ /**
+ * Guesses end time from list of guessed datetimes relative to start time
+ *
+ * @param start start time to consider when guessing
+ * @param doGuessStart whether start time should be guessed for task or event
+ * @returns datetime object for end time
+ */
+ guessEnd(start, doGuessStart) {
+ let guess = {};
+ let endTimes = this.collected.filter(val => val.relation == "end");
+ let durations = this.collected.filter(val => val.relation == "duration");
+ if (endTimes.length == 0 && durations.length == 0) {
+ return {};
+ }
+ for (let val in endTimes) {
+ cal.LOG("[calExtract] End: " + JSON.stringify(endTimes[val]));
+ }
+
+ let wDay = endTimes.filter(val => val.day != null);
+ let wDayNA = wDay.filter(val => val.ambiguous === undefined);
+ let wMinute = endTimes.filter(val => val.minute != null);
+ let wMinuteNA = wMinute.filter(val => val.ambiguous === undefined);
+
+ // first set non-ambiguous dates
+ let pos = doGuessStart ? 0 : wDayNA.length - 1;
+ if (wDayNA.length != 0) {
+ guess.year = wDayNA[pos].year;
+ guess.month = wDayNA[pos].month;
+ guess.day = wDayNA[pos].day;
+ // then ambiguous dates
+ } else if (wDay.length != 0) {
+ pos = doGuessStart ? 0 : wDay.length - 1;
+ guess.year = wDay[pos].year;
+ guess.month = wDay[pos].month;
+ guess.day = wDay[pos].day;
+ }
+
+ // then non-ambiguous times
+ if (wMinuteNA.length != 0) {
+ pos = doGuessStart ? 0 : wMinuteNA.length - 1;
+ guess.hour = wMinuteNA[pos].hour;
+ guess.minute = wMinuteNA[pos].minute;
+ if (guess.day == null || guess.day == start.day) {
+ if (
+ wMinuteNA[pos].hour < start.hour ||
+ (wMinuteNA[pos].hour == start.hour && wMinuteNA[pos].minute < start.minute)
+ ) {
+ let nextDay = new Date(start.year, start.month - 1, start.day);
+ nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000);
+ guess.year = nextDay.getFullYear();
+ guess.month = nextDay.getMonth() + 1;
+ guess.day = nextDay.getDate();
+ }
+ }
+ // and ambiguous times
+ } else if (wMinute.length != 0) {
+ pos = doGuessStart ? 0 : wMinute.length - 1;
+ guess.hour = wMinute[pos].hour;
+ guess.minute = wMinute[pos].minute;
+ if (guess.day == null || guess.day == start.day) {
+ if (
+ wMinute[pos].hour < start.hour ||
+ (wMinute[pos].hour == start.hour && wMinute[pos].minute < start.minute)
+ ) {
+ let nextDay = new Date(start.year, start.month - 1, start.day);
+ nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000);
+ guess.year = nextDay.getFullYear();
+ guess.month = nextDay.getMonth() + 1;
+ guess.day = nextDay.getDate();
+ }
+ }
+ }
+
+ // fill in date when time was guessed
+ if (guess.minute != null && guess.day == null) {
+ guess.year = start.year;
+ guess.month = start.month;
+ guess.day = start.day;
+ }
+
+ // fill in end from total duration
+ if (guess.day == null && guess.hour == null) {
+ let duration = 0;
+
+ for (let val in durations) {
+ duration += durations[val].duration;
+ cal.LOG("[calExtract] Dur: " + JSON.stringify(durations[val]));
+ }
+
+ if (duration != 0) {
+ let startDate = new Date(start.year, start.month - 1, start.day);
+ if ("hour" in start) {
+ startDate.setHours(start.hour);
+ startDate.setMinutes(start.minute);
+ } else {
+ startDate.setHours(0);
+ startDate.setMinutes(0);
+ }
+
+ let endTime = new Date(startDate.getTime() + duration * 60 * 1000);
+ guess.year = endTime.getFullYear();
+ guess.month = endTime.getMonth() + 1;
+ guess.day = endTime.getDate();
+ if (!(endTime.getHours() == 0 && endTime.getMinutes() == 0)) {
+ guess.hour = endTime.getHours();
+ guess.minute = endTime.getMinutes();
+ }
+ }
+ }
+
+ // no zero or negative length events/tasks
+ let startTime = new Date(
+ start.year || 0,
+ start.month - 1 || 0,
+ start.day || 0,
+ start.hour || 0,
+ start.minute || 0
+ ).getTime();
+ let guessTime = new Date(
+ guess.year || 0,
+ guess.month - 1 || 0,
+ guess.day || 0,
+ guess.hour || 0,
+ guess.minute || 0
+ ).getTime();
+ if (guessTime <= startTime) {
+ guess.year = null;
+ guess.month = null;
+ guess.day = null;
+ guess.hour = null;
+ guess.minute = null;
+ }
+
+ if (guess.year != null && guess.minute == null && doGuessStart) {
+ guess.hour = 0;
+ guess.minute = 0;
+ }
+
+ cal.LOG("[calExtract] End picked: " + JSON.stringify(guess));
+ return guess;
+ },
+
+ getPatterns(name) {
+ let value;
+ try {
+ value = this.bundle.GetStringFromName(name);
+ if (value.trim() == "") {
+ cal.LOG("[calExtract] Pattern not found: " + name);
+ return this.defPattern;
+ }
+
+ let vals = this.cleanPatterns(value).split("|");
+ for (let idx = vals.length - 1; idx >= 0; idx--) {
+ if (vals[idx].trim() == "") {
+ vals.splice(idx, 1);
+ console.error("[calExtract] Faulty extraction pattern " + value + " for " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].add !== undefined) {
+ let additions = this.overrides[name].add;
+ additions = this.cleanPatterns(additions).split("|");
+ for (let pattern in additions) {
+ vals.push(additions[pattern]);
+ cal.LOG("[calExtract] Added " + additions[pattern] + " to " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].remove !== undefined) {
+ let removals = this.overrides[name].remove;
+ removals = this.cleanPatterns(removals).split("|");
+ for (let pattern in removals) {
+ let idx = vals.indexOf(removals[pattern]);
+ if (idx != -1) {
+ vals.splice(idx, 1);
+ cal.LOG("[calExtract] Removed " + removals[pattern] + " from " + name);
+ }
+ }
+ }
+
+ vals.sort((a, b) => b.length - a.length);
+ return vals.join("|");
+ } catch (ex) {
+ cal.LOG("[calExtract] Pattern not found: " + name);
+
+ // fake a value to avoid empty regexes creating endless loops
+ return this.defPattern;
+ }
+ },
+
+ getRepPatterns(name, replaceables) {
+ let alts = [];
+ let patterns = [];
+
+ try {
+ let value = this.bundle.GetStringFromName(name);
+ if (value.trim() == "") {
+ cal.LOG("[calExtract] Pattern empty: " + name);
+ return alts;
+ }
+
+ let vals = this.cleanPatterns(value).split("|");
+ for (let idx = vals.length - 1; idx >= 0; idx--) {
+ if (vals[idx].trim() == "") {
+ vals.splice(idx, 1);
+ console.error("[calExtract] Faulty extraction pattern " + value + " for " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].add !== undefined) {
+ let additions = this.overrides[name].add;
+ additions = this.cleanPatterns(additions).split("|");
+ for (let pattern in additions) {
+ vals.push(additions[pattern]);
+ cal.LOG("[calExtract] Added " + additions[pattern] + " to " + name);
+ }
+ }
+
+ if (this.overrides[name] !== undefined && this.overrides[name].remove !== undefined) {
+ let removals = this.overrides[name].remove;
+ removals = this.cleanPatterns(removals).split("|");
+ for (let pattern in removals) {
+ let idx = vals.indexOf(removals[pattern]);
+ if (idx != -1) {
+ vals.splice(idx, 1);
+ cal.LOG("[calExtract] Removed " + removals[pattern] + " from " + name);
+ }
+ }
+ }
+
+ vals.sort((a, b) => b.length - a.length);
+ for (let val in vals) {
+ let pattern = vals[val];
+ for (let cnt = 1; cnt <= replaceables.length; cnt++) {
+ pattern = pattern.split("#" + cnt).join(replaceables[cnt - 1]);
+ }
+ patterns.push(pattern);
+ }
+
+ for (let val in vals) {
+ let positions = [];
+ if (replaceables.length == 1) {
+ positions[1] = 1;
+ } else {
+ positions = this.getPositionsFor(vals[val], name, replaceables.length);
+ }
+ alts[val] = { pattern: patterns[val], positions };
+ }
+ } catch (ex) {
+ cal.LOG("[calExtract] Pattern not found: " + name);
+ }
+ return alts;
+ },
+
+ getPositionsFor(str, name, count) {
+ let positions = [];
+ let re = /#(\d)/g;
+ let match;
+ let i = 0;
+ while ((match = re.exec(str))) {
+ i++;
+ positions[parseInt(match[1], 10)] = i;
+ }
+
+ // correctness checking
+ for (i = 1; i <= count; i++) {
+ if (positions[i] === undefined) {
+ console.error(
+ "[calExtract] Faulty extraction pattern " + name + ", missing parameter #" + i
+ );
+ }
+ }
+ return positions;
+ },
+
+ cleanPatterns(pattern) {
+ // remove whitespace around | if present
+ let value = pattern.replace(/\s*\|\s*/g, "|");
+ // allow matching for patterns with missing or excessive whitespace
+ return this.sanitize(value).replace(/\s+/g, "\\s*");
+ },
+
+ isValidYear(year) {
+ return year >= 2000 && year <= 2050;
+ },
+
+ isValidMonth(month) {
+ return month >= 1 && month <= 12;
+ },
+
+ isValidDay(day) {
+ return day >= 1 && day <= 31;
+ },
+
+ isValidHour(hour) {
+ return hour >= 0 && hour <= 23;
+ },
+
+ isValidMinute(minute) {
+ return minute >= 0 && minute <= 59;
+ },
+
+ isPastDate(date, referenceDate) {
+ // avoid changing original refDate
+ let refDate = new Date(referenceDate.getTime());
+ refDate.setHours(0);
+ refDate.setMinutes(0);
+ refDate.setSeconds(0);
+ refDate.setMilliseconds(0);
+ let jsDate;
+ if (date.day != null) {
+ jsDate = new Date(date.year, date.month - 1, date.day);
+ }
+ return jsDate < refDate;
+ },
+
+ normalizeHour(hour) {
+ if (hour < this.dayStart && hour <= 11) {
+ return hour + 12;
+ }
+ return hour;
+ },
+
+ normalizeYear(year) {
+ return year.length == 2 ? "20" + year : year;
+ },
+
+ limitNums(res, email) {
+ let pattern = email.substring(res.index, res.index + res[0].length);
+ let before = email.charAt(res.index - 1);
+ let after = email.charAt(res.index + res[0].length);
+ let result =
+ (/\d/.exec(before) && /\d/.exec(pattern.charAt(0))) ||
+ (/\d/.exec(pattern.charAt(pattern.length - 1)) && /\d/.exec(after));
+ return result != null;
+ },
+
+ limitChars(res, email) {
+ let alphabet = this.getPatterns("alphabet");
+ // for languages without regular alphabet surrounding characters are ignored
+ if (alphabet == this.defPattern) {
+ return false;
+ }
+
+ let pattern = email.substring(res.index, res.index + res[0].length);
+ let before = email.charAt(res.index - 1);
+ let after = email.charAt(res.index + res[0].length);
+
+ let re = new RegExp("[" + alphabet + "]");
+ let result =
+ (re.exec(before) && re.exec(pattern.charAt(0))) ||
+ (re.exec(pattern.charAt(pattern.length - 1)) && re.exec(after));
+ return result != null;
+ },
+
+ prefixSuffixStartEnd(res, relation, email) {
+ let pattern = email.substring(res.index, res.index + res[0].length);
+ let prev = email.substring(0, res.index);
+ let next = email.substring(res.index + res[0].length);
+ let prefixSuffix = {
+ start: res.index,
+ end: res.index + res[0].length,
+ pattern,
+ relation,
+ };
+ let char = "\\s*";
+ let psres;
+
+ let re = new RegExp("(" + this.getPatterns("end.prefix") + ")" + char + "$", "ig");
+ if ((psres = re.exec(prev)) != null) {
+ prefixSuffix.relation = "end";
+ prefixSuffix.start = psres.index;
+ prefixSuffix.pattern = psres[0] + pattern;
+ }
+
+ re = new RegExp("^" + char + "(" + this.getPatterns("end.suffix") + ")", "ig");
+ if ((psres = re.exec(next)) != null) {
+ prefixSuffix.relation = "end";
+ prefixSuffix.end = prefixSuffix.end + psres[0].length;
+ prefixSuffix.pattern = pattern + psres[0];
+ }
+
+ re = new RegExp("(" + this.getPatterns("start.prefix") + ")" + char + "$", "ig");
+ if ((psres = re.exec(prev)) != null) {
+ prefixSuffix.relation = "start";
+ prefixSuffix.start = psres.index;
+ prefixSuffix.pattern = psres[0] + pattern;
+ }
+
+ re = new RegExp("^" + char + "(" + this.getPatterns("start.suffix") + ")", "ig");
+ if ((psres = re.exec(next)) != null) {
+ prefixSuffix.relation = "start";
+ prefixSuffix.end = prefixSuffix.end + psres[0].length;
+ prefixSuffix.pattern = pattern + psres[0];
+ }
+
+ re = new RegExp("\\s(" + this.getPatterns("no.datetime.prefix") + ")" + char + "$", "ig");
+
+ if ((psres = re.exec(prev)) != null) {
+ prefixSuffix.relation = "notadatetime";
+ }
+
+ re = new RegExp("^" + char + "(" + this.getPatterns("no.datetime.suffix") + ")", "ig");
+ if ((psres = re.exec(next)) != null) {
+ prefixSuffix.relation = "notadatetime";
+ }
+
+ return prefixSuffix;
+ },
+
+ parseNumber(numberString, numbers) {
+ let number = parseInt(numberString, 10);
+ // number comes in as plain text, numbers are already adjusted for usage
+ // in regular expression
+ let cleanNumberString = this.cleanPatterns(numberString);
+ if (isNaN(number)) {
+ for (let i = 0; i <= 31; i++) {
+ let numberparts = numbers[i].split("|");
+ if (numberparts.includes(cleanNumberString.toLowerCase())) {
+ return i;
+ }
+ }
+ return -1;
+ }
+ return number;
+ },
+
+ guess(year, month, day, hour, minute, start, end, str, relation, pattern, ambiguous) {
+ let dateGuess = {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ start,
+ end,
+ str,
+ relation,
+ pattern,
+ ambiguous,
+ };
+
+ // past dates are kept for containment checks
+ if (this.isPastDate(dateGuess, this.now)) {
+ dateGuess.relation = "notadatetime";
+ }
+ this.collected.push(dateGuess);
+ },
+
+ sanitize(str) {
+ return str.replace(/[-[\]{}()*+?.,\\^$]/g, "\\$&");
+ },
+
+ unescape(str) {
+ return str.replace(/\\([.])/g, "$1");
+ },
+};
diff --git a/comm/calendar/base/modules/calHashedArray.jsm b/comm/calendar/base/modules/calHashedArray.jsm
new file mode 100644
index 0000000000..1094abb765
--- /dev/null
+++ b/comm/calendar/base/modules/calHashedArray.jsm
@@ -0,0 +1,258 @@
+/* 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/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this
+
+/**
+ * An unsorted array of hashable items with some extra functions to quickly
+ * retrieve the item by its hash id.
+ *
+ * Performance Considerations:
+ * - Accessing items is fast
+ * - Adding items is fast (they are added to the end)
+ * - Deleting items is O(n)
+ * - Modifying items is fast.
+ */
+cal.HashedArray = function () {
+ this.clear();
+};
+
+cal.HashedArray.prototype = {
+ mArray: null,
+ mHash: null,
+
+ mBatch: 0,
+ mFirstDirty: -1,
+
+ /**
+ * Returns a copy of the internal array. Note this is a shallow copy.
+ */
+ get arrayCopy() {
+ return this.mArray.concat([]);
+ },
+
+ /**
+ * The function to retrieve the hashId given the item. This function can be
+ * overridden by implementations, in case the added items are not instances
+ * of calIItemBase.
+ *
+ * @param item The item to get the hashId for
+ * @returns The hashId of the item
+ */
+ hashAccessor(item) {
+ return item.hashId;
+ },
+
+ /**
+ * Returns the item, given its index in the array
+ *
+ * @param index The index of the item to retrieve.
+ * @returns The retrieved item.
+ */
+ itemByIndex(index) {
+ return this.mArray[index];
+ },
+
+ /**
+ * Returns the item, given its hashId
+ *
+ * @param id The hashId of the item to retrieve.
+ * @returns The retrieved item.
+ */
+ itemById(id) {
+ if (this.mBatch > 0) {
+ throw new Error("Accessing Array by ID not supported in batch mode");
+ }
+ return id in this.mHash ? this.mArray[this.mHash[id]] : null;
+ },
+
+ /**
+ * Returns the index of the given item. This function is cheap performance
+ * wise, since it uses the hash
+ *
+ * @param item The item to search for.
+ * @returns The index of the item.
+ */
+ indexOf(item) {
+ if (this.mBatch > 0) {
+ throw new Error("Accessing Array Indexes not supported in batch mode");
+ }
+ let hashId = this.hashAccessor(item);
+ return hashId in this.mHash ? this.mHash[hashId] : -1;
+ },
+
+ /**
+ * Remove the item with the given hashId.
+ *
+ * @param id The id of the item to be removed
+ */
+ removeById(id) {
+ if (this.mBatch > 0) {
+ throw new Error("Remvoing by ID in batch mode is not supported"); /* TODO */
+ }
+ let index = this.mHash[id];
+ delete this.mHash[id];
+ this.mArray.splice(index, 1);
+ this.reindex(index);
+ },
+
+ /**
+ * Remove the item at the given index.
+ *
+ * @param index The index of the item to remove.
+ */
+ removeByIndex(index) {
+ delete this.mHash[this.hashAccessor(this.mArray[index])];
+ this.mArray.splice(index, 1);
+ this.reindex(index);
+ },
+
+ /**
+ * Clear the whole array, removing all items. This also resets batch mode.
+ */
+ clear() {
+ this.mHash = {};
+ this.mArray = [];
+ this.mFirstDirty = -1;
+ this.mBatch = 0;
+ },
+
+ /**
+ * Add the item to the array
+ *
+ * @param item The item to add.
+ * @returns The index of the added item.
+ */
+ addItem(item) {
+ let index = this.mArray.length;
+ this.mArray.push(item);
+ this.reindex(index);
+ return index;
+ },
+
+ /**
+ * Modifies the item in the array. If the item is already in the array, then
+ * it is replaced by the passed item. Otherwise, the item is added to the
+ * array.
+ *
+ * @param item The item to modify.
+ * @returns The (new) index.
+ */
+ modifyItem(item) {
+ let hashId = this.hashAccessor(item);
+ if (hashId in this.mHash) {
+ let index = this.mHash[this.hashAccessor(item)];
+ this.mArray[index] = item;
+ return index;
+ }
+ return this.addItem(item);
+ },
+
+ /**
+ * Reindexes the items in the array. This function is mostly used
+ * internally. All parameters are inclusive. The ranges are automatically
+ * swapped if from > to.
+ *
+ * @param from (optional) The index to start indexing from. If left
+ * out, defaults to 0.
+ * @param to (optional) The index to end indexing on. If left out,
+ * defaults to the array length.
+ */
+ reindex(from, to) {
+ if (this.mArray.length == 0) {
+ return;
+ }
+
+ from = from === undefined ? 0 : from;
+ to = to === undefined ? this.mArray.length - 1 : to;
+
+ from = Math.min(this.mArray.length - 1, Math.max(0, from));
+ to = Math.min(this.mArray.length - 1, Math.max(0, to));
+
+ if (from > to) {
+ let tmp = from;
+ from = to;
+ to = tmp;
+ }
+
+ if (this.mBatch > 0) {
+ // No indexing in batch mode, but remember from where to index.
+ this.mFirstDirty = Math.min(Math.max(0, this.mFirstDirty), from);
+ return;
+ }
+
+ for (let idx = from; idx <= to; idx++) {
+ this.mHash[this.hashAccessor(this.mArray[idx])] = idx;
+ }
+ },
+
+ startBatch() {
+ this.mBatch++;
+ },
+
+ endBatch() {
+ this.mBatch = Math.max(0, this.mBatch - 1);
+
+ if (this.mBatch == 0 && this.mFirstDirty > -1) {
+ this.reindex(this.mFirstDirty);
+ this.mFirstDirty = -1;
+ }
+ },
+
+ /**
+ * Iterator to allow iterating the hashed array object.
+ */
+ *[Symbol.iterator]() {
+ yield* this.mArray;
+ },
+};
+
+/**
+ * Sorted hashed array. The array always stays sorted.
+ *
+ * Performance Considerations:
+ * - Accessing items is fast
+ * - Adding and deleting items is O(n)
+ * - Modifying items is fast.
+ */
+cal.SortedHashedArray = function (comparator) {
+ cal.HashedArray.apply(this, arguments);
+ if (!comparator) {
+ throw new Error("Sorted Hashed Array needs a comparator");
+ }
+ this.mCompFunc = comparator;
+};
+
+cal.SortedHashedArray.prototype = {
+ __proto__: cal.HashedArray.prototype,
+
+ mCompFunc: null,
+
+ addItem(item) {
+ let newIndex = cal.data.binaryInsert(this.mArray, item, this.mCompFunc, false);
+ this.reindex(newIndex);
+ return newIndex;
+ },
+
+ modifyItem(item) {
+ let hashId = this.hashAccessor(item);
+ if (hashId in this.mHash) {
+ let cmp = this.mCompFunc(item, this.mArray[this.mHash[hashId]]);
+ if (cmp == 0) {
+ // The item will be at the same index, we just need to replace it
+ this.mArray[this.mHash[hashId]] = item;
+ return this.mHash[hashId];
+ }
+ let oldIndex = this.mHash[hashId];
+
+ let newIndex = cal.data.binaryInsert(this.mArray, item, this.mCompFunc, false);
+ this.mArray.splice(oldIndex, 1);
+ this.reindex(oldIndex, newIndex);
+ return newIndex;
+ }
+ return this.addItem(item);
+ },
+};
diff --git a/comm/calendar/base/modules/calRecurrenceUtils.jsm b/comm/calendar/base/modules/calRecurrenceUtils.jsm
new file mode 100644
index 0000000000..125f429801
--- /dev/null
+++ b/comm/calendar/base/modules/calRecurrenceUtils.jsm
@@ -0,0 +1,553 @@
+/* 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/. */
+
+/* exported recurrenceStringFromItem, recurrenceRule2String, splitRecurrenceRules,
+ * checkRecurrenceRule, countOccurrences
+ */
+
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalRecurrenceDate: "resource:///modules/CalRecurrenceDate.jsm",
+ CalRecurrenceRule: "resource:///modules/CalRecurrenceRule.jsm",
+});
+
+const EXPORTED_SYMBOLS = [
+ "recurrenceStringFromItem",
+ "recurrenceRule2String",
+ "splitRecurrenceRules",
+ "checkRecurrenceRule",
+ "countOccurrences",
+ "hasUnsupported",
+];
+
+/**
+ * Given a calendar event or task, return a string that describes the item's
+ * recurrence pattern. When the recurrence pattern is too complex, return a
+ * "too complex" string by getting that string using the arguments provided.
+ *
+ * @param {calIEvent | calITodo} item A calendar item.
+ * @param {string} bundleName - Name of the properties file, e.g. "calendar-event-dialog".
+ * @param {string} stringName - Name of the string within the properties file.
+ * @param {string[]} [params] - (optional) Parameters to format the string.
+ * @returns {string | null} A string describing the recurrence
+ * pattern or null if the item has no
+ * recurrence info.
+ */
+function recurrenceStringFromItem(item, bundleName, stringName, params) {
+ // See the `parentItem` property of `calIItemBase`.
+ let parent = item.parentItem;
+
+ let recurrenceInfo = parent.recurrenceInfo;
+ if (!recurrenceInfo) {
+ return null;
+ }
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ let rawStartDate = parent.startDate || parent.entryDate;
+ let rawEndDate = parent.endDate || parent.dueDate;
+
+ let startDate = rawStartDate ? rawStartDate.getInTimezone(kDefaultTimezone) : null;
+ let endDate = rawEndDate ? rawEndDate.getInTimezone(kDefaultTimezone) : null;
+
+ return (
+ recurrenceRule2String(recurrenceInfo, startDate, endDate, startDate.isDate) ||
+ cal.l10n.getString(bundleName, stringName, params)
+ );
+}
+
+/**
+ * This function takes the recurrence info passed as argument and creates a
+ * literal string representing the repeat pattern in natural language.
+ *
+ * @param recurrenceInfo An item's recurrence info to parse.
+ * @param startDate The start date to base rules on.
+ * @param endDate The end date to base rules on.
+ * @param allDay If true, the pattern should assume an allday item.
+ * @returns A human readable string describing the recurrence.
+ */
+function recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay) {
+ function getRString(name, args) {
+ return cal.l10n.getString("calendar-event-dialog", name, args);
+ }
+ function day_of_week(day) {
+ return Math.abs(day) % 8;
+ }
+ function day_position(day) {
+ return ((Math.abs(day) - day_of_week(day)) / 8) * (day < 0 ? -1 : 1);
+ }
+ function nounClass(aDayString, aRuleString) {
+ // Select noun class (grammatical gender) for rule string
+ let nounClassStr = getRString(aDayString + "Nounclass");
+ return aRuleString + nounClassStr.substr(0, 1).toUpperCase() + nounClassStr.substr(1);
+ }
+ function pluralWeekday(aDayString) {
+ let plural = getRString("pluralForWeekdays") == "true";
+ return plural ? aDayString + "Plural" : aDayString;
+ }
+ function everyWeekDay(aByDay) {
+ // Checks if aByDay contains only values from 1 to 7 with any order.
+ let mask = aByDay.reduce((value, item) => value | (1 << item), 1);
+ return aByDay.length == 7 && mask == Math.pow(2, 8) - 1;
+ }
+
+ // Retrieve a valid recurrence rule from the currently
+ // set recurrence info. Bail out if there's more
+ // than a single rule or something other than a rule.
+ recurrenceInfo = recurrenceInfo.clone();
+ if (hasUnsupported(recurrenceInfo)) {
+ return null;
+ }
+
+ let rrules = splitRecurrenceRules(recurrenceInfo);
+ if (rrules[0].length == 1) {
+ let rule = cal.wrapInstance(rrules[0][0], Ci.calIRecurrenceRule);
+ // Currently we allow only for BYDAY, BYMONTHDAY, BYMONTH rules.
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ /* "BYDAY", */
+ "BYHOUR",
+ /* "BYMONTHDAY", */
+ "BYYEARDAY",
+ "BYWEEKNO",
+ /* "BYMONTH", */
+ "BYSETPOS",
+ ];
+
+ if (rule && !checkRecurrenceRule(rule, byparts)) {
+ let dateFormatter = cal.dtz.formatter;
+ let ruleString;
+ if (rule.type == "DAILY") {
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ let days = rule.getComponent("BYDAY");
+ let weekdays = [2, 3, 4, 5, 6];
+ if (weekdays.length == days.length) {
+ let i;
+ for (i = 0; i < weekdays.length; i++) {
+ if (weekdays[i] != days[i]) {
+ break;
+ }
+ }
+ if (i == weekdays.length) {
+ ruleString = getRString("repeatDetailsRuleDaily4");
+ }
+ } else {
+ return null;
+ }
+ } else {
+ let dailyString = getRString("dailyEveryNth");
+ ruleString = PluralForm.get(rule.interval, dailyString).replace("#1", rule.interval);
+ }
+ } else if (rule.type == "WEEKLY") {
+ // weekly recurrence, currently we
+ // support a single 'BYDAY'-rule only.
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ // create a string like 'Monday, Tuesday and Wednesday'
+ let days = rule.getComponent("BYDAY");
+ let weekdays = "";
+ // select noun class (grammatical gender) according to the
+ // first day of the list
+ let weeklyString = nounClass("repeatDetailsDay" + days[0], "weeklyNthOn");
+ for (let i = 0; i < days.length; i++) {
+ if (rule.interval == 1) {
+ weekdays += getRString(pluralWeekday("repeatDetailsDay" + days[i]));
+ } else {
+ weekdays += getRString("repeatDetailsDay" + days[i]);
+ }
+ if (days.length > 1 && i == days.length - 2) {
+ weekdays += " " + getRString("repeatDetailsAnd") + " ";
+ } else if (i < days.length - 1) {
+ weekdays += ", ";
+ }
+ }
+
+ weeklyString = getRString(weeklyString, [weekdays]);
+ ruleString = PluralForm.get(rule.interval, weeklyString).replace("#2", rule.interval);
+ } else {
+ let weeklyString = getRString("weeklyEveryNth");
+ ruleString = PluralForm.get(rule.interval, weeklyString).replace("#1", rule.interval);
+ }
+ } else if (rule.type == "MONTHLY") {
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ let byday = rule.getComponent("BYDAY");
+ if (everyWeekDay(byday)) {
+ // Rule every day of the month.
+ ruleString = getRString("monthlyEveryDayOfNth");
+ ruleString = PluralForm.get(rule.interval, ruleString).replace("#2", rule.interval);
+ } else {
+ // For rules with generic number of weekdays with and
+ // without "position" prefix we build two separate
+ // strings depending on the position and then join them.
+ // Notice: we build the description string but currently
+ // the UI can manage only rules with only one weekday.
+ let weekdaysString_every = "";
+ let weekdaysString_position = "";
+ let firstDay = byday[0];
+ for (let i = 0; i < byday.length; i++) {
+ if (day_position(byday[i]) == 0) {
+ if (!weekdaysString_every) {
+ firstDay = byday[i];
+ }
+ weekdaysString_every +=
+ getRString(pluralWeekday("repeatDetailsDay" + byday[i])) + ", ";
+ } else {
+ if (day_position(byday[i]) < -1 || day_position(byday[i]) > 5) {
+ // We support only weekdays with -1 as negative
+ // position ('THE LAST ...').
+ return null;
+ }
+
+ let duplicateWeekday = byday.some(element => {
+ return (
+ day_position(element) == 0 && day_of_week(byday[i]) == day_of_week(element)
+ );
+ });
+ if (duplicateWeekday) {
+ // Prevent to build strings such as for example:
+ // "every Monday and the second Monday...".
+ continue;
+ }
+
+ let ordinalString = "repeatOrdinal" + day_position(byday[i]);
+ let dayString = "repeatDetailsDay" + day_of_week(byday[i]);
+ ordinalString = nounClass(dayString, ordinalString);
+ ordinalString = getRString(ordinalString);
+ dayString = getRString(dayString);
+ let stringOrdinalWeekday = getRString("ordinalWeekdayOrder", [
+ ordinalString,
+ dayString,
+ ]);
+ weekdaysString_position += stringOrdinalWeekday + ", ";
+ }
+ }
+ let weekdaysString = weekdaysString_every + weekdaysString_position;
+ weekdaysString = weekdaysString
+ .slice(0, -2)
+ .replace(/,(?= [^,]*$)/, " " + getRString("repeatDetailsAnd"));
+
+ let monthlyString = weekdaysString_every
+ ? "monthlyEveryOfEvery"
+ : "monthlyRuleNthOfEvery";
+ monthlyString = nounClass("repeatDetailsDay" + day_of_week(firstDay), monthlyString);
+ monthlyString = getRString(monthlyString, [weekdaysString]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) {
+ let component = rule.getComponent("BYMONTHDAY");
+
+ // First, find out if the 'BYMONTHDAY' component contains
+ // any elements with a negative value lesser than -1 ("the
+ // last day"). If so we currently don't support any rule
+ if (component.some(element => element < -1)) {
+ // we don't support any other combination for now...
+ return getRString("ruleTooComplex");
+ } else if (component.length == 1 && component[0] == -1) {
+ // i.e. one day, the last day of the month
+ let monthlyString = getRString("monthlyLastDayOfNth");
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#1", rule.interval);
+ } else {
+ // i.e. one or more monthdays every N months.
+
+ // Build a string with a list of days separated with commas.
+ let day_string = "";
+ let lastDay = false;
+ for (let i = 0; i < component.length; i++) {
+ if (component[i] == -1) {
+ lastDay = true;
+ continue;
+ }
+ day_string += dateFormatter.formatDayWithOrdinal(component[i]) + ", ";
+ }
+ if (lastDay) {
+ day_string += getRString("monthlyLastDay") + ", ";
+ }
+ day_string = day_string
+ .slice(0, -2)
+ .replace(/,(?= [^,]*$)/, " " + getRString("repeatDetailsAnd"));
+
+ // Add the word "day" in plural form to the list of days then
+ // compose the final string with the interval of months
+ let monthlyDayString = getRString("monthlyDaysOfNth_day", [day_string]);
+ monthlyDayString = PluralForm.get(component.length, monthlyDayString);
+ let monthlyString = getRString("monthlyDaysOfNth", [monthlyDayString]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else {
+ let monthlyString = getRString("monthlyDaysOfNth", [startDate.day]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else if (rule.type == "YEARLY") {
+ let bymonthday = null;
+ let bymonth = null;
+ if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) {
+ bymonthday = rule.getComponent("BYMONTHDAY");
+ }
+ if (checkRecurrenceRule(rule, ["BYMONTH"])) {
+ bymonth = rule.getComponent("BYMONTH");
+ }
+ if (
+ (bymonth && bymonth.length > 1) ||
+ (bymonthday && (bymonthday.length > 1 || bymonthday[0] < -1))
+ ) {
+ // Don't build a string for a recurrence rule that the UI
+ // currently can't show completely (with more than one month
+ // or than one monthday, or bymonthdays lesser than -1).
+ return getRString("ruleTooComplex");
+ }
+
+ if (
+ checkRecurrenceRule(rule, ["BYMONTHDAY"]) &&
+ (checkRecurrenceRule(rule, ["BYMONTH"]) || !checkRecurrenceRule(rule, ["BYDAY"]))
+ ) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x;BYMONTHDAY=y.
+ // RRULE:FREQ=YEARLY;BYMONTHDAY=x (takes the month from the start date).
+ let monthNumber = bymonth ? bymonth[0] : startDate.month + 1;
+ let month = getRString("repeatDetailsMonth" + monthNumber);
+ let monthDay =
+ bymonthday[0] == -1
+ ? getRString("monthlyLastDay")
+ : dateFormatter.formatDayWithOrdinal(bymonthday[0]);
+ let yearlyString = getRString("yearlyNthOn", [month, monthDay]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else if (checkRecurrenceRule(rule, ["BYMONTH"]) && checkRecurrenceRule(rule, ["BYDAY"])) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x;BYDAY=y1,y2,....
+ let byday = rule.getComponent("BYDAY");
+ let month = getRString("repeatDetailsMonth" + bymonth[0]);
+ if (everyWeekDay(byday)) {
+ // Every day of the month.
+ let yearlyString = "yearlyEveryDayOf";
+ yearlyString = getRString(yearlyString, [month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#2", rule.interval);
+ } else if (byday.length == 1) {
+ let dayString = "repeatDetailsDay" + day_of_week(byday[0]);
+ if (day_position(byday[0]) == 0) {
+ // Every any weekday.
+ let yearlyString = "yearlyOnEveryNthOfNth";
+ yearlyString = nounClass(dayString, yearlyString);
+ let day = getRString(pluralWeekday(dayString));
+ yearlyString = getRString(yearlyString, [day, month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else if (day_position(byday[0]) >= -1 || day_position(byday[0]) <= 5) {
+ // The first|the second|...|the last Monday, Tuesday, ..., day.
+ let yearlyString = "yearlyNthOnNthOf";
+ yearlyString = nounClass(dayString, yearlyString);
+ let ordinalString = "repeatOrdinal" + day_position(byday[0]);
+ ordinalString = nounClass(dayString, ordinalString);
+ let ordinal = getRString(ordinalString);
+ let day = getRString(dayString);
+ yearlyString = getRString(yearlyString, [ordinal, day, month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#4", rule.interval);
+ } else {
+ return getRString("ruleTooComplex");
+ }
+ } else {
+ // Currently we don't support yearly rules with
+ // more than one BYDAY element or exactly 7 elements
+ // with all the weekdays (the "every day" case).
+ return getRString("ruleTooComplex");
+ }
+ } else if (checkRecurrenceRule(rule, ["BYMONTH"])) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x (takes the day from the start date).
+ let month = getRString("repeatDetailsMonth" + bymonth[0]);
+ let yearlyString = getRString("yearlyNthOn", [month, startDate.day]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else {
+ let month = getRString("repeatDetailsMonth" + (startDate.month + 1));
+ let yearlyString = getRString("yearlyNthOn", [month, startDate.day]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ }
+ }
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ let detailsString;
+ if (!endDate || allDay) {
+ if (rule.isFinite) {
+ if (rule.isByCount) {
+ let countString = getRString("repeatCountAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ ]);
+
+ detailsString = PluralForm.get(rule.count, countString).replace("#3", rule.count);
+ } else {
+ let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone);
+ detailsString = getRString("repeatDetailsUntilAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatDateShort(untilDate),
+ ]);
+ }
+ } else {
+ detailsString = getRString("repeatDetailsInfiniteAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ ]);
+ }
+ } else if (rule.isFinite) {
+ if (rule.isByCount) {
+ let countString = getRString("repeatCount", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ detailsString = PluralForm.get(rule.count, countString).replace("#5", rule.count);
+ } else {
+ let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone);
+ detailsString = getRString("repeatDetailsUntil", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatDateShort(untilDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ }
+ } else {
+ detailsString = getRString("repeatDetailsInfinite", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ }
+ return detailsString;
+ }
+ }
+ return null;
+}
+
+/**
+ * Used to test if the recurrence items of a calIRecurrenceInfo instance are
+ * supported. We do not currently allow the "SECONDLY" or "MINUTELY" frequency
+ * values.
+ *
+ * @param {calIRecurrenceInfo} recurrenceInfo
+ * @returns {boolean}
+ */
+function hasUnsupported(recurrenceInfo) {
+ return recurrenceInfo
+ .getRecurrenceItems()
+ .some(item => item.type == "SECONDLY" || item.type == "MINUTELY");
+}
+
+/**
+ * Split rules into negative and positive rules.
+ *
+ * @param recurrenceInfo An item's recurrence info to parse.
+ * @returns An array with two elements: an array of positive
+ * rules and an array of negative rules.
+ */
+function splitRecurrenceRules(recurrenceInfo) {
+ let ritems = recurrenceInfo.getRecurrenceItems();
+ let rules = [];
+ let exceptions = [];
+ for (let ritem of ritems) {
+ if (ritem.isNegative) {
+ exceptions.push(ritem);
+ } else {
+ rules.push(ritem);
+ }
+ }
+ return [rules, exceptions];
+}
+
+/**
+ * Check if a recurrence rule's component is valid.
+ *
+ * @see calIRecurrenceRule
+ * @param aRule The recurrence rule to check.
+ * @param aArray An array of component names to check.
+ * @returns Returns true if the rule is valid.
+ */
+function checkRecurrenceRule(aRule, aArray) {
+ for (let comp of aArray) {
+ let ruleComp = aRule.getComponent(comp);
+ if (ruleComp && ruleComp.length > 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Counts the occurrences of the parent item if any of a provided item
+ *
+ * @param {(calIEvent|calIToDo)} aItem item to count for
+ * @returns {(number|null)} number of occurrences or null if the
+ * passed item's parent item isn't a
+ * recurring item or its recurrence is
+ * infinite
+ */
+function countOccurrences(aItem) {
+ let occCounter = null;
+ let recInfo = aItem.parentItem.recurrenceInfo;
+ if (recInfo && recInfo.isFinite) {
+ occCounter = 0;
+ let excCounter = 0;
+ let byCount = false;
+ let ritems = recInfo.getRecurrenceItems();
+ for (let ritem of ritems) {
+ if (ritem instanceof lazy.CalRecurrenceRule || ritem instanceof Ci.calIRecurrenceRule) {
+ if (ritem.isByCount) {
+ occCounter = occCounter + ritem.count;
+ byCount = true;
+ } else {
+ // The rule is limited by an until date.
+ let parentItem = aItem.parentItem;
+ let startDate = parentItem.startDate ?? parentItem.entryDate;
+ let endDate = parentItem.endDate ?? parentItem.dueDate ?? startDate;
+ let from = startDate.clone();
+ let until = endDate.clone();
+ if (until.compare(ritem.untilDate) == -1) {
+ until = ritem.untilDate.clone();
+ }
+
+ let exceptionIds = recInfo.getExceptionIds();
+ for (let exceptionId of exceptionIds) {
+ let recur = recInfo.getExceptionFor(exceptionId);
+ let recurStartDate = recur.startDate ?? recur.entryDate;
+ let recurEndDate = recur.endDate ?? recur.dueDate ?? recurStartDate;
+ if (from.compare(recurStartDate) == 1) {
+ from = recurStartDate.clone();
+ }
+ if (until.compare(recurEndDate) == -1) {
+ until = recurEndDate.clone();
+ }
+ }
+
+ // we add an extra day at beginning and end, so we don't
+ // need to take care of any timezone conversion
+ from.addDuration(cal.createDuration("-P1D"));
+ until.addDuration(cal.createDuration("P1D"));
+
+ let occurrences = recInfo.getOccurrences(from, until, 0);
+ occCounter = occCounter + occurrences.length;
+ }
+ } else if (
+ ritem instanceof lazy.CalRecurrenceDate ||
+ ritem instanceof Ci.calIRecurrenceDate
+ ) {
+ if (ritem.isNegative) {
+ // this is an exdate
+ excCounter++;
+ } else {
+ // this is an (additional) rdate
+ occCounter++;
+ }
+ }
+ }
+
+ if (byCount) {
+ // for a rrule by count, we still need to subtract exceptions if any
+ occCounter = occCounter - excCounter;
+ }
+ }
+ return occCounter;
+}
diff --git a/comm/calendar/base/modules/calUtils.jsm b/comm/calendar/base/modules/calUtils.jsm
new file mode 100644
index 0000000000..7ee5669344
--- /dev/null
+++ b/comm/calendar/base/modules/calUtils.jsm
@@ -0,0 +1,578 @@
+/* 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { ConsoleAPI } = ChromeUtils.importESModule("resource://gre/modules/Console.sys.mjs");
+
+const { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+ICAL.design.strict = false;
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalDateTime: "resource:///modules/CalDateTime.jsm",
+ CalDuration: "resource:///modules/CalDuration.jsm",
+ CalRecurrenceDate: "resource:///modules/CalRecurrenceDate.jsm",
+ CalRecurrenceRule: "resource:///modules/CalRecurrenceRule.jsm",
+});
+
+// The calendar console instance
+var gCalendarConsole = new ConsoleAPI({
+ prefix: "Calendar",
+ consoleID: "calendar",
+ maxLogLevel: Services.prefs.getBoolPref("calendar.debug.log", false) ? "all" : "warn",
+});
+
+const EXPORTED_SYMBOLS = ["cal"];
+var cal = {
+ // These functions exist to reduce boilerplate code for creating instances
+ // as well as getting services and other (cached) objects.
+ createDateTime(value) {
+ let instance = new lazy.CalDateTime();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+ createDuration(value) {
+ let instance = new lazy.CalDuration();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+ createRecurrenceDate(value) {
+ let instance = new lazy.CalRecurrenceDate();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+ createRecurrenceRule(value) {
+ let instance = new lazy.CalRecurrenceRule();
+ if (value) {
+ instance.icalString = value;
+ }
+ return instance;
+ },
+
+ /**
+ * The calendar console instance
+ */
+ console: gCalendarConsole,
+
+ /**
+ * Logs a calendar message to the console. Needs calendar.debug.log enabled to show messages.
+ * Shortcut to cal.console.log()
+ */
+ LOG: gCalendarConsole.log,
+ LOGverbose: gCalendarConsole.debug,
+
+ /**
+ * Logs a calendar warning to the console. Shortcut to cal.console.warn()
+ */
+ WARN: gCalendarConsole.warn,
+
+ /**
+ * Logs a calendar error to the console. Shortcut to cal.console.error()
+ */
+ ERROR: gCalendarConsole.error,
+
+ /**
+ * Uses the prompt service to display an error message. Use this sparingly,
+ * as it interrupts the user.
+ *
+ * @param aMsg The message to be shown
+ * @param aWindow The window to show the message in, or null for any window.
+ */
+ showError(aMsg, aWindow = null) {
+ Services.prompt.alert(aWindow, cal.l10n.getCalString("genericErrorTitle"), aMsg);
+ },
+
+ /**
+ * Returns a string describing the current js-stack with filename and line
+ * numbers.
+ *
+ * @param aDepth (optional) The number of frames to include. Defaults to 5.
+ * @param aSkip (optional) Number of frames to skip
+ */
+ STACK(aDepth = 10, aSkip = 0) {
+ let stack = "";
+ let frame = Components.stack.caller;
+ for (let i = 1; i <= aDepth + aSkip && frame; i++) {
+ if (i > aSkip) {
+ stack += `${i}: [${frame.filename}:${frame.lineNumber}] ${frame.name}\n`;
+ }
+ frame = frame.caller;
+ }
+ return stack;
+ },
+
+ /**
+ * Logs a message and the current js-stack, if aCondition fails
+ *
+ * @param aCondition the condition to test for
+ * @param aMessage the message to report in the case the assert fails
+ * @param aCritical if true, throw an error to stop current code execution
+ * if false, code flow will continue
+ * may be a result code
+ */
+ ASSERT(aCondition, aMessage, aCritical = false) {
+ if (aCondition) {
+ return;
+ }
+
+ let string = `Assert failed: ${aMessage}\n ${cal.STACK(0, 1)}`;
+ if (aCritical) {
+ let rescode = aCritical === true ? Cr.NS_ERROR_UNEXPECTED : aCritical;
+ throw new Components.Exception(string, rescode);
+ } else {
+ console.error(string);
+ }
+ },
+
+ /**
+ * Generates the QueryInterface function. This is a replacement for XPCOMUtils.generateQI, which
+ * is being replaced. Unfortunately Calendar's code depends on some of its classes providing
+ * nsIClassInfo, which causes xpconnect/xpcom to make all methods available, e.g. for an event
+ * both calIItemBase and calIEvent.
+ *
+ * @param {Array<string | nsIIDRef>} aInterfaces The interfaces to generate QI for.
+ * @returns {Function} The QueryInterface function
+ */
+ generateQI(aInterfaces) {
+ if (aInterfaces.length == 1) {
+ cal.WARN(
+ "When generating QI for one interface, please use ChromeUtils.generateQI",
+ cal.STACK(10)
+ );
+ return ChromeUtils.generateQI(aInterfaces);
+ }
+ /* Note that Ci[Ci.x] == Ci.x for all x */
+ let names = [];
+ if (aInterfaces) {
+ for (let i = 0; i < aInterfaces.length; i++) {
+ let iface = aInterfaces[i];
+ let name = (iface && iface.name) || String(iface);
+ if (name in Ci) {
+ names.push(name);
+ }
+ }
+ }
+ return makeQI(names);
+ },
+
+ /**
+ * Generate a ClassInfo implementation for a component. The returned object
+ * must be assigned to the 'classInfo' property of a JS object. The first and
+ * only argument should be an object that contains a number of optional
+ * properties: "interfaces", "contractID", "classDescription", "classID" and
+ * "flags". The values of the properties will be returned as the values of the
+ * various properties of the nsIClassInfo implementation.
+ */
+ generateCI(classInfo) {
+ if ("QueryInterface" in classInfo) {
+ throw Error("In generateCI, don't use a component for generating classInfo");
+ }
+ /* Note that Ci[Ci.x] == Ci.x for all x */
+ let _interfaces = [];
+ for (let i = 0; i < classInfo.interfaces.length; i++) {
+ let iface = classInfo.interfaces[i];
+ if (Ci[iface]) {
+ _interfaces.push(Ci[iface]);
+ }
+ }
+ return {
+ get interfaces() {
+ return [Ci.nsIClassInfo, Ci.nsISupports].concat(_interfaces);
+ },
+ getScriptableHelper() {
+ return null;
+ },
+ contractID: classInfo.contractID,
+ classDescription: classInfo.classDescription,
+ classID: classInfo.classID,
+ flags: classInfo.flags,
+ QueryInterface: ChromeUtils.generateQI(["nsIClassInfo"]),
+ };
+ },
+
+ /**
+ * Create an adapter for the given interface. If passed, methods will be
+ * added to the template object, otherwise a new object will be returned.
+ *
+ * @param iface The interface to adapt, either using
+ * Components.interfaces or the name as a string.
+ * @param template (optional) A template object to extend
+ * @returns If passed the adapted template object, otherwise a
+ * clean adapter.
+ *
+ * Currently supported interfaces are:
+ * - calIObserver
+ * - calICalendarManagerObserver
+ * - calIOperationListener
+ * - calICompositeObserver
+ */
+ createAdapter(iface, template) {
+ let methods;
+ let adapter = template || {};
+ switch (iface.name || iface) {
+ case "calIObserver":
+ methods = [
+ "onStartBatch",
+ "onEndBatch",
+ "onLoad",
+ "onAddItem",
+ "onModifyItem",
+ "onDeleteItem",
+ "onError",
+ "onPropertyChanged",
+ "onPropertyDeleting",
+ ];
+ break;
+ case "calICalendarManagerObserver":
+ methods = ["onCalendarRegistered", "onCalendarUnregistering", "onCalendarDeleting"];
+ break;
+ case "calIOperationListener":
+ methods = ["onGetResult", "onOperationComplete"];
+ break;
+ case "calICompositeObserver":
+ methods = ["onCalendarAdded", "onCalendarRemoved", "onDefaultCalendarChanged"];
+ break;
+ default:
+ methods = [];
+ break;
+ }
+
+ for (let method of methods) {
+ if (!(method in template)) {
+ adapter[method] = function () {};
+ }
+ }
+ adapter.QueryInterface = ChromeUtils.generateQI([iface]);
+
+ return adapter;
+ },
+
+ /**
+ * Make a UUID, without enclosing brackets, e.g. 0d3950fd-22e5-4508-91ba-0489bdac513f
+ *
+ * @returns {string} The generated UUID
+ */
+ getUUID() {
+ // generate uuids without braces to avoid problems with
+ // CalDAV servers that don't support filenames with {}
+ return Services.uuid.generateUUID().toString().replace(/[{}]/g, "");
+ },
+
+ /**
+ * Adds an observer listening for the topic.
+ *
+ * @param func function to execute on topic
+ * @param topic topic to listen for
+ * @param oneTime whether to listen only once
+ */
+ addObserver(func, topic, oneTime) {
+ let observer = {
+ // nsIObserver:
+ observe(subject, topic_, data) {
+ if (topic == topic_) {
+ if (oneTime) {
+ Services.obs.removeObserver(this, topic);
+ }
+ func(subject, topic, data);
+ }
+ },
+ };
+ Services.obs.addObserver(observer, topic);
+ },
+
+ /**
+ * Wraps an instance, making sure the xpcom wrapped object is used.
+ *
+ * @param aObj the object under consideration
+ * @param aInterface the interface to be wrapped
+ *
+ * Use this function to QueryInterface the object to a particular interface.
+ * You may only expect the return value to be wrapped, not the original passed object.
+ * For example:
+ * // BAD USAGE:
+ * if (cal.wrapInstance(foo, Ci.nsIBar)) {
+ * foo.barMethod();
+ * }
+ * // GOOD USAGE:
+ * foo = cal.wrapInstance(foo, Ci.nsIBar);
+ * if (foo) {
+ * foo.barMethod();
+ * }
+ *
+ */
+ wrapInstance(aObj, aInterface) {
+ if (!aObj) {
+ return null;
+ }
+
+ try {
+ return aObj.QueryInterface(aInterface);
+ } catch (e) {
+ return null;
+ }
+ },
+
+ /**
+ * Tries to get rid of wrappers, if this is not possible then return the
+ * passed object.
+ *
+ * @param aObj The object under consideration
+ * @returns The possibly unwrapped object.
+ */
+ unwrapInstance(aObj) {
+ return aObj && aObj.wrappedJSObject ? aObj.wrappedJSObject : aObj;
+ },
+
+ /**
+ * Adds an xpcom shutdown observer.
+ *
+ * @param func function to execute
+ */
+ addShutdownObserver(func) {
+ cal.addObserver(func, "xpcom-shutdown", true /* one time */);
+ },
+
+ /**
+ * Due to wrapped js objects, some objects may have cyclic references.
+ * You can register properties of objects to be cleaned up on xpcom-shutdown.
+ *
+ * @param obj object
+ * @param prop property to be deleted on shutdown
+ * (if null, |object| will be deleted)
+ */
+ registerForShutdownCleanup: shutdownCleanup,
+};
+
+/**
+ * Update the logging preferences for the calendar console based on the state of verbose logging and
+ * normal calendar logging.
+ */
+function updateLogPreferences() {
+ if (cal.verboseLogEnabled) {
+ gCalendarConsole.maxLogLevel = "all";
+ } else if (cal.debugLogEnabled) {
+ gCalendarConsole.maxLogLevel = "log";
+ } else {
+ gCalendarConsole.maxLogLevel = "warn";
+ }
+}
+
+// Preferences
+XPCOMUtils.defineLazyPreferenceGetter(
+ cal,
+ "debugLogEnabled",
+ "calendar.debug.log",
+ false,
+ updateLogPreferences
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ cal,
+ "verboseLogEnabled",
+ "calendar.debug.log.verbose",
+ false,
+ updateLogPreferences
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ cal,
+ "threadingEnabled",
+ "calendar.threading.disabled",
+ false
+);
+
+// Services
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "manager",
+ "@mozilla.org/calendar/manager;1",
+ "calICalendarManager"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "icsService",
+ "@mozilla.org/calendar/ics-service;1",
+ "calIICSService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "timezoneService",
+ "@mozilla.org/calendar/timezone-service;1",
+ "calITimezoneService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "freeBusyService",
+ "@mozilla.org/calendar/freebusy-service;1",
+ "calIFreeBusyService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "weekInfoService",
+ "@mozilla.org/calendar/weekinfo-service;1",
+ "calIWeekInfoService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ cal,
+ "dragService",
+ "@mozilla.org/widget/dragservice;1",
+ "nsIDragService"
+);
+
+// Sub-modules for calUtils
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "acl",
+ "resource:///modules/calendar/utils/calACLUtils.jsm",
+ "calacl"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "alarms",
+ "resource:///modules/calendar/utils/calAlarmUtils.jsm",
+ "calalarms"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "auth",
+ "resource:///modules/calendar/utils/calAuthUtils.jsm",
+ "calauth"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "category",
+ "resource:///modules/calendar/utils/calCategoryUtils.jsm",
+ "calcategory"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "data",
+ "resource:///modules/calendar/utils/calDataUtils.jsm",
+ "caldata"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "dtz",
+ "resource:///modules/calendar/utils/calDateTimeUtils.jsm",
+ "caldtz"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "email",
+ "resource:///modules/calendar/utils/calEmailUtils.jsm",
+ "calemail"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "invitation",
+ "resource:///modules/calendar/utils/calInvitationUtils.jsm",
+ "calinvitation"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "item",
+ "resource:///modules/calendar/utils/calItemUtils.jsm",
+ "calitem"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "iterate",
+ "resource:///modules/calendar/utils/calIteratorUtils.jsm",
+ "caliterate"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "itip",
+ "resource:///modules/calendar/utils/calItipUtils.jsm",
+ "calitip"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "l10n",
+ "resource:///modules/calendar/utils/calL10NUtils.jsm",
+ "call10n"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "print",
+ "resource:///modules/calendar/utils/calPrintUtils.jsm",
+ "calprint"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "provider",
+ "resource:///modules/calendar/utils/calProviderUtils.jsm",
+ "calprovider"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "unifinder",
+ "resource:///modules/calendar/utils/calUnifinderUtils.jsm",
+ "calunifinder"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "view",
+ "resource:///modules/calendar/utils/calViewUtils.jsm",
+ "calview"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "window",
+ "resource:///modules/calendar/utils/calWindowUtils.jsm",
+ "calwindow"
+);
+XPCOMUtils.defineLazyModuleGetter(
+ cal,
+ "xml",
+ "resource:///modules/calendar/utils/calXMLUtils.jsm",
+ "calxml"
+);
+
+// will be used to clean up global objects on shutdown
+// some objects have cyclic references due to wrappers
+function shutdownCleanup(obj, prop) {
+ if (!shutdownCleanup.mEntries) {
+ shutdownCleanup.mEntries = [];
+ cal.addShutdownObserver(() => {
+ for (let entry of shutdownCleanup.mEntries) {
+ if (entry.mProp) {
+ delete entry.mObj[entry.mProp];
+ } else {
+ delete entry.mObj;
+ }
+ }
+ delete shutdownCleanup.mEntries;
+ });
+ }
+ shutdownCleanup.mEntries.push({ mObj: obj, mProp: prop });
+}
+
+/**
+ * This is the makeQI function from XPCOMUtils.sys.mjs, it is separate to avoid leaks
+ *
+ * @param {Array<string | nsIIDRef>} aInterfaces The interfaces to make QI for.
+ * @returns {Function} The QueryInterface function.
+ */
+function makeQI(aInterfaces) {
+ return function (iid) {
+ if (iid.equals(Ci.nsISupports)) {
+ return this;
+ }
+ if (iid.equals(Ci.nsIClassInfo) && "classInfo" in this) {
+ return this.classInfo;
+ }
+ for (let i = 0; i < aInterfaces.length; i++) {
+ if (Ci[aInterfaces[i]].equals(iid)) {
+ return this;
+ }
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ };
+}
diff --git a/comm/calendar/base/modules/moz.build b/comm/calendar/base/modules/moz.build
new file mode 100644
index 0000000000..f0269ff7f6
--- /dev/null
+++ b/comm/calendar/base/modules/moz.build
@@ -0,0 +1,36 @@
+# vim: set filetype=python:
+# 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/.
+
+EXTRA_JS_MODULES.calendar.utils += [
+ "utils/calACLUtils.jsm",
+ "utils/calAlarmUtils.jsm",
+ "utils/calAuthUtils.jsm",
+ "utils/calCategoryUtils.jsm",
+ "utils/calDataUtils.jsm",
+ "utils/calDateTimeFormatter.jsm",
+ "utils/calDateTimeUtils.jsm",
+ "utils/calEmailUtils.jsm",
+ "utils/calInvitationUtils.jsm",
+ "utils/calItemUtils.jsm",
+ "utils/calIteratorUtils.jsm",
+ "utils/calItipUtils.jsm",
+ "utils/calL10NUtils.jsm",
+ "utils/calPrintUtils.jsm",
+ "utils/calProviderDetectionUtils.jsm",
+ "utils/calProviderUtils.jsm",
+ "utils/calUnifinderUtils.jsm",
+ "utils/calViewUtils.jsm",
+ "utils/calWindowUtils.jsm",
+ "utils/calXMLUtils.jsm",
+]
+
+EXTRA_JS_MODULES.calendar += [
+ "calCalendarDeactivator.jsm",
+ "calExtract.jsm",
+ "calHashedArray.jsm",
+ "calRecurrenceUtils.jsm",
+ "calUtils.jsm",
+ "Ical.jsm",
+]
diff --git a/comm/calendar/base/modules/utils/calACLUtils.jsm b/comm/calendar/base/modules/utils/calACLUtils.jsm
new file mode 100644
index 0000000000..507d9f232d
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calACLUtils.jsm
@@ -0,0 +1,92 @@
+/* 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/. */
+
+/**
+ * Helpers for permission checks and other ACL features
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.acl namespace.
+
+const EXPORTED_SYMBOLS = ["calacl"];
+
+var calacl = {
+ /**
+ * Check if the specified calendar is writable. This is the case when it is
+ * not marked readOnly, we are not offline, or we are offline and the
+ * calendar is local.
+ *
+ * @param aCalendar The calendar to check
+ * @returns True if the calendar is writable
+ */
+ isCalendarWritable(aCalendar) {
+ return (
+ !aCalendar.getProperty("disabled") &&
+ !aCalendar.readOnly &&
+ (!Services.io.offline ||
+ aCalendar.getProperty("cache.enabled") ||
+ aCalendar.getProperty("cache.always") ||
+ aCalendar.getProperty("requiresNetwork") === false)
+ );
+ },
+
+ /**
+ * Check if the specified calendar is writable from an ACL point of view.
+ *
+ * @param aCalendar The calendar to check
+ * @returns True if the calendar is writable
+ */
+ userCanAddItemsToCalendar(aCalendar) {
+ let aclEntry = aCalendar.aclEntry;
+ return (
+ !aclEntry || !aclEntry.hasAccessControl || aclEntry.userIsOwner || aclEntry.userCanAddItems
+ );
+ },
+
+ /**
+ * Check if the user can delete items from the specified calendar, from an
+ * ACL point of view.
+ *
+ * @param aCalendar The calendar to check
+ * @returns True if the calendar is writable
+ */
+ userCanDeleteItemsFromCalendar(aCalendar) {
+ let aclEntry = aCalendar.aclEntry;
+ return (
+ !aclEntry || !aclEntry.hasAccessControl || aclEntry.userIsOwner || aclEntry.userCanDeleteItems
+ );
+ },
+
+ /**
+ * Check if the user can fully modify the specified item, from an ACL point
+ * of view. Note to be confused with the right to respond to an
+ * invitation, which is handled instead by userCanRespondToInvitation.
+ *
+ * @param aItem The calendar item to check
+ * @returns True if the item is modifiable
+ */
+ userCanModifyItem(aItem) {
+ let aclEntry = aItem.aclEntry;
+ return (
+ !aclEntry ||
+ !aclEntry.calendarEntry.hasAccessControl ||
+ aclEntry.calendarEntry.userIsOwner ||
+ aclEntry.userCanModify
+ );
+ },
+
+ /**
+ * Checks if the user can modify the item and has the right to respond to
+ * invitations for the item.
+ *
+ * @param aItem The calendar item to check
+ * @returns True if the invitation w.r.t. the item can be
+ * responded to.
+ */
+ userCanRespondToInvitation(aItem) {
+ let aclEntry = aItem.aclEntry;
+ // TODO check if || is really wanted here
+ return calacl.userCanModifyItem(aItem) || aclEntry.userCanRespond;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calAlarmUtils.jsm b/comm/calendar/base/modules/utils/calAlarmUtils.jsm
new file mode 100644
index 0000000000..d4792652f7
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calAlarmUtils.jsm
@@ -0,0 +1,161 @@
+/* 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/. */
+
+/**
+ * Helpers for manipulating calendar alarms
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.alarm namespace.
+
+const EXPORTED_SYMBOLS = ["calalarms"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+});
+
+var calalarms = {
+ /**
+ * Read default alarm settings from user preferences and apply them to the
+ * event/todo passed in. The item's calendar should be set to ensure the
+ * correct alarm type is set.
+ *
+ * @param aItem The item to apply the default alarm values to.
+ */
+ setDefaultValues(aItem) {
+ let type = aItem.isEvent() ? "event" : "todo";
+ if (Services.prefs.getIntPref("calendar.alarms.onfor" + type + "s", 0) == 1) {
+ let alarmOffset = lazy.cal.createDuration();
+ let alarm = new lazy.CalAlarm();
+ let units = Services.prefs.getStringPref("calendar.alarms." + type + "alarmunit", "minutes");
+
+ // Make sure the alarm pref is valid, default to minutes otherwise
+ if (!["weeks", "days", "hours", "minutes", "seconds"].includes(units)) {
+ units = "minutes";
+ }
+
+ alarmOffset[units] = Services.prefs.getIntPref("calendar.alarms." + type + "alarmlen", 0);
+ alarmOffset.normalize();
+ alarmOffset.isNegative = true;
+ if (type == "todo" && !aItem.entryDate) {
+ // You can't have an alarm if the entryDate doesn't exist.
+ aItem.entryDate = lazy.cal.dtz.now();
+ }
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_START;
+ alarm.offset = alarmOffset;
+
+ // Default to a display alarm, unless the calendar doesn't support
+ // it or we have no calendar yet. (Man this is hard to wrap)
+ let actionValues = (aItem.calendar &&
+ aItem.calendar.getProperty("capabilities.alarms.actionValues")) || ["DISPLAY"];
+
+ alarm.action = actionValues.includes("DISPLAY") ? "DISPLAY" : actionValues[0];
+ aItem.addAlarm(alarm);
+ }
+ },
+
+ /**
+ * Calculate the alarm date for a calIAlarm.
+ *
+ * @param aItem The item used to calculate the alarm date.
+ * @param aAlarm The alarm to calculate the date for.
+ * @returns The alarm date.
+ */
+ calculateAlarmDate(aItem, aAlarm) {
+ if (aAlarm.related == Ci.calIAlarm.ALARM_RELATED_ABSOLUTE) {
+ return aAlarm.alarmDate;
+ }
+ let returnDate;
+ if (aAlarm.related == Ci.calIAlarm.ALARM_RELATED_START) {
+ returnDate = aItem[lazy.cal.dtz.startDateProp(aItem)];
+ } else if (aAlarm.related == Ci.calIAlarm.ALARM_RELATED_END) {
+ returnDate = aItem[lazy.cal.dtz.endDateProp(aItem)];
+ }
+
+ if (returnDate && aAlarm.offset) {
+ // Handle all day events. This is kinda weird, because they don't
+ // have a well defined startTime. We just consider the start/end
+ // to be midnight in the user's timezone.
+ if (returnDate.isDate) {
+ let timezone = lazy.cal.dtz.defaultTimezone;
+ // This returns a copy, so no extra cloning needed.
+ returnDate = returnDate.getInTimezone(timezone);
+ returnDate.isDate = false;
+ } else if (returnDate.timezone.tzid == "floating") {
+ let timezone = lazy.cal.dtz.defaultTimezone;
+ returnDate = returnDate.getInTimezone(timezone);
+ } else {
+ // Clone the date to correctly add the duration.
+ returnDate = returnDate.clone();
+ }
+
+ returnDate.addDuration(aAlarm.offset);
+ return returnDate;
+ }
+
+ return null;
+ },
+
+ /**
+ * Removes previous children and adds reminder images to a given container,
+ * making sure only one icon per alarm action is added.
+ *
+ * @param {Element} container - The element to add the images to.
+ * @param {CalAlarm[]} reminderSet - The set of reminders to add images for.
+ */
+ addReminderImages(container, reminderSet) {
+ while (container.lastChild) {
+ container.lastChild.remove();
+ }
+
+ let document = container.ownerDocument;
+ let suppressed = container.hasAttribute("suppressed");
+ let actionSet = [];
+ for (let reminder of reminderSet) {
+ // Up to one icon per action
+ if (actionSet.includes(reminder.action)) {
+ continue;
+ }
+ actionSet.push(reminder.action);
+
+ let src;
+ let l10nId;
+ switch (reminder.action) {
+ case "DISPLAY":
+ if (suppressed) {
+ src = "chrome://messenger/skin/icons/new/bell-disabled.svg";
+ l10nId = "calendar-editable-item-reminder-icon-suppressed-alarm";
+ } else {
+ src = "chrome://messenger/skin/icons/new/bell.svg";
+ l10nId = "calendar-editable-item-reminder-icon-alarm";
+ }
+ break;
+ case "EMAIL":
+ src = "chrome://messenger/skin/icons/new/mail-sm.svg";
+ l10nId = "calendar-editable-item-reminder-icon-email";
+ break;
+ case "AUDIO":
+ src = "chrome://messenger/skin/icons/new/bell-ring.svg";
+ l10nId = "calendar-editable-item-reminder-icon-audio";
+ break;
+ default:
+ // Never create icons for actions we don't handle.
+ continue;
+ }
+
+ let image = document.createElement("img");
+ image.setAttribute("class", "reminder-icon");
+ image.setAttribute("value", reminder.action);
+ image.setAttribute("src", src);
+ // Set alt.
+ document.l10n.setAttributes(image, l10nId);
+ container.appendChild(image);
+ }
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calAuthUtils.jsm b/comm/calendar/base/modules/utils/calAuthUtils.jsm
new file mode 100644
index 0000000000..1f14d1c6cd
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calAuthUtils.jsm
@@ -0,0 +1,564 @@
+/* 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/. */
+
+/**
+ * Authentication tools and prompts, mostly for providers
+ */
+
+// NOTE: This module should not be loaded directly, it is available when including
+// calUtils.jsm under the cal.auth namespace.
+
+const EXPORTED_SYMBOLS = ["calauth"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ MsgAuthPrompt: "resource:///modules/MsgAsyncPrompter.jsm",
+});
+
+/**
+ * The userContextId of nsIHttpChannel is currently implemented as a uint32, so
+ * the ContainerMap defined below must not return Ids greater then the allowed
+ * range of a uint32.
+ */
+const MAX_CONTAINER_ID = Math.pow(2, 32) - 1;
+
+/**
+ * A map that handles userContextIds and usernames and provides unique Ids for
+ * different usernames.
+ */
+class ContainerMap extends Map {
+ /**
+ * Create a container map with a given range of userContextIds.
+ *
+ * @param {number} min - The lower range limit of userContextIds to be
+ * used.
+ * @param {number} max - The upper range limit of userContextIds to be
+ * used.
+ * @param {?object} iterable - Optional parameter which is passed to the
+ * constructor of Map. See definition of Map
+ * for more details.
+ */
+ constructor(min = 0, max = MAX_CONTAINER_ID, iterable) {
+ super(iterable);
+ this.order = [];
+ this.inverted = {};
+ this.min = min;
+ // The userConextId is a uint32, limit accordingly.
+ this.max = Math.max(max, MAX_CONTAINER_ID);
+ if (this.min > this.max) {
+ throw new RangeError(
+ "[ContainerMap] The provided min value " +
+ "(" +
+ this.min +
+ ") must not be greater than the provided " +
+ "max value (" +
+ this.max +
+ ")"
+ );
+ }
+ }
+
+ /**
+ * Check if the allowed userContextId range is fully used.
+ */
+ get full() {
+ return this.size > this.max - this.min;
+ }
+
+ /**
+ * Add a new username to the map.
+ *
+ * @param {string} username - The username to be added.
+ * @returns {number} The userContextId assigned to the given username.
+ */
+ _add(username) {
+ let nextUserContextId;
+ if (this.full) {
+ let oldestUsernameEntry = this.order.shift();
+ nextUserContextId = this.get(oldestUsernameEntry);
+ this.delete(oldestUsernameEntry);
+ } else {
+ nextUserContextId = this.min + this.size;
+ }
+
+ Services.clearData.deleteDataFromOriginAttributesPattern({ userContextId: nextUserContextId });
+ this.order.push(username);
+ this.set(username, nextUserContextId);
+ this.inverted[nextUserContextId] = username;
+ return nextUserContextId;
+ }
+
+ /**
+ * Look up the userContextId for the given username. Create a new one,
+ * if the username is not yet known.
+ *
+ * @param {string} username - The username for which the userContextId
+ * is to be looked up.
+ * @returns {number} The userContextId which is assigned to
+ * the provided username.
+ */
+ getUserContextIdForUsername(username) {
+ if (this.has(username)) {
+ return this.get(username);
+ }
+ return this._add(username);
+ }
+
+ /**
+ * Look up the username for the given userContextId. Return empty string
+ * if not found.
+ *
+ * @param {number} userContextId - The userContextId for which the
+ * username is to be to looked up.
+ * @returns {string} The username mapped to the given
+ * userContextId.
+ */
+ getUsernameForUserContextId(userContextId) {
+ if (this.inverted.hasOwnProperty(userContextId)) {
+ return this.inverted[userContextId];
+ }
+ return "";
+ }
+}
+
+var calauth = {
+ /**
+ * Calendar Auth prompt implementation. This instance of the auth prompt should
+ * be used by providers and other components that handle authentication using
+ * nsIAuthPrompt2 and friends.
+ *
+ * This implementation guarantees there are no request loops when an invalid
+ * password is stored in the login-manager.
+ *
+ * There is one instance of that object per calendar provider.
+ */
+ Prompt: class {
+ constructor() {
+ this.mWindow = lazy.cal.window.getCalendarWindow();
+ this.mReturnedLogins = {};
+ this.mProvider = null;
+ }
+
+ /**
+ * @typedef {object} PasswordInfo
+ * @property {boolean} found True, if the password was found
+ * @property {?string} username The found username
+ * @property {?string} password The found password
+ */
+
+ /**
+ * Retrieve password information from the login manager
+ *
+ * @param {string} aPasswordRealm - The realm to retrieve password info for
+ * @param {string} aRequestedUser - The username to look up.
+ * @returns {PasswordInfo} The retrieved password information
+ */
+ getPasswordInfo(aPasswordRealm, aRequestedUser) {
+ // Prefill aRequestedUser, so it will be used in the prompter.
+ let username = aRequestedUser;
+ let password;
+ let found = false;
+
+ let logins = Services.logins.findLogins(aPasswordRealm.prePath, null, aPasswordRealm.realm);
+ for (let login of logins) {
+ if (!aRequestedUser || aRequestedUser == login.username) {
+ username = login.username;
+ password = login.password;
+ found = true;
+ break;
+ }
+ }
+ if (found) {
+ let keyStr = aPasswordRealm.prePath + ":" + aPasswordRealm.realm + ":" + aRequestedUser;
+ let now = new Date();
+ // Remove the saved password if it was already returned less
+ // than 60 seconds ago. The reason for the timestamp check is that
+ // nsIHttpChannel can call the nsIAuthPrompt2 interface
+ // again in some situation. ie: When using Digest auth token
+ // expires.
+ if (
+ this.mReturnedLogins[keyStr] &&
+ now.getTime() - this.mReturnedLogins[keyStr].getTime() < 60000
+ ) {
+ lazy.cal.LOG(
+ "Credentials removed for: user=" +
+ username +
+ ", host=" +
+ aPasswordRealm.prePath +
+ ", realm=" +
+ aPasswordRealm.realm
+ );
+
+ delete this.mReturnedLogins[keyStr];
+ calauth.passwordManagerRemove(username, aPasswordRealm.prePath, aPasswordRealm.realm);
+ return { found: false, username };
+ }
+ this.mReturnedLogins[keyStr] = now;
+ }
+ return { found, username, password };
+ }
+
+ // boolean promptAuth(in nsIChannel aChannel,
+ // in uint32_t level,
+ // in nsIAuthInformation authInfo)
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ let hostRealm = {};
+ hostRealm.prePath = aChannel.URI.prePath;
+ hostRealm.realm = aAuthInfo.realm;
+ let port = aChannel.URI.port;
+ if (port == -1) {
+ let handler = Services.io
+ .getProtocolHandler(aChannel.URI.scheme)
+ .QueryInterface(Ci.nsIProtocolHandler);
+ port = handler.defaultPort;
+ }
+ hostRealm.passwordRealm = aChannel.URI.host + ":" + port + " (" + aAuthInfo.realm + ")";
+
+ let requestedUser = lazy.cal.auth.containerMap.getUsernameForUserContextId(
+ aChannel.loadInfo.originAttributes.userContextId
+ );
+ let pwInfo = this.getPasswordInfo(hostRealm, requestedUser);
+ aAuthInfo.username = pwInfo.username;
+ if (pwInfo && pwInfo.found) {
+ aAuthInfo.password = pwInfo.password;
+ return true;
+ }
+ let savePasswordLabel = null;
+ if (Services.prefs.getBoolPref("signon.rememberSignons", true)) {
+ savePasswordLabel = lazy.cal.l10n.getAnyString(
+ "passwordmgr",
+ "passwordmgr",
+ "rememberPassword"
+ );
+ }
+ let savePassword = {};
+ let returnValue = new lazy.MsgAuthPrompt().promptAuth(
+ aChannel,
+ aLevel,
+ aAuthInfo,
+ savePasswordLabel,
+ savePassword
+ );
+ if (savePassword.value) {
+ calauth.passwordManagerSave(
+ aAuthInfo.username,
+ aAuthInfo.password,
+ hostRealm.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return returnValue;
+ }
+
+ // nsICancelable asyncPromptAuth(in nsIChannel aChannel,
+ // in nsIAuthPromptCallback aCallback,
+ // in nsISupports aContext,
+ // in uint32_t level,
+ // in nsIAuthInformation authInfo);
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ let self = this;
+ let promptlistener = {
+ onPromptStartAsync(callback) {
+ callback.onAuthResult(this.onPromptStart());
+ },
+
+ onPromptStart() {
+ let res = self.promptAuth(aChannel, aLevel, aAuthInfo);
+ if (res) {
+ gAuthCache.setAuthInfo(hostKey, aAuthInfo);
+ this.onPromptAuthAvailable();
+ return true;
+ }
+
+ this.onPromptCanceled();
+ return false;
+ },
+
+ onPromptAuthAvailable() {
+ let authInfo = gAuthCache.retrieveAuthInfo(hostKey);
+ if (authInfo) {
+ aAuthInfo.username = authInfo.username;
+ aAuthInfo.password = authInfo.password;
+ }
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ },
+
+ onPromptCanceled() {
+ gAuthCache.retrieveAuthInfo(hostKey);
+ aCallback.onAuthCancelled(aContext, true);
+ },
+ };
+
+ let requestedUser = lazy.cal.auth.containerMap.getUsernameForUserContextId(
+ aChannel.loadInfo.originAttributes.userContextId
+ );
+ let hostKey = aChannel.URI.prePath + ":" + aAuthInfo.realm + ":" + requestedUser;
+ gAuthCache.planForAuthInfo(hostKey);
+
+ let queuePrompt = function () {
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(hostKey, false, promptlistener);
+ };
+
+ let finalSteps = function () {
+ // the prompt will fail if we are too early
+ if (self.mWindow.document.readyState == "complete") {
+ queuePrompt();
+ } else {
+ self.mWindow.addEventListener("load", queuePrompt, true);
+ }
+ };
+
+ let tryUntilReady = function () {
+ self.mWindow = lazy.cal.window.getCalendarWindow();
+ if (!self.mWindow) {
+ lazy.setTimeout(tryUntilReady, 1000);
+ return;
+ }
+
+ finalSteps();
+ };
+
+ // We might reach this code when cal.window.getCalendarWindow()
+ // returns null, which means the window obviously isn't yet
+ // in readyState complete, and we also cannot yet queue a prompt.
+ // It may happen if startup shows a blocking primary password
+ // prompt, which delays starting up the application windows.
+ // Use a timer to retry until we can access the calendar window.
+
+ tryUntilReady();
+ }
+ },
+
+ /**
+ * Tries to get the username/password combination of a specific calendar name from the password
+ * manager or asks the user.
+ *
+ * @param {string} aTitle - The dialog title.
+ * @param {string} aCalendarName - The calendar name or url to look up. Can be null.
+ * @param {{value: string}} aUsername The username that belongs to the calendar.
+ * @param {{value: string}} aPassword The password that belongs to the calendar.
+ * @param {{value: string}} aSavePassword Should the password be saved?
+ * @param {boolean} aFixedUsername - Whether the user name is fixed or editable
+ * @returns {boolean} Could a password be retrieved?
+ */
+ getCredentials(aTitle, aCalendarName, aUsername, aPassword, aSavePassword, aFixedUsername) {
+ if (
+ typeof aUsername != "object" ||
+ typeof aPassword != "object" ||
+ typeof aSavePassword != "object"
+ ) {
+ throw new Components.Exception("", Cr.NS_ERROR_XPC_NEED_OUT_OBJECT);
+ }
+
+ let prompter = new lazy.MsgAuthPrompt();
+
+ // Only show the save password box if we are supposed to.
+ let savepassword = null;
+ if (Services.prefs.getBoolPref("signon.rememberSignons", true)) {
+ savepassword = lazy.cal.l10n.getAnyString("passwordmgr", "passwordmgr", "rememberPassword");
+ }
+
+ let aText;
+ if (aFixedUsername) {
+ aText = lazy.cal.l10n.getAnyString("global", "commonDialogs", "EnterPasswordFor", [
+ aUsername.value,
+ aCalendarName,
+ ]);
+ return prompter.promptPassword(aTitle, aText, aPassword, savepassword, aSavePassword);
+ }
+ aText = lazy.cal.l10n.getAnyString("global", "commonDialogs", "EnterUserPasswordFor2", [
+ aCalendarName,
+ ]);
+ return prompter.promptUsernameAndPassword(
+ aTitle,
+ aText,
+ aUsername,
+ aPassword,
+ savepassword,
+ aSavePassword
+ );
+ },
+
+ /**
+ * Make sure the passed origin is actually an uri string, because password manager functions
+ * require it. This is a fallback for compatibility only and should be removed a few versions
+ * after Lightning 6.2
+ *
+ * @param {string} aOrigin - The hostname or origin to check
+ * @returns {string} The origin uri
+ */
+ _ensureOrigin(aOrigin) {
+ try {
+ let { prePath, spec } = Services.io.newURI(aOrigin);
+ if (prePath == "oauth:") {
+ return spec;
+ }
+ return prePath;
+ } catch (e) {
+ return "https://" + aOrigin;
+ }
+ },
+
+ /**
+ * Helper to insert/update an entry to the password manager.
+ *
+ * @param {string} aUsername - The username to insert
+ * @param {string} aPassword - The corresponding password
+ * @param {string} aOrigin - The corresponding origin
+ * @param {string} aRealm - The password realm (unused on branch)
+ */
+ passwordManagerSave(aUsername, aPassword, aOrigin, aRealm) {
+ lazy.cal.ASSERT(aUsername);
+ lazy.cal.ASSERT(aPassword);
+
+ let origin = this._ensureOrigin(aOrigin);
+
+ if (!Services.logins.getLoginSavingEnabled(origin)) {
+ throw new Components.Exception(
+ "Password saving is disabled for " + origin,
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+
+ try {
+ let logins = Services.logins.findLogins(origin, null, aRealm);
+
+ let newLoginInfo = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+ newLoginInfo.init(origin, null, aRealm, aUsername, aPassword, "", "");
+ for (let login of logins) {
+ if (aUsername == login.username) {
+ Services.logins.modifyLogin(login, newLoginInfo);
+ return;
+ }
+ }
+ Services.logins.addLogin(newLoginInfo);
+ } catch (exc) {
+ // Only show the message if its not an abort, which can happen if
+ // the user canceled the primary password dialog
+ lazy.cal.ASSERT(exc.result == Cr.NS_ERROR_ABORT, exc);
+ }
+ },
+
+ /**
+ * Helper to retrieve an entry from the password manager.
+ *
+ * @param {string} aUsername - The username to search
+ * @param {string} aPassword - The corresponding password
+ * @param {string} aOrigin - The corresponding origin
+ * @param {string} aRealm - The password realm (unused on branch)
+ * @returns {boolean} True, if an entry exists in the password manager
+ */
+ passwordManagerGet(aUsername, aPassword, aOrigin, aRealm) {
+ lazy.cal.ASSERT(aUsername);
+
+ if (typeof aPassword != "object") {
+ throw new Components.Exception("", Cr.NS_ERROR_XPC_NEED_OUT_OBJECT);
+ }
+
+ let origin = this._ensureOrigin(aOrigin);
+
+ try {
+ let logins = Services.logins.findLogins(origin, null, "");
+ for (let loginInfo of logins) {
+ if (
+ loginInfo.username == aUsername &&
+ (loginInfo.httpRealm == aRealm || loginInfo.httpRealm.split(" ").includes(aRealm))
+ ) {
+ aPassword.value = loginInfo.password;
+ return true;
+ }
+ }
+ } catch (exc) {
+ lazy.cal.ASSERT(false, exc);
+ }
+ return false;
+ },
+
+ /**
+ * Helper to remove an entry from the password manager
+ *
+ * @param {string} aUsername - The username to remove
+ * @param {string} aOrigin - The corresponding origin
+ * @param {string} aRealm - The password realm (unused on branch)
+ * @returns {boolean} Could the user be removed?
+ */
+ passwordManagerRemove(aUsername, aOrigin, aRealm) {
+ lazy.cal.ASSERT(aUsername);
+
+ let origin = this._ensureOrigin(aOrigin);
+
+ try {
+ let logins = Services.logins.findLogins(origin, null, aRealm);
+ for (let loginInfo of logins) {
+ if (loginInfo.username == aUsername) {
+ Services.logins.removeLogin(loginInfo);
+ return true;
+ }
+ }
+ } catch (exc) {
+ // If no logins are found, fall through to the return statement below.
+ }
+ return false;
+ },
+
+ /**
+ * A map which maps usernames to userContextIds, reserving a range
+ * of 20000 - 29999 for userContextIds to be used within calendar.
+ *
+ * @param {number} min - The lower range limit of userContextIds to be
+ * used.
+ * @param {number} max - The upper range limit of userContextIds to be
+ * used.
+ */
+ containerMap: new ContainerMap(20000, 29999),
+};
+
+// Cache for authentication information since onAuthInformation in the prompt
+// listener is called without further information. If the password is not
+// saved, there is no way to retrieve it. We use ref counting to avoid keeping
+// the password in memory longer than needed.
+var gAuthCache = {
+ _authInfoCache: new Map(),
+ planForAuthInfo(hostKey) {
+ let authInfo = this._authInfoCache.get(hostKey);
+ if (authInfo) {
+ authInfo.refCnt++;
+ } else {
+ this._authInfoCache.set(hostKey, { refCnt: 1 });
+ }
+ },
+
+ setAuthInfo(hostKey, aAuthInfo) {
+ let authInfo = this._authInfoCache.get(hostKey);
+ if (authInfo) {
+ authInfo.username = aAuthInfo.username;
+ authInfo.password = aAuthInfo.password;
+ }
+ },
+
+ retrieveAuthInfo(hostKey) {
+ let authInfo = this._authInfoCache.get(hostKey);
+ if (authInfo) {
+ authInfo.refCnt--;
+
+ if (authInfo.refCnt == 0) {
+ this._authInfoCache.delete(hostKey);
+ }
+ }
+ return authInfo;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calCategoryUtils.jsm b/comm/calendar/base/modules/utils/calCategoryUtils.jsm
new file mode 100644
index 0000000000..c06708b61b
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calCategoryUtils.jsm
@@ -0,0 +1,103 @@
+/* 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/. */
+
+/**
+ * Helpers for reading and writing calendar categories
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.category namespace.
+
+const EXPORTED_SYMBOLS = ["calcategory"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calcategory = {
+ /**
+ * Sets up the default categories from the localized string
+ *
+ * @returns The default set of categories as a comma separated string.
+ */
+ setupDefaultCategories() {
+ let defaultBranch = Services.prefs.getDefaultBranch("");
+
+ // First, set up the category names
+ let categories = lazy.cal.l10n.getString("categories", "categories2");
+ defaultBranch.setStringPref("calendar.categories.names", categories);
+
+ // Now, initialize the category default colors
+ let categoryArray = calcategory.stringToArray(categories);
+ for (let category of categoryArray) {
+ let prefName = lazy.cal.view.formatStringForCSSRule(category);
+ defaultBranch.setStringPref(
+ "calendar.category.color." + prefName,
+ lazy.cal.view.hashColor(category)
+ );
+ }
+
+ // Return the list of categories for further processing
+ return categories;
+ },
+
+ /**
+ * Get array of category names from preferences or locale default,
+ * unescaping any commas in each category name.
+ *
+ * @returns array of category names
+ */
+ fromPrefs() {
+ let categories = Services.prefs.getStringPref("calendar.categories.names", null);
+
+ // If no categories are configured load a default set from properties file
+ if (!categories) {
+ categories = calcategory.setupDefaultCategories();
+ }
+ return calcategory.stringToArray(categories);
+ },
+
+ /**
+ * Convert categories string to list of category names.
+ *
+ * Stored categories may include escaped commas within a name. Split
+ * categories string at commas, but not at escaped commas (\,). Afterward,
+ * replace escaped commas (\,) with commas (,) in each name.
+ *
+ * @param aCategoriesPrefValue string from "calendar.categories.names" pref,
+ * which may contain escaped commas (\,) in names.
+ * @returns list of category names
+ */
+ stringToArray(aCategories) {
+ if (!aCategories) {
+ return [];
+ }
+ /* eslint-disable no-control-regex */
+ // \u001A is the unicode "SUBSTITUTE" character
+ let categories = aCategories
+ .replace(/\\,/g, "\u001A")
+ .split(",")
+ .map(name => name.replace(/\u001A/g, ","));
+ /* eslint-enable no-control-regex */
+ if (categories.length == 1 && categories[0] == "") {
+ // Split will return an array with an empty element when splitting an
+ // empty string, correct this.
+ categories.pop();
+ }
+ return categories;
+ },
+
+ /**
+ * Convert array of category names to string.
+ *
+ * Category names may contain commas (,). Escape commas (\,) in each, then
+ * join them in comma separated string for storage.
+ *
+ * @param aSortedCategoriesArray sorted array of category names, may
+ * contain unescaped commas, which will
+ * be escaped in combined string.
+ */
+ arrayToString(aSortedCategoriesArray) {
+ return aSortedCategoriesArray.map(cat => cat.replace(/,/g, "\\,")).join(",");
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calDataUtils.jsm b/comm/calendar/base/modules/utils/calDataUtils.jsm
new file mode 100644
index 0000000000..be37a876d3
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calDataUtils.jsm
@@ -0,0 +1,313 @@
+/* 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/. */
+
+/**
+ * Data structures and algorithms used within the codebase
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.data namespace.
+
+const EXPORTED_SYMBOLS = ["caldata"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+class ListenerSet extends Set {
+ constructor(iid, iterable) {
+ super(iterable);
+ this.mIID = iid;
+ }
+
+ add(item) {
+ super.add(item.QueryInterface(this.mIID));
+ }
+
+ has(item) {
+ return super.has(item.QueryInterface(this.mIID));
+ }
+
+ delete(item) {
+ super.delete(item.QueryInterface(this.mIID));
+ }
+
+ notify(func, args = []) {
+ let currentObservers = [...this.values()];
+ for (let observer of currentObservers) {
+ try {
+ observer[func](...args);
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ }
+}
+
+class ObserverSet extends ListenerSet {
+ constructor(iid, iterable) {
+ super(iid, iterable);
+ this.mCalendarsInBatch = new Set();
+ }
+
+ get batchCount() {
+ return this.mCalendarsInBatch.size;
+ }
+
+ notify(func, args = []) {
+ switch (func) {
+ case "onStartBatch":
+ this.mCalendarsInBatch.add(args[0]);
+ break;
+ case "onEndBatch":
+ this.mCalendarsInBatch.delete(args[0]);
+ break;
+ }
+ return super.notify(func, args);
+ }
+
+ add(item) {
+ if (!this.has(item)) {
+ // Replay batch notifications, because the onEndBatch notifications are yet to come.
+ // We may think about doing the reverse on remove, though I currently see no need:
+ for (let calendar of this.mCalendarsInBatch) {
+ item.onStartBatch(calendar);
+ }
+ }
+ super.add(item);
+ }
+}
+
+/**
+ * This object implements calIOperation and could group multiple sub
+ * operations into one. You can pass a cancel function which is called once
+ * the operation group is cancelled.
+ * Users must call notifyCompleted() once all sub operations have been
+ * successful, else the operation group will stay pending.
+ * The reason for the latter is that providers currently should (but need
+ * not) implement (and return) calIOperation handles, thus there may be pending
+ * calendar operations (without handle).
+ */
+class OperationGroup {
+ static nextGroupId() {
+ if (typeof OperationGroup.mOpGroupId == "undefined") {
+ OperationGroup.mOpGroupId = 0;
+ }
+
+ return OperationGroup.mOpGroupId++;
+ }
+
+ constructor(aCancelFunc) {
+ this.mId = lazy.cal.getUUID() + "-" + OperationGroup.nextGroupId();
+ this.mIsPending = true;
+
+ this.mCancelFunc = aCancelFunc;
+ this.mSubOperations = [];
+ this.mStatus = Cr.NS_OK;
+ }
+
+ get id() {
+ return this.mId;
+ }
+ get isPending() {
+ return this.mIsPending;
+ }
+ get status() {
+ return this.mStatus;
+ }
+ get isEmpty() {
+ return this.mSubOperations.length == 0;
+ }
+
+ add(aOperation) {
+ if (aOperation && aOperation.isPending) {
+ this.mSubOperations.push(aOperation);
+ }
+ }
+
+ remove(aOperation) {
+ if (aOperation) {
+ this.mSubOperations = this.mSubOperations.filter(operation => aOperation.id != operation.id);
+ }
+ }
+
+ notifyCompleted(aStatus) {
+ lazy.cal.ASSERT(this.isPending, "[OperationGroup_notifyCompleted] this.isPending");
+ if (this.isPending) {
+ this.mIsPending = false;
+ if (aStatus) {
+ this.mStatus = aStatus;
+ }
+ }
+ }
+
+ cancel(aStatus = Ci.calIErrors.OPERATION_CANCELLED) {
+ if (this.isPending) {
+ this.notifyCompleted(aStatus);
+ let cancelFunc = this.mCancelFunc;
+ if (cancelFunc) {
+ this.mCancelFunc = null;
+ cancelFunc();
+ }
+ let subOperations = this.mSubOperations;
+ this.mSubOperations = [];
+ for (let operation of subOperations) {
+ operation.cancel(Ci.calIErrors.OPERATION_CANCELLED);
+ }
+ }
+ }
+
+ toString() {
+ return `[OperationGroup id=${this.id}]`;
+ }
+}
+
+var caldata = {
+ ListenerSet,
+ ObserverSet,
+ OperationGroup,
+
+ /**
+ * Use the binary search algorithm to search for an item in an array.
+ * function.
+ *
+ * The comptor function may look as follows for calIDateTime objects.
+ * function comptor(a, b) {
+ * return a.compare(b);
+ * }
+ * If no comptor is specified, the default greater-than comptor will be used.
+ *
+ * @param itemArray The array to search.
+ * @param newItem The item to search in the array.
+ * @param comptor A comparison function that can compare two items.
+ * @returns The index of the new item.
+ */
+ binarySearch(itemArray, newItem, comptor) {
+ function binarySearchInternal(low, high) {
+ // Are we done yet?
+ if (low == high) {
+ return low + (comptor(newItem, itemArray[low]) < 0 ? 0 : 1);
+ }
+
+ let mid = Math.floor(low + (high - low) / 2);
+ let cmp = comptor(newItem, itemArray[mid]);
+ if (cmp > 0) {
+ return binarySearchInternal(mid + 1, high);
+ } else if (cmp < 0) {
+ return binarySearchInternal(low, mid);
+ }
+ return mid;
+ }
+
+ if (itemArray.length < 1) {
+ return -1;
+ }
+ if (!comptor) {
+ comptor = function (a, b) {
+ return (a > b) - (a < b);
+ };
+ }
+ return binarySearchInternal(0, itemArray.length - 1);
+ },
+
+ /**
+ * Insert a new node underneath the given parentNode, using binary search. See binarySearch
+ * for a note on how the comptor works.
+ *
+ * @param parentNode The parent node underneath the new node should be inserted.
+ * @param inserNode The node to insert
+ * @param aItem The calendar item to add a widget for.
+ * @param comptor A comparison function that can compare two items (not DOM Nodes!)
+ * @param discardDuplicates Use the comptor function to check if the item in
+ * question is already in the array. If so, the
+ * new item is not inserted.
+ * @param itemAccessor [optional] A function that receives a DOM node and returns the associated item
+ * If null, this function will be used: function(n) n.item
+ */
+ binaryInsertNode(parentNode, insertNode, aItem, comptor, discardDuplicates, itemAccessor) {
+ let accessor = itemAccessor || caldata.binaryInsertNodeDefaultAccessor;
+
+ // Get the index of the node before which the inserNode will be inserted
+ let newIndex = caldata.binarySearch(Array.from(parentNode.children, accessor), aItem, comptor);
+
+ if (newIndex < 0) {
+ parentNode.appendChild(insertNode);
+ newIndex = 0;
+ } else if (
+ !discardDuplicates ||
+ comptor(
+ accessor(parentNode.children[Math.min(newIndex, parentNode.children.length - 1)]),
+ aItem
+ ) >= 0
+ ) {
+ // Only add the node if duplicates should not be discarded, or if
+ // they should and the childNode[newIndex] == node.
+ let node = parentNode.children[newIndex];
+ parentNode.insertBefore(insertNode, node);
+ }
+ return newIndex;
+ },
+ binaryInsertNodeDefaultAccessor: n => n.item,
+
+ /**
+ * Insert an item into the given array, using binary search. See binarySearch
+ * for a note on how the comptor works.
+ *
+ * @param itemArray The array to insert into.
+ * @param item The item to insert into the array.
+ * @param comptor A comparison function that can compare two items.
+ * @param discardDuplicates Use the comptor function to check if the item in
+ * question is already in the array. If so, the
+ * new item is not inserted.
+ * @returns The index of the new item.
+ */
+ binaryInsert(itemArray, item, comptor, discardDuplicates) {
+ let newIndex = caldata.binarySearch(itemArray, item, comptor);
+
+ if (newIndex < 0) {
+ itemArray.push(item);
+ newIndex = 0;
+ } else if (
+ !discardDuplicates ||
+ comptor(itemArray[Math.min(newIndex, itemArray.length - 1)], item) != 0
+ ) {
+ // Only add the item if duplicates should not be discarded, or if
+ // they should and itemArray[newIndex] != item.
+ itemArray.splice(newIndex, 0, item);
+ }
+ return newIndex;
+ },
+
+ /**
+ * Generic object comparer
+ * Use to compare two objects which are not of type calIItemBase, in order
+ * to avoid the js-wrapping issues mentioned above.
+ *
+ * @param aObject first object to be compared
+ * @param aOtherObject second object to be compared
+ * @param aIID IID to use in comparison, undefined/null defaults to nsISupports
+ */
+ compareObjects(aObject, aOtherObject, aIID) {
+ // xxx todo: seems to work fine, but I still mistrust this trickery...
+ // Anybody knows an official API that could be used for this purpose?
+ // For what reason do clients need to pass aIID since
+ // every XPCOM object has to implement nsISupports?
+ // XPCOM (like COM, like UNO, ...) defines that QueryInterface *only* needs to return
+ // the very same pointer for nsISupports during its lifetime.
+ if (!aIID) {
+ aIID = Ci.nsISupports;
+ }
+ let sip1 = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance(
+ Ci.nsISupportsInterfacePointer
+ );
+ sip1.data = aObject;
+ sip1.dataIID = aIID;
+
+ let sip2 = Cc["@mozilla.org/supports-interface-pointer;1"].createInstance(
+ Ci.nsISupportsInterfacePointer
+ );
+ sip2.data = aOtherObject;
+ sip2.dataIID = aIID;
+ return sip1.data == sip2.data;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm b/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm
new file mode 100644
index 0000000000..42df519e22
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calDateTimeFormatter.jsm
@@ -0,0 +1,620 @@
+/* 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(lazy, "gDateStringBundle", () =>
+ Services.strings.createBundle("chrome://calendar/locale/dateFormat.properties")
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(lazy, "dateFormat", "calendar.date.format", 0);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "timeBeforeDate",
+ "calendar.date.formatTimeBeforeDate",
+ false
+);
+
+/** Cache of calls to new Services.intl.DateTimeFormat. */
+var formatCache = new Map();
+
+/*
+ * Date time formatting functions for display.
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.dtz.formatter namespace.
+
+const EXPORTED_SYMBOLS = ["formatter"];
+
+var formatter = {
+ /**
+ * Format a date in either short or long format, depending on the users preference.
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDate(aDate) {
+ // Format the date using user's format preference (long or short)
+ return lazy.dateFormat == 0 ? this.formatDateLong(aDate) : this.formatDateShort(aDate);
+ },
+
+ /**
+ * Format a date into a short format, for example "12/17/2005".
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateShort(aDate) {
+ return formatDateTimeWithOptions(aDate, { dateStyle: "short" });
+ },
+
+ /**
+ * Format a date into a long format, for example "Sat Dec 17 2005".
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateLong(aDate) {
+ return formatDateTimeWithOptions(aDate, { dateStyle: "full" });
+ },
+
+ /**
+ * Format a date into a short format without mentioning the year, for example "Dec 17"
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateWithoutYear(aDate) {
+ return formatDateTimeWithOptions(aDate, { month: "short", day: "numeric" });
+ },
+
+ /**
+ * Format a date into a long format without mentioning the year, for example
+ * "Monday, December 17".
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the date part of the datetime.
+ */
+ formatDateLongWithoutYear(aDate) {
+ return formatDateTimeWithOptions(aDate, { weekday: "long", month: "long", day: "numeric" });
+ },
+
+ /**
+ * Format the time portion of a date-time object. Note: only the hour and
+ * minutes are shown.
+ *
+ * @param {calIDateTime} time - The date-time to format the time of.
+ * @param {boolean} [preferEndOfDay = false] - Whether to prefer showing a
+ * midnight time as the end of a day, rather than the start of the day, if
+ * the time formatting allows for it. I.e. if the formatter would use a
+ * 24-hour format, then this would show midnight as 24:00, rather than
+ * 00:00.
+ *
+ * @returns {string} A string representing the time.
+ */
+ formatTime(time, preferEndOfDay = false) {
+ if (time.isDate) {
+ return lazy.gDateStringBundle.GetStringFromName("AllDay");
+ }
+
+ const options = { timeStyle: "short" };
+ if (preferEndOfDay && time.hour == 0 && time.minute == 0) {
+ // Midnight. Note that the timeStyle is short, so we don't test for
+ // seconds.
+ // Test what hourCycle the default formatter would use.
+ if (getFormatter(options).resolvedOptions().hourCycle == "h23") {
+ // Midnight start-of-day is 00:00, so we can show midnight end-of-day
+ // as 24:00.
+ options.hourCycle = "h24";
+ }
+ // NOTE: Regarding the other hourCycle values:
+ // + "h24": This is not expected in any locale.
+ // + "h12": In a 12-hour format that cycles 12 -> 1 -> ... -> 11, there is
+ // no convention to distinguish between midnight start-of-day and
+ // midnight end-of-day. So we do nothing.
+ // + "h11": The ja-JP locale with a 12-hour format returns this. In this
+ // locale, midnight start-of-day is shown as "午前0:00" (i.e. 0 AM),
+ // which means midnight end-of-day can be shown as "午後12:00" (12 PM).
+ // However, Intl.DateTimeFormatter does not expose a means to do this.
+ // Just forcing a h12 hourCycle will show midnight as "午前12:00", which
+ // would be incorrect in this locale. Therefore, we similarly do nothing
+ // in this case as well.
+ }
+
+ return formatDateTimeWithOptions(time, options);
+ },
+
+ /**
+ * Format a datetime into the format specified by the OS settings. Will omit the seconds from the
+ * output.
+ *
+ * @param {calIDateTime} aDate - The datetime to format.
+ * @returns {string} A string representing the datetime.
+ */
+ formatDateTime(aDate) {
+ let formattedDate = this.formatDate(aDate);
+ let formattedTime = this.formatTime(aDate);
+
+ if (lazy.timeBeforeDate) {
+ return formattedTime + " " + formattedDate;
+ }
+ return formattedDate + " " + formattedTime;
+ },
+
+ /**
+ * Format a time interval like formatInterval, but show only the time.
+ *
+ * @param {calIDateTime} aStartDate - The start of the interval.
+ * @param {calIDateTime} aEndDate - The end of the interval.
+ * @returns {string} The formatted time interval.
+ */
+ formatTimeInterval(aStartDate, aEndDate) {
+ if (!aStartDate && aEndDate) {
+ return this.formatTime(aEndDate);
+ }
+ if (!aEndDate && aStartDate) {
+ return this.formatTime(aStartDate);
+ }
+ if (!aStartDate && !aEndDate) {
+ return "";
+ }
+
+ // TODO do we need l10n for this?
+ // TODO should we check for the same day? The caller should know what
+ // he is doing...
+ return this.formatTime(aStartDate) + "\u2013" + this.formatTime(aEndDate);
+ },
+
+ /**
+ * Format a date/time interval to a string. The returned string may assume
+ * that the dates are so close to each other, that it can leave out some parts
+ * of the part string denoting the end date.
+ *
+ * @param {calIDateTime} startDate - The start of the interval.
+ * @param {calIDateTime} endDate - The end of the interval.
+ * @returns {string} - A string describing the interval in a legible form.
+ */
+ formatInterval(startDate, endDate) {
+ let format = this.formatIntervalParts(startDate, endDate);
+ switch (format.type) {
+ case "task-without-dates":
+ return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutDate");
+
+ case "task-without-due-date":
+ return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutDueDate", [
+ format.startDate,
+ format.startTime,
+ ]);
+
+ case "task-without-start-date":
+ return lazy.cal.l10n.getCalString("datetimeIntervalTaskWithoutStartDate", [
+ format.endDate,
+ format.endTime,
+ ]);
+
+ case "all-day":
+ return format.startDate;
+
+ case "all-day-between-years":
+ return lazy.cal.l10n.getCalString("daysIntervalBetweenYears", [
+ format.startMonth,
+ format.startDay,
+ format.startYear,
+ format.endMonth,
+ format.endDay,
+ format.endYear,
+ ]);
+
+ case "all-day-in-month":
+ return lazy.cal.l10n.getCalString("daysIntervalInMonth", [
+ format.month,
+ format.startDay,
+ format.endDay,
+ format.year,
+ ]);
+
+ case "all-day-between-months":
+ return lazy.cal.l10n.getCalString("daysIntervalBetweenMonths", [
+ format.startMonth,
+ format.startDay,
+ format.endMonth,
+ format.endDay,
+ format.year,
+ ]);
+
+ case "same-date-time":
+ return lazy.cal.l10n.getCalString("datetimeIntervalOnSameDateTime", [
+ format.startDate,
+ format.startTime,
+ ]);
+
+ case "same-day":
+ return lazy.cal.l10n.getCalString("datetimeIntervalOnSameDay", [
+ format.startDate,
+ format.startTime,
+ format.endTime,
+ ]);
+
+ case "several-days":
+ return lazy.cal.l10n.getCalString("datetimeIntervalOnSeveralDays", [
+ format.startDate,
+ format.startTime,
+ format.endDate,
+ format.endTime,
+ ]);
+ default:
+ return "";
+ }
+ },
+
+ /**
+ * Object used to describe the parts of a formatted interval.
+ *
+ * @typedef {object} IntervalParts
+ * @property {string} type
+ * Used to distinguish IntervalPart results.
+ * @property {string?} startDate
+ * The full date of the start of the interval.
+ * @property {string?} startTime
+ * The time part of the start of the interval.
+ * @property {string?} startDay
+ * The day (of the month) the interval starts on.
+ * @property {string?} startMonth
+ * The month the interval starts on.
+ * @property {string?} startYear
+ * The year interval starts on.
+ * @property {string?} endDate
+ * The full date of the end of the interval.
+ * @property {string?} endTime
+ * The time part of the end of the interval.
+ * @property {string?} endDay
+ * The day (of the month) the interval ends on.
+ * @property {string?} endMonth
+ * The month the interval ends on.
+ * @property {string?} endYear
+ * The year interval ends on.
+ * @property {string?} month
+ * The month the interval occurs in when the start is all day and the
+ * interval does not span multiple months.
+ * @property {string?} year
+ * The year the interval occurs in when the the start is all day and the
+ * interval does not span multiple years.
+ */
+
+ /**
+ * Format a date interval into various parts suitable for building
+ * strings that describe the interval. This result may leave out some parts of
+ * either date based on the closeness of the two.
+ *
+ * @param {calIDateTime} startDate - The start of the interval.
+ * @param {calIDateTime} endDate - The end of the interval.
+ * @returns {IntervalParts} An object to be used to create an
+ * interval string.
+ */
+ formatIntervalParts(startDate, endDate) {
+ if (endDate == null && startDate == null) {
+ return { type: "task-without-dates" };
+ }
+
+ if (endDate == null) {
+ return {
+ type: "task-without-due-date",
+ startDate: this.formatDate(startDate),
+ startTime: this.formatTime(startDate),
+ };
+ }
+
+ if (startDate == null) {
+ return {
+ type: "task-without-start-date",
+ endDate: this.formatDate(endDate),
+ endTime: this.formatTime(endDate),
+ };
+ }
+
+ // Here there are only events or tasks with both start and due date.
+ // make sure start and end use the same timezone when formatting intervals:
+ let testdate = startDate.clone();
+ testdate.isDate = true;
+ let originalEndDate = endDate.clone();
+ endDate = endDate.getInTimezone(startDate.timezone);
+ let sameDay = testdate.compare(endDate) == 0;
+ if (startDate.isDate) {
+ // All-day interval, so we should leave out the time part
+ if (sameDay) {
+ return {
+ type: "all-day",
+ startDate: this.formatDateLong(startDate),
+ };
+ }
+
+ let startDay = this.formatDayWithOrdinal(startDate.day);
+ let startYear = String(startDate.year);
+ let endDay = this.formatDayWithOrdinal(endDate.day);
+ let endYear = String(endDate.year);
+ if (startDate.year != endDate.year) {
+ return {
+ type: "all-day-between-years",
+ startDay,
+ startMonth: lazy.cal.l10n.formatMonth(
+ startDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenYears"
+ ),
+ startYear,
+ endDay,
+ endMonth: lazy.cal.l10n.formatMonth(
+ originalEndDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenYears"
+ ),
+ endYear,
+ };
+ }
+
+ if (startDate.month == endDate.month) {
+ return {
+ type: "all-day-in-month",
+ startDay,
+ month: lazy.cal.l10n.formatMonth(startDate.month + 1, "calendar", "daysIntervalInMonth"),
+ endDay,
+ year: endYear,
+ };
+ }
+
+ return {
+ type: "all-day-between-months",
+ startDay,
+ startMonth: lazy.cal.l10n.formatMonth(
+ startDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenMonths"
+ ),
+ endDay,
+ endMonth: lazy.cal.l10n.formatMonth(
+ originalEndDate.month + 1,
+ "calendar",
+ "daysIntervalBetweenMonths"
+ ),
+ year: endYear,
+ };
+ }
+
+ let startDateString = this.formatDate(startDate);
+ let startTime = this.formatTime(startDate);
+ let endDateString = this.formatDate(endDate);
+ let endTime = this.formatTime(endDate);
+ // non-allday, so need to return date and time
+ if (sameDay) {
+ // End is on the same day as start, so we can leave out the end date
+ if (startTime == endTime) {
+ // End time is on the same time as start, so we can leave out the end time
+ // "5 Jan 2006 13:00"
+ return {
+ type: "same-date-time",
+ startDate: startDateString,
+ startTime,
+ };
+ }
+ // still include end time
+ // "5 Jan 2006 13:00 - 17:00"
+ return {
+ type: "same-day",
+ startDate: startDateString,
+ startTime,
+ endTime,
+ };
+ }
+
+ // Spanning multiple days, so need to include date and time
+ // for start and end
+ // "5 Jan 2006 13:00 - 7 Jan 2006 9:00"
+ return {
+ type: "several-days",
+ startDate: startDateString,
+ startTime,
+ endDate: endDateString,
+ endTime,
+ };
+ },
+
+ /**
+ * Get the monthday followed by its ordinal symbol in the current locale.
+ * e.g. monthday 1 -> 1st
+ * monthday 2 -> 2nd etc.
+ *
+ * @param {number} aDay - A number from 1 to 31.
+ * @returns {string} The monthday number in ordinal format in the current locale.
+ */
+ formatDayWithOrdinal(aDay) {
+ let ordinalSymbols = lazy.gDateStringBundle.GetStringFromName("dayOrdinalSymbol").split(",");
+ let dayOrdinalSymbol = ordinalSymbols[aDay - 1] || ordinalSymbols[0];
+ return aDay + dayOrdinalSymbol;
+ },
+
+ /**
+ * Helper to get the start/end dates for a given item.
+ *
+ * @param {calIItemBase} item - The item to get the dates for.
+ * @returns {[calIDateTime, calIDateTime]} An array with start and end date.
+ */
+ getItemDates(item) {
+ let start = item[lazy.cal.dtz.startDateProp(item)];
+ let end = item[lazy.cal.dtz.endDateProp(item)];
+ let kDefaultTimezone = lazy.cal.dtz.defaultTimezone;
+ // Check for tasks without start and/or due date
+ if (start) {
+ start = start.getInTimezone(kDefaultTimezone);
+ }
+ if (end) {
+ end = end.getInTimezone(kDefaultTimezone);
+ }
+ // EndDate is exclusive. For all-day events, we need to subtract one day,
+ // to get into a format that's understandable.
+ if (start && start.isDate && end) {
+ end.day -= 1;
+ }
+
+ return [start, end];
+ },
+
+ /**
+ * Format an interval that is defined by an item with the default timezone.
+ *
+ * @param {calIItemBase} aItem - The item describing the interval.
+ * @returns {string} The formatted item interval.
+ */
+ formatItemInterval(aItem) {
+ return this.formatInterval(...this.getItemDates(aItem));
+ },
+
+ /**
+ * Format a time interval like formatItemInterval, but only show times.
+ *
+ * @param {calIItemBase} aItem - The item describing the interval.
+ * @returns {string} The formatted item interval.
+ */
+ formatItemTimeInterval(aItem) {
+ return this.formatTimeInterval(...this.getItemDates(aItem));
+ },
+
+ /**
+ * Get the month name.
+ *
+ * @param {number} aMonthIndex - Zero-based month number (0 is january, 11 is december).
+ * @returns {string} The month name in the current locale.
+ */
+ monthName(aMonthIndex) {
+ let oneBasedMonthIndex = aMonthIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".name");
+ },
+
+ /**
+ * Get the abbreviation of the month name.
+ *
+ * @param {number} aMonthIndex - Zero-based month number (0 is january, 11 is december).
+ * @returns {string} The abbreviated month name in the current locale.
+ */
+ shortMonthName(aMonthIndex) {
+ let oneBasedMonthIndex = aMonthIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".Mmm");
+ },
+
+ /**
+ * Get the day name.
+ *
+ * @param {number} aMonthIndex - Zero-based day number (0 is sunday, 6 is saturday).
+ * @returns {string} The day name in the current locale.
+ */
+ dayName(aDayIndex) {
+ let oneBasedDayIndex = aDayIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".name");
+ },
+
+ /**
+ * Get the abbreviation of the day name.
+ *
+ * @param {number} aMonthIndex - Zero-based day number (0 is sunday, 6 is saturday).
+ * @returns {string} The abbrevidated day name in the current locale.
+ */
+ shortDayName(aDayIndex) {
+ let oneBasedDayIndex = aDayIndex + 1;
+ return lazy.gDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".Mmm");
+ },
+};
+
+/**
+ * Determine whether a datetime is specified relative to the user, i.e. a date
+ * or floating datetime, both of which should be displayed the same regardless
+ * of the user's time zone.
+ *
+ * @param {calIDateTime} dateTime The datetime object to check.
+ * @returns {boolean}
+ */
+function isDateTimeRelativeToUser(dateTime) {
+ return dateTime.isDate || dateTime.timezone.isFloating;
+}
+
+/**
+ * Format a datetime object as a string with a given set of formatting options.
+ *
+ * @param {calIDateTime} dateTime The datetime object to be formatted.
+ * @param {object} options
+ * The set of Intl.DateTimeFormat options to use for formatting.
+ * @returns {string} A formatted string representing the given datetime.
+ */
+function formatDateTimeWithOptions(dateTime, options) {
+ const jsDate = getDateTimeAsAdjustedJsDate(dateTime);
+
+ // We want floating datetimes and dates to be formatted without regard to
+ // timezone; everything else has been adjusted so that "UTC" will produce the
+ // correct result because we cannot guarantee that the datetime's timezone is
+ // supported by Gecko.
+ const timezone = isDateTimeRelativeToUser(dateTime) ? undefined : "UTC";
+
+ return getFormatter({ ...options, timeZone: timezone }).format(jsDate);
+}
+
+/**
+ * Convert a calendar datetime object to a JavaScript standard Date adjusted
+ * for timezone offset.
+ *
+ * @param {calIDateTime} dateTime The datetime object to convert and adjust.
+ * @returns {Date} The standard JS equivalent of the given datetime, offset
+ * from UTC according to the datetime's timezone.
+ */
+function getDateTimeAsAdjustedJsDate(dateTime) {
+ const unadjustedJsDate = lazy.cal.dtz.dateTimeToJsDate(dateTime);
+
+ // If the datetime is date-only, it doesn't make sense to adjust for timezone.
+ // Floating datetimes likewise are not fixed in a single timezone.
+ if (isDateTimeRelativeToUser(dateTime)) {
+ return unadjustedJsDate;
+ }
+
+ // We abuse `Date` slightly here: its internal representation is intended to
+ // contain the date as seconds from the epoch, but `Intl` relies on adjusting
+ // timezone and we can't be sure we have a recognized timezone ID. Instead, we
+ // force the internal representation to compensate for timezone offset.
+ const offsetInMs = dateTime.timezoneOffset * 1000;
+ return new Date(unadjustedJsDate.valueOf() + offsetInMs);
+}
+
+/**
+ * Get a formatter that can be used to format a date-time in a
+ * locale-appropriate way.
+ *
+ * NOTE: formatters are cached for future requests.
+ *
+ * @param {object} formatOptions - Intl.DateTimeFormatter options.
+ *
+ * @returns {DateTimeFormatter} - The formatter.
+ */
+function getFormatter(formatOptions) {
+ let cacheKey = JSON.stringify(formatOptions);
+ if (formatCache.has(cacheKey)) {
+ return formatCache.get(cacheKey);
+ }
+
+ // Use en-US when running in a test to make the result independent of the test
+ // machine.
+ let locale = Services.appinfo.name == "xpcshell" ? "en-US" : undefined;
+ let formatter;
+ if ("hourCycle" in formatOptions) {
+ // FIXME: The hourCycle property is currently ignored by Services.intl, so
+ // we use Intl instead. Once bug 1749459 is closed, we should only use
+ // Services.intl again.
+ formatter = new Intl.DateTimeFormat(locale, formatOptions);
+ } else {
+ formatter = new Services.intl.DateTimeFormat(locale, formatOptions);
+ }
+
+ formatCache.set(cacheKey, formatter);
+ return formatter;
+}
diff --git a/comm/calendar/base/modules/utils/calDateTimeUtils.jsm b/comm/calendar/base/modules/utils/calDateTimeUtils.jsm
new file mode 100644
index 0000000000..5ea62313b7
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calDateTimeUtils.jsm
@@ -0,0 +1,430 @@
+/* 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/. */
+
+/**
+ * Date, time and timezone related functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.dtz namespace.
+
+const EXPORTED_SYMBOLS = ["caldtz"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var caldtz = {
+ /**
+ * Shortcut to the timezone service's defaultTimezone
+ */
+ get defaultTimezone() {
+ return lazy.cal.timezoneService.defaultTimezone;
+ },
+
+ /**
+ * Shorcut to the UTC timezone
+ */
+ get UTC() {
+ return lazy.cal.timezoneService.UTC;
+ },
+
+ /**
+ * Shortcut to the floating (local) timezone
+ */
+ get floating() {
+ return lazy.cal.timezoneService.floating;
+ },
+
+ /**
+ * Makes sure the given timezone id is part of the list of recent timezones.
+ *
+ * @param aTzid The timezone id to add
+ */
+ saveRecentTimezone(aTzid) {
+ let recentTimezones = caldtz.getRecentTimezones();
+ const MAX_RECENT_TIMEZONES = 5; // We don't need a pref for *everything*.
+
+ if (aTzid != caldtz.defaultTimezone.tzid && !recentTimezones.includes(aTzid)) {
+ // Add the timezone if its not already the default timezone
+ recentTimezones.unshift(aTzid);
+ recentTimezones.splice(MAX_RECENT_TIMEZONES);
+ Services.prefs.setStringPref("calendar.timezone.recent", JSON.stringify(recentTimezones));
+ }
+ },
+
+ /**
+ * Returns a calIDateTime that corresponds to the current time in the user's
+ * default timezone.
+ */
+ now() {
+ let date = caldtz.jsDateToDateTime(new Date());
+ return date.getInTimezone(caldtz.defaultTimezone);
+ },
+
+ /**
+ * Get the default event start date. This is the next full hour, or 23:00 if it
+ * is past 23:00.
+ *
+ * @param aReferenceDate If passed, the time of this date will be modified,
+ * keeping the date and timezone intact.
+ */
+ getDefaultStartDate(aReferenceDate) {
+ let startDate = caldtz.now();
+ if (aReferenceDate) {
+ let savedHour = startDate.hour;
+ startDate = aReferenceDate;
+ if (!startDate.isMutable) {
+ startDate = startDate.clone();
+ }
+ startDate.isDate = false;
+ startDate.hour = savedHour;
+ }
+
+ startDate.second = 0;
+ startDate.minute = 0;
+ if (startDate.hour < 23) {
+ startDate.hour++;
+ }
+ return startDate;
+ },
+
+ /**
+ * Setup the default start and end hours of the given item. This can be a task
+ * or an event.
+ *
+ * @param aItem The item to set up the start and end date for.
+ * @param aReferenceDate If passed, the time of this date will be modified,
+ * keeping the date and timezone intact.
+ */
+ setDefaultStartEndHour(aItem, aReferenceDate) {
+ aItem[caldtz.startDateProp(aItem)] = caldtz.getDefaultStartDate(aReferenceDate);
+
+ if (aItem.isEvent()) {
+ aItem.endDate = aItem.startDate.clone();
+ aItem.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60);
+ }
+ },
+
+ /**
+ * Returns the property name used for the start date of an item, ie either an
+ * event's start date or a task's entry date.
+ */
+ startDateProp(aItem) {
+ if (aItem) {
+ if (aItem.isEvent()) {
+ return "startDate";
+ } else if (aItem.isTodo()) {
+ return "entryDate";
+ }
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ /**
+ * Returns the property name used for the end date of an item, ie either an
+ * event's end date or a task's due date.
+ */
+ endDateProp(aItem) {
+ if (aItem) {
+ if (aItem.isEvent()) {
+ return "endDate";
+ } else if (aItem.isTodo()) {
+ return "dueDate";
+ }
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ /**
+ * Check if the two dates are on the same day (ignoring time)
+ *
+ * @param date1 The left date to compare
+ * @param date2 The right date to compare
+ * @returns True, if dates are on the same day
+ */
+ sameDay(date1, date2) {
+ if (date1 && date2) {
+ if (date1.day == date2.day && date1.month == date2.month && date1.year == date2.year) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Many computations want to work only with date-times, not with dates. This
+ * method will return a proper datetime (set to midnight) for a date object. If
+ * the object is already a datetime, it will simply be returned.
+ *
+ * @param aDate the date or datetime to check
+ */
+ ensureDateTime(aDate) {
+ if (!aDate || !aDate.isDate) {
+ return aDate;
+ }
+ let newDate = aDate.clone();
+ newDate.isDate = false;
+ return newDate;
+ },
+
+ /**
+ * Returns a calIDateTime corresponding to a javascript Date.
+ *
+ * @param aDate a javascript date
+ * @param aTimezone (optional) a timezone that should be enforced
+ * @returns a calIDateTime
+ *
+ * @warning Use of this function is strongly discouraged. calIDateTime should
+ * be used directly whenever possible.
+ * If you pass a timezone, then the passed jsDate's timezone will be ignored,
+ * but only its local time portions are be taken.
+ */
+ jsDateToDateTime(aDate, aTimezone) {
+ let newDate = lazy.cal.createDateTime();
+ if (aTimezone) {
+ newDate.resetTo(
+ aDate.getFullYear(),
+ aDate.getMonth(),
+ aDate.getDate(),
+ aDate.getHours(),
+ aDate.getMinutes(),
+ aDate.getSeconds(),
+ aTimezone
+ );
+ } else {
+ newDate.resetTo(
+ aDate.getUTCFullYear(),
+ aDate.getUTCMonth(),
+ aDate.getUTCDate(),
+ aDate.getUTCHours(),
+ aDate.getUTCMinutes(),
+ aDate.getUTCSeconds(),
+ // Use the existing timezone instead of caldtz.UTC, or starting the
+ // timezone service becomes a requirement in tests.
+ newDate.timezone
+ );
+ }
+ return newDate;
+ },
+
+ /**
+ * Convert a calIDateTime to a Javascript date object. This is the
+ * replacement for the former .jsDate property.
+ *
+ * @param cdt The calIDateTime instance
+ * @returns The Javascript date equivalent.
+ */
+ dateTimeToJsDate(cdt) {
+ if (cdt.isDate) {
+ return new Date(cdt.year, cdt.month, cdt.day);
+ }
+
+ if (cdt.timezone.isFloating) {
+ return new Date(cdt.year, cdt.month, cdt.day, cdt.hour, cdt.minute, cdt.second);
+ }
+ return new Date(cdt.nativeTime / 1000);
+ },
+
+ /**
+ * fromRFC3339
+ * Convert a RFC3339 compliant Date string to a calIDateTime.
+ *
+ * @param aStr The RFC3339 compliant Date String
+ * @param aTimezone The timezone this date string is most likely in
+ * @returns A calIDateTime object
+ */
+ fromRFC3339(aStr, aTimezone) {
+ // XXX I have not covered leapseconds (matches[8]), this might need to
+ // be done. The only reference to leap seconds I found is bug 227329.
+ let dateTime = lazy.cal.createDateTime();
+
+ // Killer regex to parse RFC3339 dates
+ let re = new RegExp(
+ "^([0-9]{4})-([0-9]{2})-([0-9]{2})" +
+ "([Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\\.[0-9]+)?)?" +
+ "(([Zz]|([+-])([0-9]{2}):([0-9]{2})))?"
+ );
+
+ let matches = re.exec(aStr);
+
+ if (!matches) {
+ return null;
+ }
+
+ // Set usual date components
+ dateTime.isDate = matches[4] == null;
+
+ dateTime.year = matches[1];
+ dateTime.month = matches[2] - 1; // Jan is 0
+ dateTime.day = matches[3];
+
+ if (!dateTime.isDate) {
+ dateTime.hour = matches[5];
+ dateTime.minute = matches[6];
+ dateTime.second = matches[7];
+ }
+
+ // Timezone handling
+ if (matches[9] == "Z" || matches[9] == "z") {
+ // If the dates timezone is "Z" or "z", then this is UTC, no matter
+ // what timezone was passed
+ dateTime.timezone = lazy.cal.dtz.UTC;
+ } else if (matches[9] == null) {
+ // We have no timezone info, only a date. We have no way to
+ // know what timezone we are in, so lets assume we are in the
+ // timezone of our local calendar, or whatever was passed.
+
+ dateTime.timezone = aTimezone;
+ } else {
+ let offset_in_s = (matches[11] == "-" ? -1 : 1) * (matches[12] * 3600 + matches[13] * 60);
+
+ // try local timezone first
+ dateTime.timezone = aTimezone;
+
+ // If offset does not match, go through timezones. This will
+ // give you the first tz in the alphabet and kill daylight
+ // savings time, but we have no other choice
+ if (dateTime.timezoneOffset != offset_in_s) {
+ // TODO A patch to Bug 363191 should make this more efficient.
+
+ // Enumerate timezones, set them, check their offset
+ for (let id of lazy.cal.timezoneService.timezoneIds) {
+ dateTime.timezone = lazy.cal.timezoneService.getTimezone(id);
+ if (dateTime.timezoneOffset == offset_in_s) {
+ // This is our last step, so go ahead and return
+ return dateTime;
+ }
+ }
+ // We are still here: no timezone was found
+ dateTime.timezone = lazy.cal.dtz.UTC;
+ if (!dateTime.isDate) {
+ dateTime.hour += (matches[11] == "-" ? -1 : 1) * matches[12];
+ dateTime.minute += (matches[11] == "-" ? -1 : 1) * matches[13];
+ }
+ }
+ }
+ return dateTime;
+ },
+
+ /**
+ * toRFC3339
+ * Convert a calIDateTime to a RFC3339 compliant Date string
+ *
+ * @param aDateTime The calIDateTime object
+ * @returns The RFC3339 compliant date string
+ */
+ toRFC3339(aDateTime) {
+ if (!aDateTime) {
+ return "";
+ }
+
+ let full_tzoffset = aDateTime.timezoneOffset;
+ let tzoffset_hr = Math.floor(Math.abs(full_tzoffset) / 3600);
+
+ let tzoffset_mn = ((Math.abs(full_tzoffset) / 3600).toFixed(2) - tzoffset_hr) * 60;
+
+ let str =
+ aDateTime.year +
+ "-" +
+ ("00" + (aDateTime.month + 1)).substr(-2) +
+ "-" +
+ ("00" + aDateTime.day).substr(-2);
+
+ // Time and Timezone extension
+ if (!aDateTime.isDate) {
+ str +=
+ "T" +
+ ("00" + aDateTime.hour).substr(-2) +
+ ":" +
+ ("00" + aDateTime.minute).substr(-2) +
+ ":" +
+ ("00" + aDateTime.second).substr(-2);
+ if (aDateTime.timezoneOffset != 0) {
+ str +=
+ (full_tzoffset < 0 ? "-" : "+") +
+ ("00" + tzoffset_hr).substr(-2) +
+ ":" +
+ ("00" + tzoffset_mn).substr(-2);
+ } else if (aDateTime.timezone.isFloating) {
+ // RFC3339 Section 4.3 Unknown Local Offset Convention
+ str += "-00:00";
+ } else {
+ // ZULU Time, according to ISO8601's timezone-offset
+ str += "Z";
+ }
+ }
+ return str;
+ },
+
+ /**
+ * Gets the list of recent timezones. Optionally returns the list as
+ * calITimezones.
+ *
+ * @param aConvertZones (optional) If true, return calITimezones instead
+ * @returns An array of timezone ids or calITimezones.
+ */
+ getRecentTimezones(aConvertZones) {
+ let recentTimezones = JSON.parse(
+ Services.prefs.getStringPref("calendar.timezone.recent", "[]") || "[]"
+ );
+ if (!Array.isArray(recentTimezones)) {
+ recentTimezones = [];
+ }
+
+ if (aConvertZones) {
+ let oldZonesLength = recentTimezones.length;
+ for (let i = 0; i < recentTimezones.length; i++) {
+ let timezone = lazy.cal.timezoneService.getTimezone(recentTimezones[i]);
+ if (timezone) {
+ // Replace id with found timezone
+ recentTimezones[i] = timezone;
+ } else {
+ // Looks like the timezone doesn't longer exist, remove it
+ recentTimezones.splice(i, 1);
+ i--;
+ }
+ }
+
+ if (oldZonesLength != recentTimezones.length) {
+ // Looks like the one or other timezone dropped out. Go ahead and
+ // modify the pref.
+ Services.prefs.setStringPref(
+ "calendar.timezone.recent",
+ JSON.stringify(recentTimezones.map(zone => zone.tzid))
+ );
+ }
+ }
+ return recentTimezones;
+ },
+
+ /**
+ * Returns a string representation of a given datetime. For example, to show
+ * in the calendar item summary dialog.
+ *
+ * @param {calIDateTime} dateTime - Datetime to convert.
+ * @returns {string} A string representation of the datetime.
+ */
+ getStringForDateTime(dateTime) {
+ const kDefaultTimezone = lazy.cal.dtz.defaultTimezone;
+ let localTime = dateTime.getInTimezone(kDefaultTimezone);
+ let formatter = lazy.cal.dtz.formatter;
+ let formattedLocalTime = formatter.formatDateTime(localTime);
+
+ if (!dateTime.timezone.isFloating && dateTime.timezone.tzid != kDefaultTimezone.tzid) {
+ // Additionally display the original datetime with timezone.
+ let originalTime = lazy.cal.l10n.getCalString("datetimeWithTimezone", [
+ formatter.formatDateTime(dateTime),
+ dateTime.timezone.tzid,
+ ]);
+ return `${formattedLocalTime} (${originalTime})`;
+ }
+ return formattedLocalTime;
+ },
+};
+
+ChromeUtils.defineModuleGetter(
+ caldtz,
+ "formatter",
+ "resource:///modules/calendar/utils/calDateTimeFormatter.jsm"
+);
diff --git a/comm/calendar/base/modules/utils/calEmailUtils.jsm b/comm/calendar/base/modules/utils/calEmailUtils.jsm
new file mode 100644
index 0000000000..5892ba569f
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calEmailUtils.jsm
@@ -0,0 +1,218 @@
+/* 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/. */
+
+/**
+ * Functions for processing email addresses and sending email
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.email namespace.
+
+const EXPORTED_SYMBOLS = ["calemail"];
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calemail = {
+ /**
+ * Convenience function to open the compose window pre-filled with the information from the
+ * parameters. These parameters are mostly raw header fields, see #createRecipientList function
+ * to create a recipient list string.
+ *
+ * @param {string} aRecipient - The email recipients string.
+ * @param {string} aSubject - The email subject.
+ * @param {string} aBody - The encoded email body text.
+ * @param {nsIMsgIdentity} aIdentity - The email identity to use for sending
+ */
+ sendTo(aRecipient, aSubject, aBody, aIdentity) {
+ let msgParams = Cc["@mozilla.org/messengercompose/composeparams;1"].createInstance(
+ Ci.nsIMsgComposeParams
+ );
+ let composeFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+
+ composeFields.to = aRecipient;
+ composeFields.subject = aSubject;
+ composeFields.body = aBody;
+
+ msgParams.type = Ci.nsIMsgCompType.New;
+ msgParams.format = Ci.nsIMsgCompFormat.Default;
+ msgParams.composeFields = composeFields;
+ msgParams.identity = aIdentity;
+
+ MailServices.compose.OpenComposeWindowWithParams(null, msgParams);
+ },
+
+ /**
+ * Iterates all email identities and calls the passed function with identity and account.
+ * If the called function returns false, iteration is stopped.
+ *
+ * @param {Function} aFunc - The function to be called for each identity and account
+ */
+ iterateIdentities(aFunc) {
+ for (let account of MailServices.accounts.accounts) {
+ for (let identity of account.identities) {
+ if (!aFunc(identity, account)) {
+ break;
+ }
+ }
+ }
+ },
+
+ /**
+ * Prepends a mailto: prefix to an email address like string
+ *
+ * @param {string} aId The string to prepend the prefix if not already there
+ * @returns {string} The string with prefix
+ */
+ prependMailTo(aId) {
+ return aId.replace(/^(?:mailto:)?(.*)@/i, "mailto:$1@");
+ },
+
+ /**
+ * Removes an existing mailto: prefix from an attendee id
+ *
+ * @param {string} aId The string to remove the prefix from if any
+ * @returns {string} The string without prefix
+ */
+ removeMailTo(aId) {
+ return aId.replace(/^mailto:/i, "");
+ },
+
+ /**
+ * Provides a string to use in email "to" header for given attendees
+ *
+ * @param {calIAttendee[]} aAttendees Array of calIAttendee's to check
+ * @returns {string} Valid string to use in a 'to' header of an email
+ */
+ createRecipientList(aAttendees) {
+ let cbEmail = function (aVal) {
+ let email = calemail.getAttendeeEmail(aVal, true);
+ if (!email.length) {
+ lazy.cal.LOG("Dropping invalid recipient for email transport: " + aVal.toString());
+ }
+ return email;
+ };
+ return aAttendees
+ .map(cbEmail)
+ .filter(aVal => aVal.length > 0)
+ .join(", ");
+ },
+
+ /**
+ * Returns a wellformed email string like 'attendee@example.net',
+ * 'Common Name <attendee@example.net>' or '"Name, Common" <attendee@example.net>'
+ *
+ * @param {calIAttendee} aAttendee The attendee to check
+ * @param {boolean} aIncludeCn Whether or not to return also the CN if available
+ * @returns {string} Valid email string or an empty string in case of error
+ */
+ getAttendeeEmail(aAttendee, aIncludeCn) {
+ // If the recipient id is of type urn, we need to figure out the email address, otherwise
+ // we fall back to the attendee id
+ let email = aAttendee.id.match(/^urn:/i) ? aAttendee.getProperty("EMAIL") || "" : aAttendee.id;
+ // Strip leading "mailto:" if it exists.
+ email = email.replace(/^mailto:/i, "");
+ // We add the CN if requested and available
+ let commonName = aAttendee.commonName;
+ if (aIncludeCn && email.length > 0 && commonName && commonName.length > 0) {
+ if (commonName.match(/[,;]/)) {
+ commonName = '"' + commonName + '"';
+ }
+ commonName = commonName + " <" + email + ">";
+ if (calemail.validateRecipientList(commonName) == commonName) {
+ email = commonName;
+ }
+ }
+ return email;
+ },
+
+ /**
+ * Returns a basically checked recipient list - malformed elements will be removed
+ *
+ * @param {string} aRecipients - A comma-seperated list of e-mail addresses
+ * @returns {string} A validated comma-seperated list of e-mail addresses
+ */
+ validateRecipientList(aRecipients) {
+ let compFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ // Resolve the list considering also configured common names
+ let members = compFields.splitRecipients(aRecipients, false);
+ let list = [];
+ let prefix = "";
+ for (let member of members) {
+ if (prefix != "") {
+ // the previous member had no email address - this happens if a recipients CN
+ // contains a ',' or ';' (splitRecipients(..) behaves wrongly here and produces an
+ // additional member with only the first CN part of that recipient and no email
+ // address while the next has the second part of the CN and the according email
+ // address) - we still need to identify the original delimiter to append it to the
+ // prefix
+ let memberCnPart = member.match(/(.*) <.*>/);
+ if (memberCnPart) {
+ let pattern = new RegExp(prefix + "([;,] *)" + memberCnPart[1]);
+ let delimiter = aRecipients.match(pattern);
+ if (delimiter) {
+ prefix = prefix + delimiter[1];
+ }
+ }
+ }
+ let parts = (prefix + member).match(/(.*)( <.*>)/);
+ if (parts) {
+ if (parts[2] == " <>") {
+ // CN but no email address - we keep the CN part to prefix the next member's CN
+ prefix = parts[1];
+ } else {
+ // CN with email address
+ let commonName = parts[1].trim();
+ // in case of any special characters in the CN string, we make sure to enclose
+ // it with dquotes - simple spaces don't require dquotes
+ if (commonName.match(/[-[\]{}()*+?.,;\\^$|#\f\n\r\t\v]/)) {
+ commonName = '"' + commonName.replace(/\\"|"/, "").trim() + '"';
+ }
+ list.push(commonName + parts[2]);
+ prefix = "";
+ }
+ } else if (member.length) {
+ // email address only
+ list.push(member);
+ prefix = "";
+ }
+ }
+ return list.join(", ");
+ },
+
+ /**
+ * Check if the attendee object matches one of the addresses in the list. This
+ * is useful to determine whether the current user acts as a delegate.
+ *
+ * @param {calIAttendee} aRefAttendee - The reference attendee object
+ * @param {string[]} aAddresses - The list of addresses
+ * @returns {boolean} True, if there is a match
+ */
+ attendeeMatchesAddresses(aRefAttendee, aAddresses) {
+ let attId = aRefAttendee.id;
+ if (!attId.match(/^mailto:/i)) {
+ // Looks like its not a normal attendee, possibly urn:uuid:...
+ // Try getting the email through the EMAIL property.
+ let emailProp = aRefAttendee.getProperty("EMAIL");
+ if (emailProp) {
+ attId = emailProp;
+ }
+ }
+
+ attId = attId.toLowerCase().replace(/^mailto:/, "");
+ for (let address of aAddresses) {
+ if (attId == address.toLowerCase().replace(/^mailto:/, "")) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calInvitationUtils.jsm b/comm/calendar/base/modules/utils/calInvitationUtils.jsm
new file mode 100644
index 0000000000..732c6bbf6c
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calInvitationUtils.jsm
@@ -0,0 +1,875 @@
+/* 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/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { recurrenceRule2String } = ChromeUtils.import(
+ "resource:///modules/calendar/calRecurrenceUtils.jsm"
+);
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "CalRecurrenceDate",
+ "resource:///modules/CalRecurrenceDate.jsm"
+);
+ChromeUtils.defineModuleGetter(lazy, "MailStringUtils", "resource:///modules/MailStringUtils.jsm");
+
+const EXPORTED_SYMBOLS = ["calinvitation"];
+
+var calinvitation = {
+ /**
+ * Returns a header title for an ITIP item depending on the response method
+ *
+ * @param {calItipItem} aItipItem the itip item to check
+ * @returns {string} the header title
+ */
+ getItipHeader(aItipItem) {
+ let header;
+
+ if (aItipItem) {
+ let item = aItipItem.getItemList()[0];
+ let summary = item.getProperty("SUMMARY") || "";
+ let organizer = item.organizer;
+ let organizerString = organizer ? organizer.commonName || organizer.toString() : "";
+
+ switch (aItipItem.responseMethod) {
+ case "REQUEST":
+ header = cal.l10n.getLtnString("itipRequestBody", [organizerString, summary]);
+ break;
+ case "CANCEL":
+ header = cal.l10n.getLtnString("itipCancelBody", [organizerString, summary]);
+ break;
+ case "COUNTER":
+ // falls through
+ case "REPLY": {
+ let attendees = item.getAttendees();
+ let sender = cal.itip.getAttendeesBySender(attendees, aItipItem.sender);
+ if (sender.length == 1) {
+ if (aItipItem.responseMethod == "COUNTER") {
+ header = cal.l10n.getLtnString("itipCounterBody", [sender[0].toString(), summary]);
+ } else {
+ let statusString =
+ sender[0].participationStatus == "DECLINED"
+ ? "itipReplyBodyDecline"
+ : "itipReplyBodyAccept";
+ header = cal.l10n.getLtnString(statusString, [sender[0].toString()]);
+ }
+ } else {
+ header = "";
+ }
+ break;
+ }
+ case "DECLINECOUNTER":
+ header = cal.l10n.getLtnString("itipDeclineCounterBody", [organizerString, summary]);
+ break;
+ }
+ }
+
+ if (!header) {
+ header = cal.l10n.getLtnString("imipHtml.header");
+ }
+
+ return header;
+ },
+
+ _createAddedElement(doc) {
+ let el = doc.createElement("ins");
+ el.classList.add("added");
+ return el;
+ },
+
+ _createRemovedElement(doc) {
+ let el = doc.createElement("del");
+ el.classList.add("removed");
+ return el;
+ },
+
+ /**
+ * Creates new icon and text label for the given event attendee.
+ *
+ * @param {Document} doc - The document the new label will belong to.
+ * @param {calIAttendee} attendee - The attendee to create the label for.
+ * @param {calIAttendee[]} attendeeList - The full list of attendees for the
+ * event.
+ * @param {calIAttendee} [oldAttendee] - The previous version of this attendee
+ * for this event.
+ * @param {calIAttendee[]} [attendeeList] - The previous list of attendees for
+ * this event. This is not optional if oldAttendee is given.
+ *
+ * @returns {HTMLDivElement} - The new attendee label.
+ */
+ createAttendeeLabel(doc, attendee, attendeeList, oldAttendee, oldAttendeeList) {
+ let userType = attendee.userType || "INDIVIDUAL";
+ let role = attendee.role || "REQ-PARTICIPANT";
+ let partstat = attendee.participationStatus || "NEEDS-ACTION";
+
+ let modified =
+ oldAttendee &&
+ ((oldAttendee.userType || "INDIVIDUAL") != userType ||
+ (oldAttendee.role || "REQ-PARTICIPANT") != role ||
+ (oldAttendee.participationStatus || "NEEDS-ACTION") != partstat);
+
+ // resolve delegatees/delegators to display also the CN
+ let del = cal.itip.resolveDelegation(attendee, attendeeList);
+ if (oldAttendee && !modified) {
+ let oldDel = cal.itip.resolveDelegation(oldAttendee, oldAttendeeList);
+ modified = oldDel.delegatees !== del.delegatees || oldDel.delegator !== del.delegator;
+ }
+
+ let userTypeString = cal.l10n.getLtnString("imipHtml.attendeeUserType2." + userType, [
+ attendee.toString(),
+ ]);
+ let roleString = cal.l10n.getLtnString("imipHtml.attendeeRole2." + role, [userTypeString]);
+ let partstatString = cal.l10n.getLtnString("imipHtml.attendeePartStat2." + partstat, [
+ attendee.commonName || attendee.toString(),
+ del.delegatees,
+ ]);
+ let tooltip = cal.l10n.getLtnString("imipHtml.attendee.combined", [roleString, partstatString]);
+
+ let name = attendee.toString();
+ if (del.delegators) {
+ name += " " + cal.l10n.getLtnString("imipHtml.attendeeDelegatedFrom", [del.delegators]);
+ }
+
+ let attendeeLabel = doc.createElement("div");
+ attendeeLabel.classList.add("attendee-label");
+ // NOTE: tooltip will not appear when the top level is XUL.
+ attendeeLabel.setAttribute("title", tooltip);
+ attendeeLabel.setAttribute("attendeeid", attendee.id);
+ attendeeLabel.setAttribute("tabindex", "0");
+
+ if (modified) {
+ attendeeLabel.classList.add("modified");
+ }
+
+ // FIXME: Replace icon with an img element with src and alt. The current
+ // problem is that the icon image is set in CSS on the itip-icon class
+ // with a background image that changes with the role attribute. This is
+ // generally inaccessible (see Bug 1702560).
+ let icon = doc.createElement("div");
+ icon.classList.add("itip-icon");
+ icon.setAttribute("partstat", partstat);
+ icon.setAttribute("usertype", userType);
+ icon.setAttribute("attendeerole", role);
+ attendeeLabel.appendChild(icon);
+
+ let text = doc.createElement("div");
+ text.classList.add("attendee-name");
+ text.appendChild(doc.createTextNode(name));
+ attendeeLabel.appendChild(text);
+
+ return attendeeLabel;
+ },
+
+ /**
+ * Create an new list item element for an attendee, to be used as a child of
+ * an "attendee-list" element.
+ *
+ * @param {Document} doc - The document the new list item will belong to.
+ * @param {Element} attendeeLabel - The attendee label to place within the
+ * list item.
+ *
+ * return {HTMLLIElement} - The attendee list item.
+ */
+ createAttendeeListItem(doc, attendeeLabel) {
+ let listItem = doc.createElement("li");
+ listItem.classList.add("attendee-list-item");
+ listItem.appendChild(attendeeLabel);
+ return listItem;
+ },
+
+ /**
+ * Creates a new element that lists the given attendees.
+ *
+ * @param {Document} doc - The document the new list will belong to.
+ * @param {calIAttendee[]} attendees - The attendees to create the list for.
+ * @param {calIAttendee[]} [oldAttendees] - A list of attendees for a
+ * previous version of the event.
+ *
+ * @returns {HTMLUListElement} - The list of attendees.
+ */
+ createAttendeesList(doc, attendees, oldAttendees) {
+ let list = doc.createElement("ul");
+ list.classList.add("attendee-list");
+
+ let oldAttendeeData;
+ if (oldAttendees) {
+ oldAttendeeData = [];
+ for (let attendee of oldAttendees) {
+ let data = { attendee, item: null };
+ oldAttendeeData.push(data);
+ }
+ }
+
+ for (let attendee of attendees) {
+ let attendeeLabel;
+ let oldData;
+ if (oldAttendeeData) {
+ oldData = oldAttendeeData.find(old => old.attendee.id == attendee.id);
+ if (oldData) {
+ // Same attendee.
+ attendeeLabel = this.createAttendeeLabel(
+ doc,
+ attendee,
+ attendees,
+ oldData.attendee,
+ oldAttendees
+ );
+ } else {
+ // Added attendee.
+ attendeeLabel = this._createAddedElement(doc);
+ attendeeLabel.appendChild(this.createAttendeeLabel(doc, attendee, attendees));
+ }
+ } else {
+ attendeeLabel = this.createAttendeeLabel(doc, attendee, attendees);
+ }
+ let listItem = this.createAttendeeListItem(doc, attendeeLabel);
+ if (oldData) {
+ oldData.item = listItem;
+ }
+ list.appendChild(listItem);
+ }
+
+ if (oldAttendeeData) {
+ let next = null;
+ // Traverse from the end of the list to the start.
+ for (let i = oldAttendeeData.length - 1; i >= 0; i--) {
+ let data = oldAttendeeData[i];
+ if (!data.item) {
+ // Removed attendee.
+ let attendeeLabel = this._createRemovedElement(doc);
+ attendeeLabel.appendChild(this.createAttendeeLabel(doc, data.attendee, attendees));
+ let listItem = this.createAttendeeListItem(doc, attendeeLabel);
+ data.item = listItem;
+
+ // Insert the removed attendee list item *before* the list item that
+ // corresponds to the attendee that follows this attendee in the
+ // oldAttendees list.
+ //
+ // NOTE: by traversing from the end of the list to the start, we are
+ // prioritising being next to the attendee that follows us, rather
+ // than being next to the attendee that precedes us in the oldAttendee
+ // list.
+ //
+ // Specifically, if a new attendee is added between these two old
+ // neighbours, the added attendee will be shown earlier than the
+ // removed attendee in the list.
+ //
+ // E.g., going from the list
+ // [first@person, removed@person, second@person]
+ // to
+ // [first@person, added@person, second@person]
+ // will be shown as
+ // first@person
+ // + added@person
+ // - removed@person
+ // second@person
+ // because the removed@person's uses second@person as their reference
+ // point.
+ //
+ // NOTE: next.item is always non-null because next.item is always set
+ // by the end of the last loop.
+ list.insertBefore(listItem, next ? next.item : null);
+ }
+ next = data;
+ }
+ }
+
+ return list;
+ },
+
+ /**
+ * Returns the html representation of the event as a DOM document.
+ *
+ * @param {calIItemBase} event - The event to parse into html.
+ * @param {calItipItem} itipItem - The itip item, which contains the event.
+ * @returns {Document} The html representation of the event.
+ */
+ createInvitationOverlay(event, itipItem) {
+ // Creates HTML using the Node strings in the properties file
+ const parser = new DOMParser();
+ let doc = parser.parseFromString(calinvitation.htmlTemplate, "text/html");
+ this.updateInvitationOverlay(doc, event, itipItem);
+ return doc;
+ },
+
+ /**
+ * Update the document created by createInvitationOverlay to show the new
+ * event details, and optionally show changes in the event against an older
+ * version of it.
+ *
+ * For example, this can be used for email invitations to update the invite to
+ * show the most recent version of the event found in the calendar, whilst
+ * also showing the event details that were removed since the original email
+ * invitation. I.e. contrasting the event found in the calendar with the event
+ * found within the email. Alternatively, if the email invitation is newer
+ * than the event found in the calendar, you can switch the comparison around.
+ * (As used in imip-bar.js.)
+ *
+ * @param {Document} doc - The document to update, previously created through
+ * createInvitationOverlay.
+ * @param {calIItemBase} event - The newest version of the event.
+ * @param {calItipItem} itipItem - The itip item, which contains the event.
+ * @param {calIItemBase} [oldEvent] - A previous version of the event to
+ * show as updated.
+ */
+ updateInvitationOverlay(doc, event, itipItem, oldEvent) {
+ let headerDescr = doc.getElementById("imipHtml-header");
+ if (headerDescr) {
+ headerDescr.textContent = calinvitation.getItipHeader(itipItem);
+ }
+
+ let formatter = cal.dtz.formatter;
+
+ /**
+ * Set whether the given field should be shown.
+ *
+ * @param {string} fieldName - The name of the field.
+ * @param {boolean} show - Whether the field should be shown.
+ */
+ let showField = (fieldName, show) => {
+ let row = doc.getElementById("imipHtml-" + fieldName + "-row");
+ if (row.hidden && show) {
+ // Make sure the field name is set.
+ doc.getElementById("imipHtml-" + fieldName + "-descr").textContent = cal.l10n.getLtnString(
+ "imipHtml." + fieldName
+ );
+ }
+ row.hidden = !show;
+ };
+
+ /**
+ * Set the given element to display the given value.
+ *
+ * @param {Element} element - The element to display the value within.
+ * @param {string} value - The value to show.
+ * @param {boolean} [convert=false] - Whether the value will need converting
+ * to a sanitised document fragment.
+ * @param {string} [html] - The html to use as the value. This is only used
+ * if convert is set to true.
+ */
+ let setElementValue = (element, value, convert = false, html) => {
+ if (convert) {
+ element.appendChild(cal.view.textToHtmlDocumentFragment(value, doc, html));
+ } else {
+ element.textContent = value;
+ }
+ };
+
+ /**
+ * Set the given field.
+ *
+ * If oldEvent is set, and the new value differs from the old one, it will
+ * be shown as added and/or removed content.
+ *
+ * If neither events have a value, the field will be hidden.
+ *
+ * @param {string} fieldName - The name of the field to set.
+ * @param {Function} getValue - A method to retrieve the field value from an
+ * event. Should return a string, or a falsey value if the event has no
+ * value for this field.
+ * @param {boolean} [convert=false] - Whether the value will need converting
+ * to a sanitised document fragment.
+ * @param {Function} [getHtml] - A method to retrieve the value as a html.
+ */
+ let setField = (fieldName, getValue, convert = false, getHtml) => {
+ let cell = doc.getElementById("imipHtml-" + fieldName + "-content");
+ while (cell.lastChild) {
+ cell.lastChild.remove();
+ }
+ let value = getValue(event);
+ let oldValue = oldEvent && getValue(oldEvent);
+ let html = getHtml && getHtml(event);
+ let oldHtml = oldEvent && getHtml && getHtml(event);
+ if (oldEvent && (oldValue || value) && oldValue !== value) {
+ // Different values, with at least one being truthy.
+ showField(fieldName, true);
+ if (!oldValue) {
+ let added = this._createAddedElement(doc);
+ setElementValue(added, value, convert, html);
+ cell.appendChild(added);
+ } else if (!value) {
+ let removed = this._createRemovedElement(doc);
+ setElementValue(removed, oldValue, convert, oldHtml);
+ cell.appendChild(removed);
+ } else {
+ let added = this._createAddedElement(doc);
+ setElementValue(added, value, convert, html);
+ let removed = this._createRemovedElement(doc);
+ setElementValue(removed, oldValue, convert, oldHtml);
+
+ cell.appendChild(added);
+ cell.appendChild(doc.createElement("br"));
+ cell.appendChild(removed);
+ }
+ } else if (value) {
+ // Same truthy value.
+ showField(fieldName, true);
+ setElementValue(cell, value, convert, html);
+ } else {
+ showField(fieldName, false);
+ }
+ };
+
+ setField("summary", ev => ev.title, true);
+ setField("location", ev => ev.getProperty("LOCATION"), true);
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+ setField("when", ev => {
+ if (ev.recurrenceInfo) {
+ let startDate = ev.startDate?.getInTimezone(kDefaultTimezone) ?? null;
+ let endDate = ev.endDate?.getInTimezone(kDefaultTimezone) ?? null;
+ let repeatString = recurrenceRule2String(
+ ev.recurrenceInfo,
+ startDate,
+ endDate,
+ startDate.isDate
+ );
+ if (repeatString) {
+ return repeatString;
+ }
+ }
+ return formatter.formatItemInterval(ev);
+ });
+
+ setField("canceledOccurrences", ev => {
+ if (!ev.recurrenceInfo) {
+ return null;
+ }
+ let formattedExDates = [];
+
+ // Show removed instances
+ for (let exc of ev.recurrenceInfo.getRecurrenceItems()) {
+ if (
+ (exc instanceof lazy.CalRecurrenceDate || exc instanceof Ci.calIRecurrenceDate) &&
+ exc.isNegative
+ ) {
+ // This is an EXDATE
+ let excDate = exc.date.getInTimezone(kDefaultTimezone);
+ formattedExDates.push(formatter.formatDateTime(excDate));
+ }
+ }
+ if (formattedExDates.length > 0) {
+ return formattedExDates.join("\n");
+ }
+ return null;
+ });
+
+ let dateComptor = (a, b) => a.startDate.compare(b.startDate);
+
+ setField("modifiedOccurrences", ev => {
+ if (!ev.recurrenceInfo) {
+ return null;
+ }
+ let modifiedOccurrences = [];
+
+ for (let exc of ev.recurrenceInfo.getRecurrenceItems()) {
+ if (
+ (exc instanceof lazy.CalRecurrenceDate || exc instanceof Ci.calIRecurrenceDate) &&
+ !exc.isNegative
+ ) {
+ // This is an RDATE, close enough to a modified occurrence
+ let excItem = ev.recurrenceInfo.getOccurrenceFor(exc.date);
+ cal.data.binaryInsert(modifiedOccurrences, excItem, dateComptor, true);
+ }
+ }
+ for (let recurrenceId of ev.recurrenceInfo.getExceptionIds()) {
+ let exc = ev.recurrenceInfo.getExceptionFor(recurrenceId);
+ let excLocation = exc.getProperty("LOCATION");
+
+ // Only show modified occurrence if start, duration or location
+ // has changed.
+ exc.QueryInterface(Ci.calIEvent);
+ if (
+ exc.startDate.compare(exc.recurrenceId) != 0 ||
+ exc.duration.compare(ev.duration) != 0 ||
+ excLocation != ev.getProperty("LOCATION")
+ ) {
+ cal.data.binaryInsert(modifiedOccurrences, exc, dateComptor, true);
+ }
+ }
+
+ if (modifiedOccurrences.length > 0) {
+ let evLocation = ev.getProperty("LOCATION");
+ return modifiedOccurrences
+ .map(occ => {
+ let formattedExc = formatter.formatItemInterval(occ);
+ let occLocation = occ.getProperty("LOCATION");
+ if (occLocation != evLocation) {
+ formattedExc +=
+ " (" + cal.l10n.getLtnString("imipHtml.newLocation", [occLocation]) + ")";
+ }
+ return formattedExc;
+ })
+ .join("\n");
+ }
+ return null;
+ });
+
+ setField(
+ "description",
+ // We remove the useless "Outlookism" squiggle.
+ ev => ev.descriptionText?.replace("*~*~*~*~*~*~*~*~*~*", ""),
+ true,
+ ev => ev.descriptionHTML
+ );
+
+ setField("url", ev => ev.getProperty("URL"), true);
+ setField(
+ "attachments",
+ ev => {
+ // ATTACH - we only display URI but no BINARY type attachments here
+ let links = [];
+ for (let attachment of ev.getAttachments()) {
+ if (attachment.uri) {
+ links.push(attachment.uri.spec);
+ }
+ }
+ return links.join("\n");
+ },
+ true
+ );
+
+ // ATTENDEE and ORGANIZER fields
+ let attendees = event.getAttendees();
+ let oldAttendees = oldEvent?.getAttendees();
+
+ let organizerCell = doc.getElementById("imipHtml-organizer-cell");
+ while (organizerCell.lastChild) {
+ organizerCell.lastChild.remove();
+ }
+
+ let organizer = event.organizer;
+ if (oldEvent) {
+ let oldOrganizer = oldEvent.organizer;
+ if (!organizer && !oldOrganizer) {
+ showField("organizer", false);
+ } else {
+ showField("organizer", true);
+
+ let removed = false;
+ let added = false;
+ if (!organizer) {
+ removed = true;
+ } else if (!oldOrganizer) {
+ added = true;
+ } else if (organizer.id !== oldOrganizer.id) {
+ removed = true;
+ added = true;
+ } else {
+ // Same organizer, potentially modified.
+ organizerCell.appendChild(
+ this.createAttendeeLabel(doc, organizer, attendees, oldOrganizer, oldAttendees)
+ );
+ }
+ // Append added first.
+ if (added) {
+ let addedEl = this._createAddedElement(doc);
+ addedEl.appendChild(this.createAttendeeLabel(doc, organizer, attendees));
+ organizerCell.appendChild(addedEl);
+ }
+ if (removed) {
+ let removedEl = this._createRemovedElement(doc);
+ removedEl.appendChild(this.createAttendeeLabel(doc, oldOrganizer, oldAttendees));
+ organizerCell.appendChild(removedEl);
+ }
+ }
+ } else if (!organizer) {
+ showField("organizer", false);
+ } else {
+ showField("organizer", true);
+ organizerCell.appendChild(this.createAttendeeLabel(doc, organizer, attendees));
+ }
+
+ let attendeesCell = doc.getElementById("imipHtml-attendees-cell");
+ while (attendeesCell.lastChild) {
+ attendeesCell.lastChild.remove();
+ }
+
+ // Hide if we have no attendees, and neither does the old event.
+ if (attendees.length == 0 && (!oldEvent || oldAttendees.length == 0)) {
+ showField("attendees", false);
+ } else {
+ // oldAttendees is undefined if oldEvent is undefined.
+ showField("attendees", true);
+ attendeesCell.appendChild(this.createAttendeesList(doc, attendees, oldAttendees));
+ }
+ },
+
+ /**
+ * Returns the header section for an invitation email.
+ *
+ * @param {string} aMessageId the message id to use for that email
+ * @param {nsIMsgIdentity} aIdentity the identity to use for that email
+ * @returns {string} the source code of the header section of the email
+ */
+ getHeaderSection(aMessageId, aIdentity, aToList, aSubject) {
+ let recipient = aIdentity.fullName + " <" + aIdentity.email + ">";
+ let from = aIdentity.fullName.length
+ ? cal.email.validateRecipientList(recipient)
+ : aIdentity.email;
+ let header =
+ "MIME-version: 1.0\r\n" +
+ (aIdentity.replyTo
+ ? "Return-path: " + calinvitation.encodeMimeHeader(aIdentity.replyTo, true) + "\r\n"
+ : "") +
+ "From: " +
+ calinvitation.encodeMimeHeader(from, true) +
+ "\r\n" +
+ (aIdentity.organization
+ ? "Organization: " + calinvitation.encodeMimeHeader(aIdentity.organization) + "\r\n"
+ : "") +
+ "Message-ID: " +
+ aMessageId +
+ "\r\n" +
+ "To: " +
+ calinvitation.encodeMimeHeader(aToList, true) +
+ "\r\n" +
+ "Date: " +
+ calinvitation.getRfc5322FormattedDate() +
+ "\r\n" +
+ "Subject: " +
+ calinvitation.encodeMimeHeader(aSubject.replace(/(\n|\r\n)/, "|")) +
+ "\r\n";
+ let validRecipients;
+ if (aIdentity.doCc) {
+ validRecipients = cal.email.validateRecipientList(aIdentity.doCcList);
+ if (validRecipients != "") {
+ header += "Cc: " + calinvitation.encodeMimeHeader(validRecipients, true) + "\r\n";
+ }
+ }
+ if (aIdentity.doBcc) {
+ validRecipients = cal.email.validateRecipientList(aIdentity.doBccList);
+ if (validRecipients != "") {
+ header += "Bcc: " + calinvitation.encodeMimeHeader(validRecipients, true) + "\r\n";
+ }
+ }
+ return header;
+ },
+
+ /**
+ * Returns a datetime string according to section 3.3 of RfC5322
+ *
+ * @param {Date} [optional] Js Date object to format; if not provided current DateTime is used
+ * @returns {string} Datetime string with a modified tz-offset notation compared to
+ * Date.toString() like "Fri, 20 Nov 2015 09:45:36 +0100"
+ */
+ getRfc5322FormattedDate(aDate = null) {
+ let date = aDate || new Date();
+ let str = date
+ .toString()
+ .replace(
+ /^(\w{3}) (\w{3}) (\d{2}) (\d{4}) ([0-9:]{8}) GMT([+-])(\d{4}).*$/,
+ "$1, $3 $2 $4 $5 $6$7"
+ );
+ // according to section 3.3 of RfC5322, +0000 should be used for defined timezones using
+ // UTC time, while -0000 should indicate a floating time instead
+ let timezone = cal.dtz.defaultTimezone;
+ if (timezone && timezone.isFloating) {
+ str.replace(/\+0000$/, "-0000");
+ }
+ return str;
+ },
+
+ /**
+ * Converts a given unicode text to utf-8 and normalizes line-breaks to \r\n
+ *
+ * @param {string} aText a unicode encoded string
+ * @returns {string} the converted uft-8 encoded string
+ */
+ encodeUTF8(aText) {
+ return calinvitation.convertFromUnicode(aText).replace(/(\r\n)|\n/g, "\r\n");
+ },
+
+ /**
+ * Converts a given unicode text
+ *
+ * @param {string} aSrc unicode text to convert
+ * @returns {string} the converted string
+ */
+ convertFromUnicode(aSrc) {
+ return lazy.MailStringUtils.stringToByteString(aSrc);
+ },
+
+ /**
+ * Converts a header to a mime encoded header
+ *
+ * @param {string} aHeader a header to encode
+ * @param {boolean} aIsEmail if enabled, only the CN but not the email address gets
+ * converted - default value is false
+ * @returns {string} the encoded string
+ */
+ encodeMimeHeader(aHeader, aIsEmail = false) {
+ let fieldNameLen = aHeader.indexOf(": ") + 2;
+ return MailServices.mimeConverter.encodeMimePartIIStr_UTF8(
+ aHeader,
+ aIsEmail,
+ fieldNameLen,
+ Ci.nsIMimeConverter.MIME_ENCODED_WORD_SIZE
+ );
+ },
+
+ /**
+ * Parses a counterproposal to extract differences to the existing event
+ *
+ * @param {calIEvent|calITodo} aProposedItem The counterproposal
+ * @param {calIEvent|calITodo} aExistingItem The item to compare with
+ * @returns {JSObject} Objcet of result and differences of parsing
+ * @returns {string} JsObject.result.type Parsing result: OK|OLDVERSION|ERROR|NODIFF
+ * @returns {string} JsObject.result.descr Parsing result description
+ * @returns {Array} JsObject.differences Array of objects consisting of property, proposed
+ * and original properties.
+ * @returns {string} JsObject.comment A comment of the attendee, if any
+ */
+ parseCounter(aProposedItem, aExistingItem) {
+ let isEvent = aProposedItem.isEvent();
+ // atm we only support a subset of properties, for a full list see RfC 5546 section 3.2.7
+ let properties = ["SUMMARY", "LOCATION", "DTSTART", "DTEND", "COMMENT"];
+ if (!isEvent) {
+ cal.LOG("Parsing of counterproposals is currently only supported for events.");
+ properties = [];
+ }
+
+ let diff = [];
+ let status = { descr: "", type: "OK" };
+ // As required in https://tools.ietf.org/html/rfc5546#section-3.2.7 a valid counterproposal
+ // is referring to as existing UID and must include the same sequence number and organizer as
+ // the original request being countered
+ if (
+ aProposedItem.id == aExistingItem.id &&
+ aProposedItem.organizer &&
+ aExistingItem.organizer &&
+ aProposedItem.organizer.id == aExistingItem.organizer.id
+ ) {
+ let proposedSequence = aProposedItem.getProperty("SEQUENCE") || 0;
+ let existingSequence = aExistingItem.getProperty("SEQUENCE") || 0;
+ if (existingSequence >= proposedSequence) {
+ if (existingSequence > proposedSequence) {
+ // in this case we prompt the organizer with the additional information that the
+ // received proposal refers to an outdated version of the event
+ status.descr = "This is a counterproposal to an already rescheduled event.";
+ status.type = "OUTDATED";
+ } else if (aProposedItem.stampTime.compare(aExistingItem.stampTime) == -1) {
+ // now this is the same sequence but the proposal is not based on the latest
+ // update of the event - updated events may have minor changes, while for major
+ // ones there has been a rescheduling
+ status.descr = "This is a counterproposal not based on the latest event update.";
+ status.type = "NOTLATESTUPDATE";
+ }
+ for (let prop of properties) {
+ let newValue = aProposedItem.getProperty(prop) || null;
+ let oldValue = aExistingItem.getProperty(prop) || null;
+ if (
+ (["DTSTART", "DTEND"].includes(prop) && newValue.toString() != oldValue.toString()) ||
+ (!["DTSTART", "DTEND"].includes(prop) && newValue != oldValue)
+ ) {
+ diff.push({
+ property: prop,
+ proposed: newValue,
+ original: oldValue,
+ });
+ }
+ }
+ } else {
+ status.descr = "Invalid sequence number in counterproposal.";
+ status.type = "ERROR";
+ }
+ } else {
+ status.descr = "Mismatch of uid or organizer in counterproposal.";
+ status.type = "ERROR";
+ }
+ if (status.type != "ERROR" && !diff.length) {
+ status.descr = "No difference in counterproposal detected.";
+ status.type = "NODIFF";
+ }
+ return { result: status, differences: diff };
+ },
+
+ /**
+ * The HTML template used to format invitations for display.
+ * This used to be in a separate file (invitation-template.xhtml) and should
+ * probably be moved back there. But loading on-the-fly was causing a nasty
+ * C++ reentrancy issue (see bug 1679299).
+ */
+ htmlTemplate: `<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="chrome://messagebody/skin/imip.css" />
+ <link rel="stylesheet" href="chrome://messagebody/skin/calendar-attendees.css" />
+ </head>
+ <body>
+ <details id="imipHTMLDetails" class="invitation-details">
+ <summary id="imipHtml-header"></summary>
+ <div class="invitation-border">
+ <table class="invitation-table">
+ <tr id="imipHtml-summary-row" hidden="hidden">
+ <th id="imipHtml-summary-descr" class="description" scope="row"></th>
+ <td id="imipHtml-summary-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-location-row" hidden="hidden">
+ <th id="imipHtml-location-descr" class="description" scope="row"></th>
+ <td id="imipHtml-location-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-when-row" hidden="hidden">
+ <th id="imipHtml-when-descr" class="description" scope="row"></th>
+ <td id="imipHtml-when-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-canceledOccurrences-row" hidden="hidden">
+ <th id="imipHtml-canceledOccurrences-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-canceledOccurrences-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-modifiedOccurrences-row" hidden="hidden">
+ <th id="imipHtml-modifiedOccurrences-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-modifiedOccurrences-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-organizer-row" hidden="hidden">
+ <th id="imipHtml-organizer-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-organizer-cell" class="content"></td>
+ </tr>
+ <tr id="imipHtml-description-row" hidden="hidden">
+ <th id="imipHtml-description-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-description-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-attachments-row" hidden="hidden">
+ <th id="imipHtml-attachments-descr"
+ class="description"
+ scope="row"></th>
+ <td id="imipHtml-attachments-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-comment-row" hidden="hidden">
+ <th id="imipHtml-comment-descr" class="description" scope="row"></th>
+ <td id="imipHtml-comment-content" class="content"></td>
+ </tr>
+ <tr id="imipHtml-attendees-row" hidden="hidden">
+ <th id="imipHtml-attendees-descr"
+ class="description"
+ scope="row">
+ </th>
+ <td id="imipHtml-attendees-cell" class="content"></td>
+ </tr>
+ <tr id="imipHtml-url-row" hidden="hidden">
+ <th id="imipHtml-url-descr" class="description" scope="row"></th>
+ <td id="imipHtml-url-content" class="content"></td>
+ </tr>
+ </table>
+ </div>
+ </details>
+ </body>
+</html>
+`,
+};
diff --git a/comm/calendar/base/modules/utils/calItemUtils.jsm b/comm/calendar/base/modules/utils/calItemUtils.jsm
new file mode 100644
index 0000000000..d121430fcc
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calItemUtils.jsm
@@ -0,0 +1,675 @@
+/* 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/. */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calHashedArray.jsm");
+
+/*
+ * Calendar item related functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.item namespace.
+
+const EXPORTED_SYMBOLS = ["calitem"];
+
+var calitem = {
+ ItemDiff: (function () {
+ /**
+ * Given two sets of items, find out which items were added, changed or
+ * removed.
+ *
+ * The general flow is to first use load method to load the engine with
+ * the first set of items, then use difference to load the set of
+ * items to diff against. Afterwards, call the complete method to tell the
+ * engine that no more items are coming.
+ *
+ * You can then access the mAddedItems/mModifiedItems/mDeletedItems attributes to
+ * get the items that were changed during the process.
+ */
+ function ItemDiff() {
+ this.reset();
+ }
+
+ ItemDiff.prototype = {
+ STATE_INITIAL: 1,
+ STATE_LOADING: 2,
+ STATE_DIFFERING: 4,
+ STATE_COMPLETED: 8,
+
+ state: 1,
+ mInitialItems: null,
+
+ mModifiedItems: null,
+ mModifiedOldItems: null,
+ mAddedItems: null,
+ mDeletedItems: null,
+
+ /**
+ * Expect the difference engine to be in the given state.
+ *
+ * @param aState The state to be in
+ * @param aMethod The method name expecting the state
+ */
+ _expectState(aState, aMethod) {
+ if ((this.state & aState) == 0) {
+ throw new Error(
+ "ItemDiff method " + aMethod + " called while in unexpected state " + this.state
+ );
+ }
+ },
+
+ /**
+ * Loads an array of items. This step cannot be executed
+ * after calling the difference methods.
+ *
+ * @param items The array of items to load
+ */
+ load(items) {
+ this._expectState(this.STATE_INITIAL | this.STATE_LOADING, "load");
+
+ for (let item of items) {
+ this.mInitialItems[item.hashId] = item;
+ }
+
+ this.state = this.STATE_LOADING;
+ },
+
+ /**
+ * Calculate the difference for the array of items. This method should be
+ * called after all load methods and before the complete method.
+ *
+ * @param items The array of items to calculate difference with
+ */
+ difference(items) {
+ this._expectState(
+ this.STATE_INITIAL | this.STATE_LOADING | this.STATE_DIFFERING,
+ "difference"
+ );
+
+ this.mModifiedOldItems.startBatch();
+ this.mModifiedItems.startBatch();
+ this.mAddedItems.startBatch();
+
+ for (let item of items) {
+ if (item.hashId in this.mInitialItems) {
+ let oldItem = this.mInitialItems[item.hashId];
+ this.mModifiedOldItems.addItem(oldItem);
+ this.mModifiedItems.addItem(item);
+ } else {
+ this.mAddedItems.addItem(item);
+ }
+ delete this.mInitialItems[item.hashId];
+ }
+
+ this.mModifiedOldItems.endBatch();
+ this.mModifiedItems.endBatch();
+ this.mAddedItems.endBatch();
+
+ this.state = this.STATE_DIFFERING;
+ },
+
+ /**
+ * Tell the engine that all load and difference calls have been made, this
+ * makes sure that all item states are correctly returned.
+ */
+ complete() {
+ this._expectState(
+ this.STATE_INITIAL | this.STATE_LOADING | this.STATE_DIFFERING,
+ "complete"
+ );
+
+ this.mDeletedItems.startBatch();
+
+ for (let hashId in this.mInitialItems) {
+ let item = this.mInitialItems[hashId];
+ this.mDeletedItems.addItem(item);
+ }
+
+ this.mDeletedItems.endBatch();
+ this.mInitialItems = {};
+
+ this.state = this.STATE_COMPLETED;
+ },
+
+ /** @returns a HashedArray containing the new version of the modified items */
+ get modifiedItems() {
+ this._expectState(this.STATE_COMPLETED, "get modifiedItems");
+ return this.mModifiedItems;
+ },
+
+ /** @returns a HashedArray containing the old version of the modified items */
+ get modifiedOldItems() {
+ this._expectState(this.STATE_COMPLETED, "get modifiedOldItems");
+ return this.mModifiedOldItems;
+ },
+
+ /** @returns a HashedArray containing added items */
+ get addedItems() {
+ this._expectState(this.STATE_COMPLETED, "get addedItems");
+ return this.mAddedItems;
+ },
+
+ /** @returns a HashedArray containing deleted items */
+ get deletedItems() {
+ this._expectState(this.STATE_COMPLETED, "get deletedItems");
+ return this.mDeletedItems;
+ },
+
+ /** @returns the number of loaded items */
+ get count() {
+ return Object.keys(this.mInitialItems).length;
+ },
+
+ /**
+ * Resets the difference engine to its initial state.
+ */
+ reset() {
+ this.mInitialItems = {};
+ this.mModifiedItems = new cal.HashedArray();
+ this.mModifiedOldItems = new cal.HashedArray();
+ this.mAddedItems = new cal.HashedArray();
+ this.mDeletedItems = new cal.HashedArray();
+ this.state = this.STATE_INITIAL;
+ },
+ };
+ return ItemDiff;
+ })(),
+
+ /**
+ * Checks if an item is supported by a Calendar.
+ *
+ * @param aCalendar the calendar
+ * @param aItem the item either a task or an event
+ * @returns true or false
+ */
+ isItemSupported(aItem, aCalendar) {
+ if (aItem.isTodo()) {
+ return aCalendar.getProperty("capabilities.tasks.supported") !== false;
+ } else if (aItem.isEvent()) {
+ return aCalendar.getProperty("capabilities.events.supported") !== false;
+ }
+ return false;
+ },
+
+ /*
+ * Checks whether a calendar supports events
+ *
+ * @param aCalendar
+ */
+ isEventCalendar(aCalendar) {
+ return aCalendar.getProperty("capabilities.events.supported") !== false;
+ },
+
+ /*
+ * Checks whether a calendar supports tasks
+ *
+ * @param aCalendar
+ */
+ isTaskCalendar(aCalendar) {
+ return aCalendar.getProperty("capabilities.tasks.supported") !== false;
+ },
+
+ /**
+ * Checks whether the passed item fits into the demanded range.
+ *
+ * @param item the item
+ * @param rangeStart (inclusive) range start or null (open range)
+ * @param rangeStart (exclusive) range end or null (open range)
+ * @param returnDtstartOrDue returns item's start (or due) date in case
+ * the item is in the specified Range; null otherwise.
+ */
+ checkIfInRange(item, rangeStart, rangeEnd, returnDtstartOrDue) {
+ let startDate;
+ let endDate;
+ let queryStart = cal.dtz.ensureDateTime(rangeStart);
+ if (item.isEvent()) {
+ startDate = item.startDate;
+ if (!startDate) {
+ // DTSTART mandatory
+ // xxx todo: should we assert this case?
+ return null;
+ }
+ endDate = item.endDate || startDate;
+ } else {
+ let dueDate = item.dueDate;
+ startDate = item.entryDate || dueDate;
+ if (!item.entryDate) {
+ if (returnDtstartOrDue) {
+ // DTSTART or DUE mandatory
+ return null;
+ }
+ // 3.6.2. To-do Component
+ // A "VTODO" calendar component without the "DTSTART" and "DUE" (or
+ // "DURATION") properties specifies a to-do that will be associated
+ // with each successive calendar date, until it is completed.
+ let completedDate = cal.dtz.ensureDateTime(item.completedDate);
+ dueDate = cal.dtz.ensureDateTime(dueDate);
+ return (
+ !completedDate ||
+ !queryStart ||
+ completedDate.compare(queryStart) > 0 ||
+ (dueDate && dueDate.compare(queryStart) >= 0)
+ );
+ }
+ endDate = dueDate || startDate;
+ }
+
+ let start = cal.dtz.ensureDateTime(startDate);
+ let end = cal.dtz.ensureDateTime(endDate);
+ let queryEnd = cal.dtz.ensureDateTime(rangeEnd);
+
+ if (start.compare(end) == 0) {
+ if (
+ (!queryStart || start.compare(queryStart) >= 0) &&
+ (!queryEnd || start.compare(queryEnd) < 0)
+ ) {
+ return startDate;
+ }
+ } else if (
+ (!queryEnd || start.compare(queryEnd) < 0) &&
+ (!queryStart || end.compare(queryStart) > 0)
+ ) {
+ return startDate;
+ }
+ return null;
+ },
+
+ setItemProperty(item, propertyName, aValue, aCapability) {
+ let isSupported =
+ item.calendar.getProperty("capabilities." + aCapability + ".supported") !== false;
+ let value = aCapability && !isSupported ? null : aValue;
+
+ switch (propertyName) {
+ case "startDate":
+ if (
+ (value.isDate && !item.startDate.isDate) ||
+ (!value.isDate && item.startDate.isDate) ||
+ !cal.data.compareObjects(value.timezone, item.startDate.timezone) ||
+ value.compare(item.startDate) != 0
+ ) {
+ item.startDate = value;
+ }
+ break;
+ case "endDate":
+ if (
+ (value.isDate && !item.endDate.isDate) ||
+ (!value.isDate && item.endDate.isDate) ||
+ !cal.data.compareObjects(value.timezone, item.endDate.timezone) ||
+ value.compare(item.endDate) != 0
+ ) {
+ item.endDate = value;
+ }
+ break;
+ case "entryDate":
+ if (value == item.entryDate) {
+ break;
+ }
+ if (
+ (value && !item.entryDate) ||
+ (!value && item.entryDate) ||
+ value.isDate != item.entryDate.isDate ||
+ !cal.data.compareObjects(value.timezone, item.entryDate.timezone) ||
+ value.compare(item.entryDate) != 0
+ ) {
+ item.entryDate = value;
+ }
+ break;
+ case "dueDate":
+ if (value == item.dueDate) {
+ break;
+ }
+ if (
+ (value && !item.dueDate) ||
+ (!value && item.dueDate) ||
+ value.isDate != item.dueDate.isDate ||
+ !cal.data.compareObjects(value.timezone, item.dueDate.timezone) ||
+ value.compare(item.dueDate) != 0
+ ) {
+ item.dueDate = value;
+ }
+ break;
+ case "isCompleted":
+ if (value != item.isCompleted) {
+ item.isCompleted = value;
+ }
+ break;
+ case "PERCENT-COMPLETE": {
+ let perc = parseInt(item.getProperty(propertyName), 10);
+ if (isNaN(perc)) {
+ perc = 0;
+ }
+ if (perc != value) {
+ item.setProperty(propertyName, value);
+ }
+ break;
+ }
+ case "title":
+ if (value != item.title) {
+ item.title = value;
+ }
+ break;
+ default:
+ if (!value || value == "") {
+ item.deleteProperty(propertyName);
+ } else if (item.getProperty(propertyName) != value) {
+ item.setProperty(propertyName, value);
+ }
+ break;
+ }
+ },
+
+ /**
+ * Returns the default transparency to apply for an event depending on whether its an all-day event
+ *
+ * @param aIsAllDay If true, the default transparency for all-day events is returned
+ */
+ getEventDefaultTransparency(aIsAllDay) {
+ let transp = null;
+ if (aIsAllDay) {
+ transp = Services.prefs.getBoolPref(
+ "calendar.events.defaultTransparency.allday.transparent",
+ false
+ )
+ ? "TRANSPARENT"
+ : "OPAQUE";
+ } else {
+ transp = Services.prefs.getBoolPref(
+ "calendar.events.defaultTransparency.standard.transparent",
+ false
+ )
+ ? "TRANSPARENT"
+ : "OPAQUE";
+ }
+ return transp;
+ },
+
+ /**
+ * Compare two items by *content*, leaving out any revision information such as
+ * X-MOZ-GENERATION, SEQUENCE, DTSTAMP, LAST-MODIFIED.
+
+ * The format for the parameters to ignore object is:
+ * { "PROPERTY-NAME": ["PARAM-NAME", ...] }
+ *
+ * If aIgnoreProps is not passed, these properties are ignored:
+ * X-MOZ-GENERATION, SEQUENCE, DTSTAMP, LAST-MODIFIED, X-MOZ-SEND-INVITATIONS
+ *
+ * If aIgnoreParams is not passed, these parameters are ignored:
+ * ATTENDEE: CN
+ * ORGANIZER: CN
+ *
+ * @param aFirstItem The item to compare.
+ * @param aSecondItem The item to compare to.
+ * @param aIgnoreProps (optional) An array of parameters to ignore.
+ * @param aIgnoreParams (optional) An object describing which parameters to
+ * ignore.
+ * @returns True, if items match.
+ */
+ compareContent(aFirstItem, aSecondItem, aIgnoreProps, aIgnoreParams) {
+ let ignoreProps = arr2hash(
+ aIgnoreProps || [
+ "SEQUENCE",
+ "DTSTAMP",
+ "LAST-MODIFIED",
+ "X-MOZ-GENERATION",
+ "X-MICROSOFT-DISALLOW-COUNTER",
+ "X-MOZ-SEND-INVITATIONS",
+ "X-MOZ-SEND-INVITATIONS-UNDISCLOSED",
+ ]
+ );
+
+ let ignoreParams = aIgnoreParams || { ATTENDEE: ["CN"], ORGANIZER: ["CN"] };
+ for (let x in ignoreParams) {
+ ignoreParams[x] = arr2hash(ignoreParams[x]);
+ }
+
+ function arr2hash(arr) {
+ let hash = {};
+ for (let x of arr) {
+ hash[x] = true;
+ }
+ return hash;
+ }
+
+ // This doesn't have to be super correct rfc5545, it just needs to be
+ // in the same order
+ function normalizeComponent(comp) {
+ let props = [];
+ for (let prop of cal.iterate.icalProperty(comp)) {
+ if (!(prop.propertyName in ignoreProps)) {
+ props.push(normalizeProperty(prop));
+ }
+ }
+ props = props.sort();
+
+ let comps = [];
+ for (let subcomp of cal.iterate.icalSubcomponent(comp)) {
+ comps.push(normalizeComponent(subcomp));
+ }
+ comps = comps.sort();
+
+ return comp.componentType + props.join("\r\n") + comps.join("\r\n");
+ }
+
+ function normalizeProperty(prop) {
+ let params = [...cal.iterate.icalParameter(prop)]
+ .filter(
+ ([k, v]) =>
+ !(prop.propertyName in ignoreParams) || !(k in ignoreParams[prop.propertyName])
+ )
+ .map(([k, v]) => k + "=" + v)
+ .sort();
+
+ return prop.propertyName + ";" + params.join(";") + ":" + prop.valueAsIcalString;
+ }
+
+ return (
+ normalizeComponent(aFirstItem.icalComponent) == normalizeComponent(aSecondItem.icalComponent)
+ );
+ },
+
+ /**
+ * Shifts an item by the given timely offset.
+ *
+ * @param item an item
+ * @param offset an offset (calIDuration)
+ */
+ shiftOffset(item, offset) {
+ // When modifying dates explicitly using the setters is important
+ // since those may triggers e.g. calIRecurrenceInfo::onStartDateChange
+ // or invalidate other properties. Moreover don't modify the date-time objects
+ // without cloning, because changes cannot be calculated if doing so.
+ if (item.isEvent()) {
+ let date = item.startDate.clone();
+ date.addDuration(offset);
+ item.startDate = date;
+ date = item.endDate.clone();
+ date.addDuration(offset);
+ item.endDate = date;
+ } else {
+ /* isToDo */
+ if (item.entryDate) {
+ let date = item.entryDate.clone();
+ date.addDuration(offset);
+ item.entryDate = date;
+ }
+ if (item.dueDate) {
+ let date = item.dueDate.clone();
+ date.addDuration(offset);
+ item.dueDate = date;
+ }
+ }
+ },
+
+ /**
+ * moves an item to another startDate
+ *
+ * @param aOldItem The Item to be modified
+ * @param aNewDate The date at which the new item is going to start
+ * @returns The modified item
+ */
+ moveToDate(aOldItem, aNewDate) {
+ let newItem = aOldItem.clone();
+ let start = (
+ aOldItem[cal.dtz.startDateProp(aOldItem)] || aOldItem[cal.dtz.endDateProp(aOldItem)]
+ ).clone();
+ let isDate = start.isDate;
+ start.resetTo(
+ aNewDate.year,
+ aNewDate.month,
+ aNewDate.day,
+ start.hour,
+ start.minute,
+ start.second,
+ start.timezone
+ );
+ start.isDate = isDate;
+ if (newItem[cal.dtz.startDateProp(newItem)]) {
+ newItem[cal.dtz.startDateProp(newItem)] = start;
+ let oldDuration = aOldItem.duration;
+ if (oldDuration) {
+ let oldEnd = aOldItem[cal.dtz.endDateProp(aOldItem)];
+ let newEnd = start.clone();
+ newEnd.addDuration(oldDuration);
+ newEnd = newEnd.getInTimezone(oldEnd.timezone);
+ newItem[cal.dtz.endDateProp(newItem)] = newEnd;
+ }
+ } else if (newItem[cal.dtz.endDateProp(newItem)]) {
+ newItem[cal.dtz.endDateProp(newItem)] = start;
+ }
+ return newItem;
+ },
+
+ /**
+ * Shortcut function to serialize an item (including all overridden items).
+ */
+ serialize(aItem) {
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem]);
+ return serializer.serializeToString();
+ },
+
+ /**
+ * Centralized functions for accessing prodid and version
+ */
+ get productId() {
+ return "-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN";
+ },
+ get productVersion() {
+ return "2.0";
+ },
+
+ /**
+ * This is a centralized function for setting the prodid and version on an
+ * ical component. This should be used whenever you need to set the prodid
+ * and version on a calIcalComponent object.
+ *
+ * @param aIcalComponent The ical component to set the prodid and
+ * version on.
+ */
+ setStaticProps(aIcalComponent) {
+ // Throw for an invalid parameter
+ if (!aIcalComponent) {
+ throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG);
+ }
+ // Set the prodid and version
+ aIcalComponent.prodid = calitem.productId;
+ aIcalComponent.version = calitem.productVersion;
+ },
+
+ /**
+ * Search for already open item dialog.
+ *
+ * @param aItem The item of the dialog to search for.
+ */
+ findWindow(aItem) {
+ // check for existing dialog windows
+ for (let dlg of Services.wm.getEnumerator("Calendar:EventDialog")) {
+ if (
+ dlg.arguments[0] &&
+ dlg.arguments[0].mode == "modify" &&
+ dlg.arguments[0].calendarEvent &&
+ dlg.arguments[0].calendarEvent.hashId == aItem.hashId
+ ) {
+ return dlg;
+ }
+ }
+ // check for existing summary windows
+ for (let dlg of Services.wm.getEnumerator("Calendar:EventSummaryDialog")) {
+ if (dlg.calendarItem && dlg.calendarItem.hashId == aItem.hashId) {
+ return dlg;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * sets the 'isDate' property of an item
+ *
+ * @param aItem The Item to be modified
+ * @param aIsDate True or false indicating the new value of 'isDate'
+ * @returns The modified item
+ */
+ setToAllDay(aItem, aIsDate) {
+ let start = aItem[cal.dtz.startDateProp(aItem)];
+ let end = aItem[cal.dtz.endDateProp(aItem)];
+ if (start || end) {
+ let item = aItem.clone();
+ if (start && start.isDate != aIsDate) {
+ start = start.clone();
+ start.isDate = aIsDate;
+ item[cal.dtz.startDateProp(item)] = start;
+ }
+ if (end && end.isDate != aIsDate) {
+ end = end.clone();
+ end.isDate = aIsDate;
+ item[cal.dtz.endDateProp(item)] = end;
+ }
+ return item;
+ }
+ return aItem;
+ },
+
+ /**
+ * This function return the progress state of a task:
+ * completed, overdue, duetoday, inprogress, future
+ *
+ * @param aTask The task to check.
+ * @returns The progress atom.
+ */
+ getProgressAtom(aTask) {
+ let nowdate = new Date();
+
+ if (aTask.recurrenceInfo) {
+ return "repeating";
+ }
+
+ if (aTask.isCompleted) {
+ return "completed";
+ }
+
+ if (aTask.dueDate && aTask.dueDate.isValid) {
+ if (cal.dtz.dateTimeToJsDate(aTask.dueDate).getTime() < nowdate.getTime()) {
+ return "overdue";
+ } else if (
+ aTask.dueDate.year == nowdate.getFullYear() &&
+ aTask.dueDate.month == nowdate.getMonth() &&
+ aTask.dueDate.day == nowdate.getDate()
+ ) {
+ return "duetoday";
+ }
+ }
+
+ if (
+ aTask.entryDate &&
+ aTask.entryDate.isValid &&
+ cal.dtz.dateTimeToJsDate(aTask.entryDate).getTime() < nowdate.getTime()
+ ) {
+ return "inprogress";
+ }
+
+ return "future";
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calIteratorUtils.jsm b/comm/calendar/base/modules/utils/calIteratorUtils.jsm
new file mode 100644
index 0000000000..2ccae6b042
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calIteratorUtils.jsm
@@ -0,0 +1,279 @@
+/* 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/. */
+
+/**
+ * Iterators for various data structures
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.iterate namespace.
+
+const EXPORTED_SYMBOLS = ["caliterate"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var caliterate = {
+ /**
+ * Iterates an array of items, i.e. the passed item including all
+ * overridden instances of a recurring series.
+ *
+ * @param {calIItemBase[]} items - array of items to iterate
+ * @yields {calIItemBase}
+ */
+ *items(items) {
+ for (let item of items) {
+ yield item;
+ let rec = item.recurrenceInfo;
+ if (rec) {
+ for (let exid of rec.getExceptionIds()) {
+ yield rec.getExceptionFor(exid);
+ }
+ }
+ }
+ },
+
+ /**
+ * Runs the body() function once for each item in the iterator using the event queue to make
+ * sure other actions could run in between. When all iterations are done (and also when
+ * cal.iterate.forEach.BREAK is returned), calls the completed() function if passed.
+ *
+ * If you would like to break or continue inside the body(), return either
+ * cal.iterate.forEach.BREAK or cal.iterate.forEach.CONTINUE
+ *
+ * Note since the event queue is used, this function will return immediately, before the
+ * iteration is complete. If you need to run actions after the real for each loop, use the
+ * optional completed() function.
+ *
+ * @param {Iterable} iterable - The Iterator or the plain Object to go through in this loop.
+ * @param {Function} body - The function called for each iteration. Its parameter is the
+ * single item from the iterator.
+ * @param {?Function} completed - [optional] The function called after the loop completes.
+ */
+ forEach: (() => {
+ // eslint-disable-next-line require-jsdoc
+ function forEach(iterable, body, completed = null) {
+ // This should be a const one day, lets keep it a pref for now though until we
+ // find a sane value.
+ let LATENCY = Services.prefs.getIntPref("calendar.threading.latency", 250);
+
+ if (typeof iterable == "object" && !iterable[Symbol.iterator]) {
+ iterable = Object.entries(iterable);
+ }
+
+ let ourIter = iterable[Symbol.iterator]();
+ let currentThread = Services.tm.currentThread;
+
+ // This is our dispatcher, it will be used for the iterations
+ let dispatcher = {
+ run() {
+ let startTime = new Date().getTime();
+ while (new Date().getTime() - startTime < LATENCY) {
+ let next = ourIter.next();
+ let done = next.done;
+
+ if (!done) {
+ let rc = body(next.value);
+ if (rc == lazy.cal.iterate.forEach.BREAK) {
+ done = true;
+ }
+ }
+
+ if (done) {
+ if (completed) {
+ completed();
+ }
+ return;
+ }
+ }
+
+ currentThread.dispatch(this, currentThread.DISPATCH_NORMAL);
+ },
+ };
+
+ currentThread.dispatch(dispatcher, currentThread.DISPATCH_NORMAL);
+ }
+ forEach.CONTINUE = 1;
+ forEach.BREAK = 2;
+
+ return forEach;
+ })(),
+
+ /**
+ * Yields all subcomponents in all calendars in the passed component.
+ * - If the passed component is an XROOT (contains multiple calendars), then go through all
+ * VCALENDARs in it and get their subcomponents.
+ * - If the passed component is a VCALENDAR, iterate through its direct subcomponents.
+ * - Otherwise assume the passed component is the item itself and yield only the passed
+ * component.
+ *
+ * This iterator can only be used in a for..of block:
+ * for (let component of cal.iterate.icalComponent(aComp)) { ... }
+ *
+ * @param {calIIcalComponent} aComponent The component to iterate given the above rules.
+ * @param {string} aCompType The type of item to iterate.
+ * @yields {calIIcalComponent} The iterator that yields all items.
+ */
+ *icalComponent(aComponent, aCompType = "ANY") {
+ if (aComponent && aComponent.componentType == "VCALENDAR") {
+ yield* lazy.cal.iterate.icalSubcomponent(aComponent, aCompType);
+ } else if (aComponent && aComponent.componentType == "XROOT") {
+ for (let calComp of lazy.cal.iterate.icalSubcomponent(aComponent, "VCALENDAR")) {
+ yield* lazy.cal.iterate.icalSubcomponent(calComp, aCompType);
+ }
+ } else if (aComponent && (aCompType == "ANY" || aCompType == aComponent.componentType)) {
+ yield aComponent;
+ }
+ },
+
+ /**
+ * Use to iterate through all subcomponents of a calIIcalComponent. This iterators depth is 1,
+ * this means no sub-sub-components will be iterated.
+ *
+ * This iterator can only be used in a for() block:
+ * for (let component of cal.iterate.icalSubcomponent(aComp)) { ... }
+ *
+ * @param {calIIcalComponent} aComponent - The component who's subcomponents to iterate.
+ * @param {?string} aSubcomp - (optional) the specific subcomponent to enumerate.
+ * If not given, "ANY" will be used.
+ * @yields {calIIcalComponent} An iterator object to iterate the properties.
+ */
+ *icalSubcomponent(aComponent, aSubcomp = "ANY") {
+ for (
+ let subcomp = aComponent.getFirstSubcomponent(aSubcomp);
+ subcomp;
+ subcomp = aComponent.getNextSubcomponent(aSubcomp)
+ ) {
+ yield subcomp;
+ }
+ },
+
+ /**
+ * Use to iterate through all properties of a calIIcalComponent.
+ * This iterator can only be used in a for() block:
+ * for (let property of cal.iterate.icalProperty(aComp)) { ... }
+ *
+ * @param {calIIcalComponent} aComponent - The component to iterate.
+ * @param {?string} aProperty - (optional) the specific property to enumerate.
+ * If not given, "ANY" will be used.
+ * @yields {calIIcalProperty} An iterator object to iterate the properties.
+ */
+ *icalProperty(aComponent, aProperty = "ANY") {
+ for (
+ let prop = aComponent.getFirstProperty(aProperty);
+ prop;
+ prop = aComponent.getNextProperty(aProperty)
+ ) {
+ yield prop;
+ }
+ },
+
+ /**
+ * Use to iterate through all parameters of a calIIcalProperty.
+ * This iterator behaves similar to the object iterator. Possible uses:
+ * for (let paramName in cal.iterate.icalParameter(prop)) { ... }
+ * or:
+ * for (let [paramName, paramValue] of cal.iterate.icalParameter(prop)) { ... }
+ *
+ * @param {calIIcalProperty} aProperty - The property to iterate.
+ * @yields {[String, String]} An iterator object to iterate the properties.
+ */
+ *icalParameter(aProperty) {
+ let paramSet = new Set();
+ for (
+ let paramName = aProperty.getFirstParameterName();
+ paramName;
+ paramName = aProperty.getNextParameterName()
+ ) {
+ // Workaround to avoid infinite loop when the property
+ // contains duplicate parameters (bug 875739 for libical)
+ if (!paramSet.has(paramName)) {
+ yield [paramName, aProperty.getParameter(paramName)];
+ paramSet.add(paramName);
+ }
+ }
+ },
+
+ /**
+ * A function used to transform items received from a ReadableStream of
+ * calIItemBase instances.
+ *
+ * @callback MapStreamFunction
+ * @param {calIItemBase[]} chunk
+ *
+ * @returns {*[]|Promise<*[]>}
+ */
+
+ /**
+ * Applies the provided MapStreamFunction to each chunk received from a
+ * ReadableStream of calIItemBase instances providing the results as a single
+ * array.
+ *
+ * @param {ReadableStream} stream
+ * @param {MapStreamFunction} func
+ *
+ * @returns {*[]}
+ */
+ async mapStream(stream, func) {
+ let buffer = [];
+ for await (let value of caliterate.streamValues(stream)) {
+ buffer.push.apply(buffer, await func(value));
+ }
+ return buffer;
+ },
+ /**
+ * Converts a ReadableStream of calIItemBase into an array.
+ *
+ * @param {ReadableStream} stream
+ *
+ * @returns {calIItemBase[]}
+ */
+ async streamToArray(stream) {
+ return caliterate.mapStream(stream, chunk => chunk);
+ },
+
+ /**
+ * Provides an async iterator for the target stream allowing its values to
+ * be extracted in a for of loop.
+ *
+ * @param {ReadableStream} stream
+ *
+ * @returns {CalReadableStreamIterator}
+ */
+ streamValues(stream) {
+ return new CalReadableStreamIterator(stream);
+ },
+};
+
+/**
+ * An async iterator implementation for streams returned from getItems() and
+ * similar calls. This class can be used in a for await ... loop to extract
+ * the values of a stream.
+ */
+class CalReadableStreamIterator {
+ _stream = null;
+ _reader = null;
+
+ /**
+ * @param {ReadableStream} stream
+ */
+ constructor(stream) {
+ this._stream = stream;
+ }
+
+ [Symbol.asyncIterator]() {
+ this._reader = this._stream.getReader();
+ return this;
+ }
+
+ /**
+ * Cancels the reading of values from the underlying stream's reader.
+ */
+ async cancel() {
+ return this._reader && this._reader.cancel();
+ }
+ async next() {
+ return this._reader.read();
+ }
+}
diff --git a/comm/calendar/base/modules/utils/calItipUtils.jsm b/comm/calendar/base/modules/utils/calItipUtils.jsm
new file mode 100644
index 0000000000..fdfe8750c6
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calItipUtils.jsm
@@ -0,0 +1,2181 @@
+/* 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/. */
+
+/**
+ * Scheduling and iTIP helper code
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.itip namespace.
+
+const EXPORTED_SYMBOLS = ["calitip"];
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { calendarDeactivator } = ChromeUtils.import(
+ "resource:///modules/calendar/calCalendarDeactivator.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+ CalItipDefaultEmailTransport: "resource:///modules/CalItipEmailTransport.jsm",
+ CalItipMessageSender: "resource:///modules/CalItipMessageSender.jsm",
+ CalItipOutgoingMessage: "resource:///modules/CalItipOutgoingMessage.jsm",
+});
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calitip = {
+ /**
+ * Gets the sequence/revision number, either of the passed item or the last received one of an
+ * attendee; see <http://tools.ietf.org/html/draft-desruisseaux-caldav-sched-04#section-7.1>.
+ *
+ * @param {calIAttendee|calIItemBase} aItem - The item or attendee to get the sequence info
+ * from.
+ * @returns {number} The sequence number
+ */
+ getSequence(aItem) {
+ let seq = null;
+
+ if (calitip.isAttendee(aItem)) {
+ seq = aItem.getProperty("RECEIVED-SEQUENCE");
+ } else if (aItem) {
+ // Unless the below is standardized, we store the last original
+ // REQUEST/PUBLISH SEQUENCE in X-MOZ-RECEIVED-SEQUENCE to test against it
+ // when updates come in:
+ seq = aItem.getProperty("X-MOZ-RECEIVED-SEQUENCE");
+ if (seq === null) {
+ seq = aItem.getProperty("SEQUENCE");
+ }
+
+ // Make sure we don't have a pre Outlook 2007 appointment, but if we do
+ // use Microsoft's Sequence number. I <3 MS
+ if (seq === null || seq == "0") {
+ seq = aItem.getProperty("X-MICROSOFT-CDO-APPT-SEQUENCE");
+ }
+ }
+
+ if (seq === null) {
+ return 0;
+ }
+ seq = parseInt(seq, 10);
+ return isNaN(seq) ? 0 : seq;
+ },
+
+ /**
+ * Gets the stamp date-time, either of the passed item or the last received one of an attendee;
+ * see <http://tools.ietf.org/html/draft-desruisseaux-caldav-sched-04#section-7.2>.
+ *
+ * @param {calIAttendee|calIItemBase} aItem - The item or attendee to retrieve the stamp from
+ * @returns {calIDateTime} The timestamp for the item
+ */
+ getStamp(aItem) {
+ let dtstamp = null;
+
+ if (calitip.isAttendee(aItem)) {
+ let stamp = aItem.getProperty("RECEIVED-DTSTAMP");
+ if (stamp) {
+ dtstamp = lazy.cal.createDateTime(stamp);
+ }
+ } else if (aItem) {
+ // Unless the below is standardized, we store the last original
+ // REQUEST/PUBLISH DTSTAMP in X-MOZ-RECEIVED-DTSTAMP to test against it
+ // when updates come in:
+ let stamp = aItem.getProperty("X-MOZ-RECEIVED-DTSTAMP");
+ if (stamp) {
+ dtstamp = lazy.cal.createDateTime(stamp);
+ } else {
+ // xxx todo: are there similar X-MICROSOFT-CDO properties to be considered here?
+ dtstamp = aItem.stampTime;
+ }
+ }
+
+ return dtstamp;
+ },
+
+ /**
+ * Compares sequences and/or stamps of two items
+ *
+ * @param {calIItemBase|calIAttendee} aItem1 - The first item to compare
+ * @param {calIItemBase|calIAttendee} aItem2 - The second item to compare
+ * @returns {number} +1 if item2 is newer, -1 if item1 is newer
+ * or 0 if both are equal
+ */
+ compare(aItem1, aItem2) {
+ let comp = calitip.compareSequence(aItem1, aItem2);
+ if (comp == 0) {
+ comp = calitip.compareStamp(aItem1, aItem2);
+ }
+ return comp;
+ },
+
+ /**
+ * Compares sequences of two items
+ *
+ * @param {calIItemBase|calIAttendee} aItem1 - The first item to compare
+ * @param {calIItemBase|calIAttendee} aItem2 - The second item to compare
+ * @returns {number} +1 if item2 is newer, -1 if item1 is newer
+ * or 0 if both are equal
+ */
+ compareSequence(aItem1, aItem2) {
+ let seq1 = calitip.getSequence(aItem1);
+ let seq2 = calitip.getSequence(aItem2);
+ if (seq1 > seq2) {
+ return 1;
+ } else if (seq1 < seq2) {
+ return -1;
+ }
+ return 0;
+ },
+
+ /**
+ * Compares stamp of two items
+ *
+ * @param {calIItemBase|calIAttendee} aItem1 - The first item to compare
+ * @param {calIItemBase|calIAttendee} aItem2 - The second item to compare
+ * @returns {number} +1 if item2 is newer, -1 if item1 is newer
+ * or 0 if both are equal
+ */
+ compareStamp(aItem1, aItem2) {
+ let st1 = calitip.getStamp(aItem1);
+ let st2 = calitip.getStamp(aItem2);
+ if (st1 && st2) {
+ return st1.compare(st2);
+ } else if (!st1 && st2) {
+ return -1;
+ } else if (st1 && !st2) {
+ return 1;
+ }
+ return 0;
+ },
+
+ /**
+ * Creates an organizer calIAttendee object based on the calendar's configured organizer id.
+ *
+ * @param {calICalendar} aCalendar - The calendar to get the organizer id from
+ * @returns {calIAttendee} The organizer attendee
+ */
+ createOrganizer(aCalendar) {
+ let orgId = aCalendar.getProperty("organizerId");
+ if (!orgId) {
+ return null;
+ }
+ let organizer = new lazy.CalAttendee();
+ organizer.id = orgId;
+ organizer.commonName = aCalendar.getProperty("organizerCN");
+ organizer.role = "REQ-PARTICIPANT";
+ organizer.participationStatus = "ACCEPTED";
+ organizer.isOrganizer = true;
+ return organizer;
+ },
+
+ /**
+ * Checks if the given calendar is a scheduling calendar. This means it
+ * needs an organizer id and an itip transport. It should also be writable.
+ *
+ * @param {calICalendar} aCalendar - The calendar to check
+ * @returns {boolean} True, if its a scheduling calendar.
+ */
+ isSchedulingCalendar(aCalendar) {
+ return (
+ lazy.cal.acl.isCalendarWritable(aCalendar) &&
+ aCalendar.getProperty("organizerId") &&
+ aCalendar.getProperty("itip.transport")
+ );
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Given an nsIMsgDBHdr and an imipMethod, set up the given itip item.
+ *
+ * @param {calIItemBase} itipItem - The item to set up
+ * @param {string} imipMethod - The received imip method
+ * @param {nsIMsgDBHdr} aMsgHdr - Information about the received email
+ */
+ initItemFromMsgData(itipItem, imipMethod, aMsgHdr) {
+ // set the sender of the itip message
+ itipItem.sender = calitip.getMessageSender(aMsgHdr);
+
+ // Get the recipient identity and save it with the itip item.
+ itipItem.identity = calitip.getMessageRecipient(aMsgHdr);
+
+ // We are only called upon receipt of an invite, so ensure that isSend
+ // is false.
+ itipItem.isSend = false;
+
+ // XXX Get these from preferences
+ itipItem.autoResponse = Ci.calIItipItem.USER;
+
+ if (imipMethod && imipMethod.length != 0 && imipMethod.toLowerCase() != "nomethod") {
+ itipItem.receivedMethod = imipMethod.toUpperCase();
+ } else {
+ // There is no METHOD in the content-type header (spec violation).
+ // Fall back to using the one from the itipItem's ICS.
+ imipMethod = itipItem.receivedMethod;
+ }
+ lazy.cal.LOG("iTIP method: " + imipMethod);
+
+ let isWritableCalendar = function (aCalendar) {
+ /* TODO: missing ACL check for existing items (require callback API) */
+ return (
+ calitip.isSchedulingCalendar(aCalendar) && lazy.cal.acl.userCanAddItemsToCalendar(aCalendar)
+ );
+ };
+
+ let writableCalendars = lazy.cal.manager.getCalendars().filter(isWritableCalendar);
+ if (writableCalendars.length > 0) {
+ let compCal = Cc["@mozilla.org/calendar/calendar;1?type=composite"].createInstance(
+ Ci.calICompositeCalendar
+ );
+ writableCalendars.forEach(compCal.addCalendar, compCal);
+ itipItem.targetCalendar = compCal;
+ }
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Gets the suggested text to be shown when an imip item has been processed.
+ * This text is ready localized and can be displayed to the user.
+ *
+ * @param {number} aStatus - The status of the processing (i.e NS_OK, an error code)
+ * @param {number} aOperationType - An operation type from calIOperationListener
+ * @returns {string} The suggested text.
+ */
+ getCompleteText(aStatus, aOperationType) {
+ let text = "";
+ const cIOL = Ci.calIOperationListener;
+ if (Components.isSuccessCode(aStatus)) {
+ switch (aOperationType) {
+ case cIOL.ADD:
+ text = lazy.cal.l10n.getLtnString("imipAddedItemToCal2");
+ break;
+ case cIOL.MODIFY:
+ text = lazy.cal.l10n.getLtnString("imipUpdatedItem2");
+ break;
+ case cIOL.DELETE:
+ text = lazy.cal.l10n.getLtnString("imipCanceledItem2");
+ break;
+ }
+ } else {
+ text = lazy.cal.l10n.getLtnString("imipBarProcessingFailed", [aStatus.toString(16)]);
+ }
+ return text;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Gets a text describing the given itip method. The text is of the form
+ * "This Message contains a ... ".
+ *
+ * @param {string} method - The method to describe.
+ * @returns {string} The localized text about the method.
+ */
+ getMethodText(method) {
+ switch (method) {
+ case "REFRESH":
+ return lazy.cal.l10n.getLtnString("imipBarRefreshText");
+ case "REQUEST":
+ return lazy.cal.l10n.getLtnString("imipBarRequestText");
+ case "PUBLISH":
+ return lazy.cal.l10n.getLtnString("imipBarPublishText");
+ case "CANCEL":
+ return lazy.cal.l10n.getLtnString("imipBarCancelText");
+ case "REPLY":
+ return lazy.cal.l10n.getLtnString("imipBarReplyText");
+ case "COUNTER":
+ return lazy.cal.l10n.getLtnString("imipBarCounterText");
+ case "DECLINECOUNTER":
+ return lazy.cal.l10n.getLtnString("imipBarDeclineCounterText");
+ default:
+ lazy.cal.ERROR("Unknown iTIP method: " + method);
+ let appName = lazy.cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ return lazy.cal.l10n.getLtnString("imipBarUnsupportedText2", [appName]);
+ }
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Gets localized toolbar label about the message state and triggers buttons to show.
+ * This returns a JS object with the following structure:
+ *
+ * {
+ * label: "This is a desciptive text about the itip item",
+ * showItems: ["imipXXXButton", ...],
+ * hideItems: ["imipXXXButton_Option", ...]
+ * }
+ *
+ * @see processItipItem This takes the same parameters as its optionFunc.
+ * @param {calIItipItem} itipItem - The itipItem to query.
+ * @param {number} rc - The result of retrieving the item
+ * @param {Function} actionFunc - The action function.
+ * @param {calIItemBase[]} foundItems - An array of items found while searching for the item
+ * in subscribed calendars
+ * @returns {object} Return information about the options
+ */
+ getOptionsText(itipItem, rc, actionFunc, foundItems) {
+ let imipLabel = null;
+ if (itipItem.receivedMethod) {
+ imipLabel = calitip.getMethodText(itipItem.receivedMethod);
+ }
+ let data = { label: imipLabel, showItems: [], hideItems: [] };
+ let separateButtons = Services.prefs.getBoolPref(
+ "calendar.itip.separateInvitationButtons",
+ false
+ );
+
+ let disallowedCounter = false;
+ if (foundItems && foundItems.length) {
+ let disallow = foundItems[0].getProperty("X-MICROSOFT-DISALLOW-COUNTER");
+ disallowedCounter = disallow && disallow == "TRUE";
+ }
+ if (!calendarDeactivator.isCalendarActivated) {
+ // Calendar is deactivated (no calendars are enabled).
+ data.label = lazy.cal.l10n.getLtnString("imipBarCalendarDeactivated");
+ data.showItems.push("imipGoToCalendarButton", "imipMoreButton");
+ data.hideItems.push("imipMoreButton_SaveCopy");
+ } else if (rc == Ci.calIErrors.CAL_IS_READONLY) {
+ // No writable calendars, tell the user about it
+ data.label = lazy.cal.l10n.getLtnString("imipBarNotWritable");
+ data.showItems.push("imipGoToCalendarButton", "imipMoreButton");
+ data.hideItems.push("imipMoreButton_SaveCopy");
+ } else if (Components.isSuccessCode(rc) && !actionFunc) {
+ // This case, they clicked on an old message that has already been
+ // added/updated, we want to tell them that.
+ data.label = lazy.cal.l10n.getLtnString("imipBarAlreadyProcessedText");
+ if (foundItems && foundItems.length) {
+ data.showItems.push("imipDetailsButton");
+ if (itipItem.receivedMethod == "COUNTER" && itipItem.sender) {
+ if (disallowedCounter) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarDisallowedCounterText");
+ } else {
+ let comparison;
+ for (let item of itipItem.getItemList()) {
+ let attendees = lazy.cal.itip.getAttendeesBySender(
+ item.getAttendees(),
+ itipItem.sender
+ );
+ if (attendees.length == 1) {
+ comparison = calitip.compareSequence(item, foundItems[0]);
+ if (comparison == 1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarCounterErrorText");
+ break;
+ } else if (comparison == -1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarCounterPreviousVersionText");
+ }
+ }
+ }
+ }
+ }
+ } else if (itipItem.receivedMethod == "REPLY") {
+ // The item has been previously removed from the available calendars or the calendar
+ // containing the item is not available
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ );
+ let delTime = null;
+ let items = itipItem.getItemList();
+ if (items && items.length) {
+ delTime = delmgr.getDeletedDate(items[0].id);
+ }
+ if (delTime) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarReplyToRecentlyRemovedItem", [
+ lazy.cal.dtz.formatter.formatTime(delTime),
+ ]);
+ } else {
+ data.label = lazy.cal.l10n.getLtnString("imipBarReplyToNotExistingItem");
+ }
+ } else if (itipItem.receivedMethod == "DECLINECOUNTER") {
+ data.label = lazy.cal.l10n.getLtnString("imipBarDeclineCounterText");
+ }
+ } else if (Components.isSuccessCode(rc)) {
+ lazy.cal.LOG("iTIP options on: " + actionFunc.method);
+ switch (actionFunc.method) {
+ case "PUBLISH:UPDATE":
+ case "REQUEST:UPDATE-MINOR":
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateText");
+ // falls through
+ case "REPLY":
+ data.showItems.push("imipUpdateButton");
+ break;
+ case "PUBLISH":
+ data.showItems.push("imipAddButton");
+ break;
+ case "REQUEST:UPDATE":
+ case "REQUEST:NEEDS-ACTION":
+ case "REQUEST": {
+ let isRecurringMaster = false;
+ for (let item of itipItem.getItemList()) {
+ if (item.recurrenceInfo) {
+ isRecurringMaster = true;
+ }
+ }
+
+ if (actionFunc.method == "REQUEST:UPDATE") {
+ if (isRecurringMaster) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateSeriesText");
+ } else if (itipItem.getItemList().length > 1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateMultipleText");
+ } else {
+ data.label = lazy.cal.l10n.getLtnString("imipBarUpdateText");
+ }
+ } else if (actionFunc.method == "REQUEST:NEEDS-ACTION") {
+ if (isRecurringMaster) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarProcessedSeriesNeedsAction");
+ } else if (itipItem.getItemList().length > 1) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarProcessedMultipleNeedsAction");
+ } else {
+ data.label = lazy.cal.l10n.getLtnString("imipBarProcessedNeedsAction");
+ }
+ }
+
+ if (itipItem.getItemList().length > 1 || isRecurringMaster) {
+ data.showItems.push("imipAcceptRecurrencesButton");
+ if (separateButtons) {
+ data.showItems.push("imipTentativeRecurrencesButton");
+ data.hideItems.push("imipAcceptRecurrencesButton_AcceptLabel");
+ data.hideItems.push("imipAcceptRecurrencesButton_TentativeLabel");
+ data.hideItems.push("imipAcceptRecurrencesButton_Tentative");
+ data.hideItems.push("imipAcceptRecurrencesButton_TentativeDontSend");
+ } else {
+ data.hideItems.push("imipTentativeRecurrencesButton");
+ data.showItems.push("imipAcceptRecurrencesButton_AcceptLabel");
+ data.showItems.push("imipAcceptRecurrencesButton_TentativeLabel");
+ data.showItems.push("imipAcceptRecurrencesButton_Tentative");
+ data.showItems.push("imipAcceptRecurrencesButton_TentativeDontSend");
+ }
+ data.showItems.push("imipDeclineRecurrencesButton");
+ } else {
+ data.showItems.push("imipAcceptButton");
+ if (separateButtons) {
+ data.showItems.push("imipTentativeButton");
+ data.hideItems.push("imipAcceptButton_AcceptLabel");
+ data.hideItems.push("imipAcceptButton_TentativeLabel");
+ data.hideItems.push("imipAcceptButton_Tentative");
+ data.hideItems.push("imipAcceptButton_TentativeDontSend");
+ } else {
+ data.hideItems.push("imipTentativeButton");
+ data.showItems.push("imipAcceptButton_AcceptLabel");
+ data.showItems.push("imipAcceptButton_TentativeLabel");
+ data.showItems.push("imipAcceptButton_Tentative");
+ data.showItems.push("imipAcceptButton_TentativeDontSend");
+ }
+ data.showItems.push("imipDeclineButton");
+ }
+ data.showItems.push("imipMoreButton");
+ // Use data.hideItems.push("idOfMenuItem") to hide specific menuitems
+ // from the dropdown menu of a button. This might be useful to remove
+ // a generally available option for a specific invitation, because the
+ // respective feature is not available for the calendar, the invitation
+ // is in or the feature is prohibited by the organizer
+ break;
+ }
+ case "CANCEL": {
+ data.showItems.push("imipDeleteButton");
+ break;
+ }
+ case "REFRESH": {
+ data.showItems.push("imipReconfirmButton");
+ break;
+ }
+ case "COUNTER": {
+ if (disallowedCounter) {
+ data.label = lazy.cal.l10n.getLtnString("imipBarDisallowedCounterText");
+ }
+ data.showItems.push("imipDeclineCounterButton");
+ data.showItems.push("imipRescheduleButton");
+ break;
+ }
+ default:
+ let appName = lazy.cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ data.label = lazy.cal.l10n.getLtnString("imipBarUnsupportedText2", [appName]);
+ break;
+ }
+ } else {
+ let appName = lazy.cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ data.label = lazy.cal.l10n.getLtnString("imipBarUnsupportedText2", [appName]);
+ }
+
+ return data;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ * Retrieves the message sender.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message header to check.
+ * @returns {string} The email address of the intended recipient.
+ */
+ getMessageSender(aMsgHdr) {
+ let author = (aMsgHdr && aMsgHdr.author) || "";
+ let compFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ let addresses = compFields.splitRecipients(author, true);
+ if (addresses.length != 1) {
+ lazy.cal.LOG("No unique email address for lookup in message.\r\n" + lazy.cal.STACK(20));
+ }
+ return addresses[0] || null;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Retrieves the intended recipient for this message.
+ *
+ * @param {nsIMsgDBHdr} aMsgHdr - The message to check.
+ * @returns {string} The email of the intended recipient.
+ */
+ getMessageRecipient(aMsgHdr) {
+ if (!aMsgHdr) {
+ return null;
+ }
+
+ let identities;
+ if (aMsgHdr.accountKey) {
+ // First, check if the message has an account key. If so, we can use the
+ // account identities to find the correct recipient
+ identities = MailServices.accounts.getAccount(aMsgHdr.accountKey).identities;
+ } else if (aMsgHdr.folder) {
+ // Without an account key, we have to revert back to using the server
+ identities = MailServices.accounts.getIdentitiesForServer(aMsgHdr.folder.server);
+ }
+
+ let emailMap = {};
+ if (!identities || identities.length == 0) {
+ let identity;
+ // If we were not able to retrieve identities above, then we have no
+ // choice but to revert to the default identity.
+ let defaultAccount = MailServices.accounts.defaultAccount;
+ if (defaultAccount) {
+ identity = defaultAccount.defaultIdentity;
+ }
+ if (!identity) {
+ // If there isn't a default identity (i.e Local Folders is your
+ // default identity), then go ahead and use the first available
+ // identity.
+ let allIdentities = MailServices.accounts.allIdentities;
+ if (allIdentities.length > 0) {
+ identity = allIdentities[0];
+ } else {
+ // If there are no identities at all, we cannot get a recipient.
+ return null;
+ }
+ }
+ emailMap[identity.email.toLowerCase()] = true;
+ } else {
+ // Build a map of usable email addresses
+ for (let identity of identities) {
+ emailMap[identity.email.toLowerCase()] = true;
+ }
+ }
+
+ // First check the recipient list
+ let toList = MailServices.headerParser.makeFromDisplayAddress(aMsgHdr.recipients || "");
+ for (let recipient of toList) {
+ if (recipient.email.toLowerCase() in emailMap) {
+ // Return the first found recipient
+ return recipient;
+ }
+ }
+
+ // Maybe we are in the CC list?
+ let ccList = MailServices.headerParser.makeFromDisplayAddress(aMsgHdr.ccList || "");
+ for (let recipient of ccList) {
+ if (recipient.email.toLowerCase() in emailMap) {
+ // Return the first found recipient
+ return recipient;
+ }
+ }
+
+ // Hrmpf. Looks like delegation or maybe Bcc.
+ return null;
+ },
+
+ /**
+ * Executes an action from a calandar message.
+ *
+ * @param {nsIWindow} aWindow - The current window
+ * @param {string} aParticipantStatus - A partstat string as per RfC 5545
+ * @param {string} aResponse - Either 'AUTO', 'NONE' or 'USER', see
+ * calItipItem interface
+ * @param {Function} aActionFunc - The function to call to do the scheduling
+ * operation
+ * @param {calIItipItem} aItipItem - Scheduling item
+ * @param {array} aFoundItems - The items found when looking for the calendar item
+ * @param {Function} aUpdateFunction - A function to call which will update the UI
+ * @returns {boolean} true, if the action succeeded
+ */
+ executeAction(
+ aWindow,
+ aParticipantStatus,
+ aResponse,
+ aActionFunc,
+ aItipItem,
+ aFoundItems,
+ aUpdateFunction
+ ) {
+ // control to avoid processing _execAction on later user changes on the item
+ let isFirstProcessing = true;
+
+ /**
+ * Internal function to trigger an scheduling operation
+ *
+ * @param {Function} aActionFunc - The function to call to do the
+ * scheduling operation
+ * @param {calIItipItem} aItipItem - Scheduling item
+ * @param {nsIWindow} aWindow - The current window
+ * @param {string} aPartStat - partstat string as per RFC 5545
+ * @param {object} aExtResponse - JS object containing at least an responseMode
+ * property
+ * @returns {boolean} true, if the action succeeded
+ */
+ function _execAction(aActionFunc, aItipItem, aWindow, aPartStat, aExtResponse) {
+ let method = aActionFunc.method;
+ if (lazy.cal.itip.promptCalendar(aActionFunc.method, aItipItem, aWindow)) {
+ if (
+ method == "REQUEST" &&
+ !lazy.cal.itip.promptInvitedAttendee(aWindow, aItipItem, Ci.calIItipItem[aResponse])
+ ) {
+ return false;
+ }
+
+ let isDeclineCounter = aPartStat == "X-DECLINECOUNTER";
+ // filter out fake partstats
+ if (aPartStat.startsWith("X-")) {
+ aParticipantStatus = "";
+ }
+ // hide the buttons now, to disable pressing them twice...
+ if (aPartStat == aParticipantStatus) {
+ aUpdateFunction({ resetButtons: true });
+ }
+
+ let opListener = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+ onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail) {
+ isFirstProcessing = false;
+ if (Components.isSuccessCode(aStatus) && isDeclineCounter) {
+ // TODO: move the DECLINECOUNTER stuff to actionFunc
+ aItipItem.getItemList().forEach(aItem => {
+ // we can rely on the received itipItem to reply at this stage
+ // already, the checks have been done in cal.itip.processFoundItems
+ // when setting up the respective aActionFunc
+ let attendees = lazy.cal.itip.getAttendeesBySender(
+ aItem.getAttendees(),
+ aItipItem.sender
+ );
+ let status = true;
+ if (attendees.length == 1 && aFoundItems?.length) {
+ // we must return a message with the same sequence number as the
+ // counterproposal - to make it easy, we simply use the received
+ // item and just remove a comment, if any
+ try {
+ let item = aItem.clone();
+ item.calendar = aFoundItems[0].calendar;
+ item.deleteProperty("COMMENT");
+ // once we have full support to deal with for multiple items
+ // in a received invitation message, we should send this
+ // from outside outside of the forEach context
+ status = lazy.cal.itip.sendDeclineCounterMessage(
+ item,
+ "DECLINECOUNTER",
+ attendees,
+ {
+ value: false,
+ }
+ );
+ } catch (e) {
+ lazy.cal.ERROR(e);
+ status = false;
+ }
+ } else {
+ status = false;
+ }
+ if (!status) {
+ lazy.cal.ERROR("Failed to send DECLINECOUNTER reply!");
+ }
+ });
+ }
+ // For now, we just state the status for the user something very simple
+ let label = lazy.cal.itip.getCompleteText(aStatus, aOperationType);
+ aUpdateFunction({ label });
+
+ if (!Components.isSuccessCode(aStatus)) {
+ lazy.cal.showError(label);
+ return;
+ }
+
+ if (Services.prefs.getBoolPref("calendar.itip.newInvitationDisplay")) {
+ aWindow.dispatchEvent(
+ new CustomEvent("onItipItemActionFinished", { detail: aItipItem })
+ );
+ }
+ },
+ onGetResult(calendar, status, itemType, detail, items) {},
+ };
+
+ try {
+ aActionFunc(opListener, aParticipantStatus, aExtResponse);
+ } catch (exc) {
+ console.error(exc);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ if (aParticipantStatus == null) {
+ aParticipantStatus = "";
+ }
+ if (aParticipantStatus == "X-SHOWDETAILS" || aParticipantStatus == "X-RESCHEDULE") {
+ let counterProposal;
+ if (aFoundItems?.length) {
+ let item = aFoundItems[0].isMutable ? aFoundItems[0] : aFoundItems[0].clone();
+
+ if (aParticipantStatus == "X-RESCHEDULE") {
+ // TODO most of the following should be moved to the actionFunc defined in
+ // calItipUtils
+ let proposedItem = aItipItem.getItemList()[0];
+ let proposedRID = proposedItem.getProperty("RECURRENCE-ID");
+ if (proposedRID) {
+ // if this is a counterproposal for a specific occurrence, we use
+ // that to compare with
+ item = item.recurrenceInfo.getOccurrenceFor(proposedRID).clone();
+ }
+ let parsedProposal = lazy.cal.invitation.parseCounter(proposedItem, item);
+ let potentialProposers = lazy.cal.itip.getAttendeesBySender(
+ proposedItem.getAttendees(),
+ aItipItem.sender
+ );
+ let proposingAttendee = potentialProposers.length == 1 ? potentialProposers[0] : null;
+ if (
+ proposingAttendee &&
+ ["OK", "OUTDATED", "NOTLATESTUPDATE"].includes(parsedProposal.result.type)
+ ) {
+ counterProposal = {
+ attendee: proposingAttendee,
+ proposal: parsedProposal.differences,
+ oldVersion:
+ parsedProposal.result == "OLDVERSION" || parsedProposal.result == "NOTLATESTUPDATE",
+ onReschedule: () => {
+ aUpdateFunction({
+ label: lazy.cal.l10n.getLtnString("imipBarCounterPreviousVersionText"),
+ });
+ // TODO: should we hide the buttons in this case, too?
+ },
+ };
+ } else {
+ aUpdateFunction({
+ label: lazy.cal.l10n.getLtnString("imipBarCounterErrorText"),
+ resetButtons: true,
+ });
+ if (proposingAttendee) {
+ lazy.cal.LOG(parsedProposal.result.descr);
+ } else {
+ lazy.cal.LOG("Failed to identify the sending attendee of the counterproposal.");
+ }
+
+ return false;
+ }
+ }
+ // if this a rescheduling operation, we suppress the occurrence
+ // prompt here
+ aWindow.modifyEventWithDialog(
+ item,
+ aParticipantStatus != "X-RESCHEDULE",
+ null,
+ counterProposal
+ );
+ }
+ } else {
+ let response;
+ if (aResponse) {
+ if (aResponse == "AUTO" || aResponse == "NONE" || aResponse == "USER") {
+ response = { responseMode: Ci.calIItipItem[aResponse] };
+ }
+ // Open an extended response dialog to enable the user to add a comment, make a
+ // counterproposal, delegate the event or interact in another way.
+ // Instead of a dialog, this might be implemented as a separate container inside the
+ // imip-overlay as proposed in bug 458578
+ }
+ let delmgr = Cc["@mozilla.org/calendar/deleted-items-manager;1"].getService(
+ Ci.calIDeletedItems
+ );
+ let items = aItipItem.getItemList();
+ if (items && items.length) {
+ let delTime = delmgr.getDeletedDate(items[0].id);
+ let dialogText = lazy.cal.l10n.getLtnString("confirmProcessInvitation");
+ let dialogTitle = lazy.cal.l10n.getLtnString("confirmProcessInvitationTitle");
+ if (delTime && !Services.prompt.confirm(aWindow, dialogTitle, dialogText)) {
+ return false;
+ }
+ }
+
+ if (aParticipantStatus == "X-SAVECOPY") {
+ // we create and adopt copies of the respective events
+ let saveitems = aItipItem
+ .getItemList()
+ .map(lazy.cal.itip.getPublishLikeItemCopy.bind(lazy.cal));
+ if (saveitems.length > 0) {
+ let methods = { receivedMethod: "PUBLISH", responseMethod: "PUBLISH" };
+ let newItipItem = lazy.cal.itip.getModifiedItipItem(aItipItem, saveitems, methods);
+ // setup callback and trigger re-processing
+ let storeCopy = function (aItipItem, aRc, aActionFunc, aFoundItems) {
+ if (isFirstProcessing && aActionFunc && Components.isSuccessCode(aRc)) {
+ _execAction(aActionFunc, aItipItem, aWindow, aParticipantStatus);
+ }
+ };
+ lazy.cal.itip.processItipItem(newItipItem, storeCopy);
+ }
+ // we stop here to not process the original item
+ return false;
+ }
+ return _execAction(aActionFunc, aItipItem, aWindow, aParticipantStatus, response);
+ }
+ return false;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Prompt for the target calendar, if needed for the given method. This calendar will be set on
+ * the passed itip item.
+ *
+ * @param {string} aMethod - The method to check.
+ * @param {calIItipItem} aItipItem - The itip item to set the target calendar on.
+ * @param {DOMWindpw} aWindow - The window to open the dialog on.
+ * @returns {boolean} True, if a calendar was selected or no selection is needed.
+ */
+ promptCalendar(aMethod, aItipItem, aWindow) {
+ let needsCalendar = false;
+ let targetCalendar = null;
+ switch (aMethod) {
+ // methods that don't require the calendar chooser:
+ case "REFRESH":
+ case "REQUEST:UPDATE":
+ case "REQUEST:UPDATE-MINOR":
+ case "PUBLISH:UPDATE":
+ case "REPLY":
+ case "CANCEL":
+ case "COUNTER":
+ case "DECLINECOUNTER":
+ needsCalendar = false;
+ break;
+ default:
+ needsCalendar = true;
+ break;
+ }
+
+ if (needsCalendar) {
+ let calendars = lazy.cal.manager.getCalendars().filter(calitip.isSchedulingCalendar);
+
+ if (aItipItem.receivedMethod == "REQUEST") {
+ // try to further limit down the list to those calendars that
+ // are configured to a matching attendee;
+ let item = aItipItem.getItemList()[0];
+ let matchingCals = calendars.filter(
+ calendar => calitip.getInvitedAttendee(item, calendar) != null
+ );
+ // if there's none, we will show the whole list of calendars:
+ if (matchingCals.length > 0) {
+ calendars = matchingCals;
+ }
+ }
+
+ if (calendars.length == 0) {
+ let msg = lazy.cal.l10n.getLtnString("imipNoCalendarAvailable");
+ aWindow.alert(msg);
+ } else if (calendars.length == 1) {
+ // There's only one calendar, so it's silly to ask what calendar
+ // the user wants to import into.
+ targetCalendar = calendars[0];
+ } else {
+ // Ask what calendar to import into
+ let args = {};
+ args.calendars = calendars;
+ args.onOk = aCal => {
+ targetCalendar = aCal;
+ };
+ args.promptText = lazy.cal.l10n.getCalString("importPrompt");
+ aWindow.openDialog(
+ "chrome://calendar/content/chooseCalendarDialog.xhtml",
+ "_blank",
+ "chrome,titlebar,modal,resizable",
+ args
+ );
+ }
+
+ if (targetCalendar) {
+ aItipItem.targetCalendar = targetCalendar;
+ }
+ }
+
+ return !needsCalendar || targetCalendar != null;
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Prompt for the invited attendee if we cannot automatically determine one.
+ * This will modify the items of the passed calIItipItem to ensure an invited
+ * attendee is available.
+ *
+ * Note: This is intended for the REQUEST/COUNTER methods.
+ *
+ * @param {Window} window - Used to prompt the user.
+ * @param {calIItipItem} itipItem - The itip item to ensure.
+ * @param {number} responseMode - One of the calIITipItem response mode
+ * constants indicating whether a response
+ * will be sent or not.
+ *
+ * @returns {boolean} True if an invited attendee is available for all
+ * items, false if otherwise.
+ */
+ promptInvitedAttendee(window, itipItem, responseMode) {
+ let cancelled = false;
+ for (let item of itipItem.getItemList()) {
+ let att = calitip.getInvitedAttendee(item, itipItem.targetCalendar);
+ if (!att) {
+ window.openDialog(
+ "chrome://calendar/content/calendar-itip-identity-dialog.xhtml",
+ "_blank",
+ "chrome,modal,resizable=no,centerscreen",
+ {
+ responseMode,
+ identities: MailServices.accounts.allIdentities.slice().sort((a, b) => {
+ if (a.email == itipItem.identity && b.email != itipItem.identity) {
+ return -1;
+ }
+ if (b.email == itipItem.identity && a.email != itipItem.identity) {
+ return 1;
+ }
+ return 0;
+ }),
+ onCancel() {
+ cancelled = true;
+ },
+ onOk(identity) {
+ att = new lazy.CalAttendee();
+ att.id = `mailto:${identity.email}`;
+ att.commonName = identity.fullName;
+ att.isOrganizer = false;
+ item.addAttendee(att);
+ },
+ }
+ );
+ }
+
+ if (cancelled) {
+ break;
+ }
+
+ if (att) {
+ let { stampTime, lastModifiedTime } = item;
+
+ // Set this so we know who accepted the event.
+ item.setProperty("X-MOZ-INVITED-ATTENDEE", att.id);
+
+ // Remove the dirty flag from the item.
+ item.setProperty("DTSTAMP", stampTime);
+ item.setProperty("LAST-MODIFIED", lastModifiedTime);
+ }
+ }
+
+ return !cancelled;
+ },
+
+ /**
+ * Clean up after the given iTIP item. This needs to be called once for each time
+ * processItipItem is called. May be called with a null itipItem in which case it will do
+ * nothing.
+ *
+ * @param {calIItipItem} itipItem - The iTIP item to clean up for.
+ */
+ cleanupItipItem(itipItem) {
+ if (itipItem) {
+ let itemList = itipItem.getItemList();
+ if (itemList.length > 0) {
+ // Again, we can assume the id is the same over all items per spec
+ ItipItemFinderFactory.cleanup(itemList[0].id);
+ }
+ }
+ },
+
+ /**
+ * Scope: iTIP message receiver
+ *
+ * Checks the passed iTIP item and calls the passed function with options offered. Be sure to
+ * call cleanupItipItem at least once after calling this function.
+ *
+ * The action func has a property |method| showing the options:
+ * REFRESH -- send the latest item (sent by attendee(s))
+ * PUBLISH -- initial publish, no reply (sent by organizer)
+ * PUBLISH:UPDATE -- update of a published item (sent by organizer)
+ * REQUEST -- initial invitation (sent by organizer)
+ * REQUEST:UPDATE -- rescheduling invitation, has major change (sent by organizer)
+ * REQUEST:UPDATE-MINOR -- update of invitation, minor change (sent by organizer)
+ * REPLY -- invitation reply (sent by attendee(s))
+ * CANCEL -- invitation cancel (sent by organizer)
+ * COUNTER -- counterproposal (sent by attendee)
+ * DECLINECOUNTER -- denial of a counterproposal (sent by organizer)
+ *
+ * @param {calIItipItem} itipItem - The iTIP item
+ * @param {Function} optionsFunc - The function being called with parameters: itipItem,
+ * resultCode, actionFunc
+ */
+ processItipItem(itipItem, optionsFunc) {
+ switch (itipItem.receivedMethod.toUpperCase()) {
+ case "REFRESH":
+ case "PUBLISH":
+ case "REQUEST":
+ case "CANCEL":
+ case "COUNTER":
+ case "DECLINECOUNTER":
+ case "REPLY": {
+ // Per iTIP spec (new Draft 4), multiple items in an iTIP message MUST have
+ // same ID, this simplifies our searching, we can just look for Item[0].id
+ let itemList = itipItem.getItemList();
+ if (!itipItem.targetCalendar) {
+ optionsFunc(itipItem, Ci.calIErrors.CAL_IS_READONLY);
+ } else if (itemList.length > 0) {
+ ItipItemFinderFactory.findItem(itemList[0].id, itipItem, optionsFunc);
+ } else if (optionsFunc) {
+ optionsFunc(itipItem, Cr.NS_OK);
+ }
+ break;
+ }
+ default: {
+ if (optionsFunc) {
+ optionsFunc(itipItem, Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ break;
+ }
+ }
+ },
+
+ /**
+ * Scope: iTIP message sender
+ *
+ * Checks to see if e.g. attendees were added/removed or an item has been deleted and sends out
+ * appropriate iTIP messages.
+ *
+ * @param {number} aOpType - Type of operation - (e.g. ADD, MODIFY or DELETE)
+ * @param {calIItemBase} aItem - The updated item
+ * @param {calIItemBase} aOriginalItem - The original item
+ * @param {?object} aExtResponse - An object to provide additional
+ * parameters for sending itip messages as response
+ * mode, comments or a subset of recipients. Currently
+ * implemented attributes are:
+ * responseMode Response mode (long) as defined for autoResponse
+ * of calIItipItem. The default mode is USER (which
+ * will trigger displaying the previously known popup
+ * to ask the user whether to send)
+ */
+ checkAndSend(aOpType, aItem, aOriginalItem, aExtResponse = null) {
+ // `CalItipMessageSender` uses the presence of an "invited attendee"
+ // (representation of the current user) as an indication that this is an
+ // incoming invitation, so we need to avoid passing it if the current user
+ // is the event organizer.
+ let currentUserAsAttendee = null;
+ const itemCalendar = aItem.calendar;
+ if (
+ itemCalendar?.supportsScheduling &&
+ itemCalendar.getSchedulingSupport().isInvitation(aItem)
+ ) {
+ currentUserAsAttendee = this.getInvitedAttendee(aItem, itemCalendar);
+ }
+
+ const sender = new lazy.CalItipMessageSender(aOriginalItem, currentUserAsAttendee);
+ if (sender.buildOutgoingMessages(aOpType, aItem, aExtResponse)) {
+ sender.send(calitip.getImipTransport(aItem));
+ }
+ },
+
+ /**
+ * Bumps the SEQUENCE in case of a major change; XXX todo may need more fine-tuning.
+ *
+ * @param {calIItemBase} newItem - The new item to set the sequence on
+ * @param {calIItemBase} oldItem - The old item to get the previous version from.
+ * @returns {calIItemBase} The newly changed item
+ */
+ prepareSequence(newItem, oldItem) {
+ if (calitip.isInvitation(newItem)) {
+ return newItem; // invitation copies don't bump the SEQUENCE
+ }
+
+ if (newItem.recurrenceId && !oldItem.recurrenceId && oldItem.recurrenceInfo) {
+ // XXX todo: there's still the bug that modifyItem is called with mixed occurrence/parent,
+ // find original occurrence
+ oldItem = oldItem.recurrenceInfo.getOccurrenceFor(newItem.recurrenceId);
+ lazy.cal.ASSERT(oldItem, "unexpected!");
+ if (!oldItem) {
+ return newItem;
+ }
+ }
+
+ let hashMajorProps = function (aItem) {
+ const majorProps = {
+ DTSTART: true,
+ DTEND: true,
+ DURATION: true,
+ DUE: true,
+ RDATE: true,
+ RRULE: true,
+ EXDATE: true,
+ STATUS: true,
+ LOCATION: true,
+ };
+
+ let propStrings = [];
+ for (let item of lazy.cal.iterate.items([aItem])) {
+ for (let prop of lazy.cal.iterate.icalProperty(item.icalComponent)) {
+ if (prop.propertyName in majorProps) {
+ propStrings.push(item.recurrenceId + "#" + prop.icalString);
+ }
+ }
+ }
+ propStrings.sort();
+ return propStrings.join("");
+ };
+
+ let hash1 = hashMajorProps(newItem);
+ let hash2 = hashMajorProps(oldItem);
+ if (hash1 != hash2) {
+ newItem = newItem.clone();
+ // bump SEQUENCE, it never decreases (mind undo scenario here)
+ newItem.setProperty(
+ "SEQUENCE",
+ String(Math.max(calitip.getSequence(oldItem), calitip.getSequence(newItem)) + 1)
+ );
+ }
+
+ return newItem;
+ },
+
+ /**
+ * Returns a copy of an itipItem with modified properties and items build from scratch Use
+ * itipItem.clone() instead if only a simple copy is required
+ *
+ * @param {calIItipItem} aItipItem ItipItem to derive a new one from
+ * @param {calIItemBase[]} aItems calIEvent or calITodo items to be contained in the new itipItem
+ * @param {object} aProps Properties to be different in the new itipItem
+ * @returns {calIItipItem} The copied and modified item
+ */
+ getModifiedItipItem(aItipItem, aItems = [], aProps = {}) {
+ let itipItem = Cc["@mozilla.org/calendar/itip-item;1"].createInstance(Ci.calIItipItem);
+ let serializedItems = "";
+ for (let item of aItems) {
+ serializedItems += lazy.cal.item.serialize(item);
+ }
+ itipItem.init(serializedItems);
+
+ itipItem.autoResponse = "autoResponse" in aProps ? aProps.autoResponse : aItipItem.autoResponse;
+ itipItem.identity = "identity" in aProps ? aProps.identity : aItipItem.identity;
+ itipItem.isSend = "isSend" in aProps ? aProps.isSend : aItipItem.isSend;
+ itipItem.localStatus = "localStatus" in aProps ? aProps.localStatus : aItipItem.localStatus;
+ itipItem.receivedMethod =
+ "receivedMethod" in aProps ? aProps.receivedMethod : aItipItem.receivedMethod;
+ itipItem.responseMethod =
+ "responseMethod" in aProps ? aProps.responseMethod : aItipItem.responseMethod;
+ itipItem.targetCalendar =
+ "targetCalendar" in aProps ? aProps.targetCalendar : aItipItem.targetCalendar;
+
+ return itipItem;
+ },
+
+ /**
+ * A shortcut to send DECLINECOUNTER messages - for everything else use calitip.checkAndSend
+ *
+ * @param {calIItipItem} aItem - item to be sent
+ * @param {string} aMethod - iTIP method
+ * @param {calIAttendee[]} aRecipientsList - array of calIAttendee objects the message should be sent to
+ * @param {object} aAutoResponse - JS object whether the transport should ask before sending
+ * @returns {boolean} True
+ */
+ sendDeclineCounterMessage(aItem, aMethod, aRecipientsList, aAutoResponse) {
+ if (aMethod == "DECLINECOUNTER") {
+ return sendMessage(aItem, aMethod, aRecipientsList, aAutoResponse);
+ }
+ return false;
+ },
+
+ /**
+ * Returns a copy of an event that
+ * - has a relation set to the original event
+ * - has the same organizer but
+ * - has any attendee removed
+ * Intended to get a copy of a normal event invitation that behaves as if the PUBLISH method was
+ * chosen instead.
+ *
+ * @param {calIItemBase} aItem - Original item
+ * @param {?string} aUid - UID to use for the new item
+ * @returns {calIItemBase} The copied item for publishing
+ */
+ getPublishLikeItemCopy(aItem, aUid) {
+ // avoid changing aItem
+ let item = aItem.clone();
+ // reset to a new UUID if applicable
+ item.id = aUid || lazy.cal.getUUID();
+ // add a relation to the original item
+ let relation = new lazy.CalRelation();
+ relation.relId = aItem.id;
+ relation.relType = "SIBLING";
+ item.addRelation(relation);
+ // remove attendees
+ item.removeAllAttendees();
+ if (!aItem.isMutable) {
+ item = item.makeImmutable();
+ }
+ return item;
+ },
+
+ /**
+ * Tests whether the passed object is a calIAttendee instance. This function
+ * takes into consideration that the object may be be unwrapped and thus a
+ * CalAttendee instance
+ *
+ * @param {object} val - The object to test.
+ *
+ * @returns {boolean}
+ */
+ isAttendee(val) {
+ return val && (val instanceof Ci.calIAttendee || val instanceof lazy.CalAttendee);
+ },
+
+ /**
+ * Shortcut function to check whether an item is an invitation copy.
+ *
+ * @param {calIItemBase} aItem - The item to check for an invitation.
+ * @returns {boolean} True, if the item is an invitation.
+ */
+ isInvitation(aItem) {
+ let isInvitation = false;
+ let calendar = aItem.calendar;
+ if (calendar && calendar.supportsScheduling) {
+ isInvitation = calendar.getSchedulingSupport().isInvitation(aItem);
+ }
+ return isInvitation;
+ },
+
+ /**
+ * Shortcut function to check whether an item is an invitation copy and has a participation
+ * status of either NEEDS-ACTION or TENTATIVE.
+ *
+ * @param {calIAttendee|calIItemBase} aItem - either calIAttendee or calIItemBase
+ * @returns {boolean} True, if the attendee partstat is NEEDS-ACTION
+ * or TENTATIVE
+ */
+ isOpenInvitation(aItem) {
+ if (!calitip.isAttendee(aItem)) {
+ aItem = calitip.getInvitedAttendee(aItem);
+ }
+ if (aItem) {
+ switch (aItem.participationStatus) {
+ case "NEEDS-ACTION":
+ case "TENTATIVE":
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Resolves delegated-to/delegated-from calusers for a given attendee to also include the
+ * respective CNs if available in a given set of attendees
+ *
+ * @param {calIAttendee} aAttendee - The attendee to resolve the delegation information for
+ * @param {calIAttendee[]} aAttendees - An array of calIAttendee objects to look up
+ * @returns {object} An object with string attributes for delegators and delegatees
+ */
+ resolveDelegation(aAttendee, aAttendees) {
+ let attendees = aAttendees || [aAttendee];
+
+ // this will be replaced by a direct property getter in calIAttendee
+ let delegators = [];
+ let delegatees = [];
+ let delegatorProp = aAttendee.getProperty("DELEGATED-FROM");
+ if (delegatorProp) {
+ delegators = typeof delegatorProp == "string" ? [delegatorProp] : delegatorProp;
+ }
+ let delegateeProp = aAttendee.getProperty("DELEGATED-TO");
+ if (delegateeProp) {
+ delegatees = typeof delegateeProp == "string" ? [delegateeProp] : delegateeProp;
+ }
+
+ for (let att of attendees) {
+ let resolveDelegation = function (e, i, a) {
+ if (e == att.id) {
+ a[i] = att.toString();
+ }
+ };
+ delegators.forEach(resolveDelegation);
+ delegatees.forEach(resolveDelegation);
+ }
+ return {
+ delegatees: delegatees.join(", "),
+ delegators: delegators.join(", "),
+ };
+ },
+
+ /**
+ * Shortcut function to get the invited attendee of an item.
+ *
+ * @param {calIItemBase} aItem - Event or task to get the invited attendee for
+ * @param {?calICalendar} aCalendar - The calendar to use for checking, defaults to the item
+ * calendar
+ * @returns {?calIAttendee} The attendee that was invited
+ */
+ getInvitedAttendee(aItem, aCalendar) {
+ let id = aItem.getProperty("X-MOZ-INVITED-ATTENDEE");
+ if (id) {
+ return aItem.getAttendeeById(id);
+ }
+ if (!aCalendar) {
+ aCalendar = aItem.calendar;
+ }
+ let invitedAttendee = null;
+ if (aCalendar && aCalendar.supportsScheduling) {
+ invitedAttendee = aCalendar.getSchedulingSupport().getInvitedAttendee(aItem);
+ }
+ return invitedAttendee;
+ },
+
+ /**
+ * Returns all attendees from given set of attendees matching based on the attendee id
+ * or a sent-by parameter compared to the specified email address
+ *
+ * @param {calIAttendee[]} aAttendees - An array of calIAttendee objects
+ * @param {string} aEmailAddress - A string containing the email address for lookup
+ * @returns {calIAttendee[]} Returns an array of matching attendees
+ */
+ getAttendeesBySender(aAttendees, aEmailAddress) {
+ let attendees = [];
+ // we extract the email address to make it work also for a raw header value
+ let compFields = Cc["@mozilla.org/messengercompose/composefields;1"].createInstance(
+ Ci.nsIMsgCompFields
+ );
+ let addresses = compFields.splitRecipients(aEmailAddress, true);
+ if (addresses.length == 1) {
+ let searchFor = lazy.cal.email.prependMailTo(addresses[0]);
+ aAttendees.forEach(aAttendee => {
+ if ([aAttendee.id, aAttendee.getProperty("SENT-BY")].includes(searchFor)) {
+ attendees.push(aAttendee);
+ }
+ });
+ } else {
+ lazy.cal.WARN("No unique email address for lookup!");
+ }
+ return attendees;
+ },
+
+ /**
+ * Provides the transport to be used for an item based on the invited attendee
+ * or calendar.
+ *
+ * @param {calIItemBase} item
+ */
+ getImipTransport(item) {
+ let id = item.getProperty("X-MOZ-INVITED-ATTENDEE");
+
+ if (id) {
+ let email = id.split("mailto:").join("");
+ let identity = MailServices.accounts.allIdentities.find(identity => identity.email == email);
+
+ if (identity) {
+ let [server] = MailServices.accounts.getServersForIdentity(identity);
+
+ if (server) {
+ let account = MailServices.accounts.FindAccountForServer(server);
+ return new lazy.CalItipDefaultEmailTransport(account, identity);
+ }
+ }
+
+ // We did not find the identity or associated account
+ return null;
+ }
+
+ return item.calendar.getProperty("itip.transport");
+ },
+};
+
+/** local to this module file
+ * Sets the received info either on the passed attendee or item object.
+ *
+ * @param {calIItemBase|calIAttendee} item - The item to set info on
+ * @param {calIItipItem} itipItemItem - The received iTIP item
+ */
+function setReceivedInfo(item, itipItemItem) {
+ let isAttendee = calitip.isAttendee(item);
+ item.setProperty(
+ isAttendee ? "RECEIVED-SEQUENCE" : "X-MOZ-RECEIVED-SEQUENCE",
+ String(calitip.getSequence(itipItemItem))
+ );
+ let dtstamp = calitip.getStamp(itipItemItem);
+ if (dtstamp) {
+ item.setProperty(
+ isAttendee ? "RECEIVED-DTSTAMP" : "X-MOZ-RECEIVED-DTSTAMP",
+ dtstamp.getInTimezone(lazy.cal.dtz.UTC).icalString
+ );
+ }
+}
+
+/** local to this module file
+ * Takes over relevant item information from iTIP item and sets received info.
+ *
+ * @param {calIItemBase} item - The stored calendar item to update
+ * @param {calIItipItem} itipItemItem - The received item
+ * @returns {calIItemBase} A copy of the item with correct received info
+ */
+function updateItem(item, itipItemItem) {
+ /**
+ * Migrates some user data from the old to new item
+ *
+ * @param {calIItemBase} newItem - The new item to copy to
+ * @param {calIItemBase} oldItem - The old item to copy from
+ */
+ function updateUserData(newItem, oldItem) {
+ // preserve user settings:
+ newItem.generation = oldItem.generation;
+ newItem.clearAlarms();
+ for (let alarm of oldItem.getAlarms()) {
+ newItem.addAlarm(alarm);
+ }
+ newItem.alarmLastAck = oldItem.alarmLastAck;
+ let cats = oldItem.getCategories();
+ newItem.setCategories(cats);
+ }
+
+ let newItem = item.clone();
+ newItem.icalComponent = itipItemItem.icalComponent;
+ setReceivedInfo(newItem, itipItemItem);
+ updateUserData(newItem, item);
+
+ let recInfo = itipItemItem.recurrenceInfo;
+ if (recInfo) {
+ // keep care of installing all overridden items, and mind existing alarms, categories:
+ for (let rid of recInfo.getExceptionIds()) {
+ let excItem = recInfo.getExceptionFor(rid).clone();
+ lazy.cal.ASSERT(excItem, "unexpected!");
+ let newExc = newItem.recurrenceInfo.getOccurrenceFor(rid).clone();
+ newExc.icalComponent = excItem.icalComponent;
+ setReceivedInfo(newExc, itipItemItem);
+ let existingExcItem = item.recurrenceInfo && item.recurrenceInfo.getExceptionFor(rid);
+ if (existingExcItem) {
+ updateUserData(newExc, existingExcItem);
+ }
+ newItem.recurrenceInfo.modifyException(newExc, true);
+ }
+ }
+
+ return newItem;
+}
+
+/** local to this module file
+ * Copies the provider-specified properties from the itip item to the passed
+ * item. Special case property "METHOD" uses the itipItem's receivedMethod.
+ *
+ * @param {calIItipItem} itipItem - The itip item containing the receivedMethod.
+ * @param {calIItemBase} itipItemItem - The calendar item inside the itip item.
+ * @param {calIItemBase} item - The target item to copy to.
+ */
+function copyProviderProperties(itipItem, itipItemItem, item) {
+ // Copy over itip properties to the item if requested by the provider
+ let copyProps = item.calendar.getProperty("itip.copyProperties") || [];
+ for (let prop of copyProps) {
+ if (prop == "METHOD") {
+ // Special case, this copies over the received method
+ item.setProperty("METHOD", itipItem.receivedMethod.toUpperCase());
+ } else if (itipItemItem.hasProperty(prop)) {
+ // Otherwise just copy from the item contained in the itipItem
+ item.setProperty(prop, itipItemItem.getProperty(prop));
+ }
+ }
+}
+
+/** local to this module file
+ * Sends an iTIP message using the passed item's calendar transport.
+ *
+ * @param {calIEvent} aItem - item to be sent
+ * @param {string} aMethod - iTIP method
+ * @param {calIAttendee[]} aRecipientsList - array of calIAttendee objects the message should be sent to
+ * @param {object} autoResponse - inout object whether the transport should ask before sending
+ * @returns {boolean} True, if the message could be sent
+ */
+function sendMessage(aItem, aMethod, aRecipientsList, autoResponse) {
+ new lazy.CalItipOutgoingMessage(
+ aMethod,
+ aRecipientsList,
+ aItem,
+ calitip.getInvitedAttendee(aItem),
+ autoResponse
+ ).send(calitip.getImipTransport(aItem));
+}
+
+/** local to this module file
+ * An operation listener that is used on calendar operations which checks and sends further iTIP
+ * messages based on the calendar action.
+ *
+ * @param {object} aOpListener - operation listener to forward
+ * @param {calIItemBase} aOldItem - The previous item before modification (if any)
+ * @param {?object} aExtResponse - An object to provide additional parameters for sending itip
+ * messages as response mode, comments or a subset of
+ * recipients.
+ */
+function ItipOpListener(aOpListener, aOldItem, aExtResponse = null) {
+ this.mOpListener = aOpListener;
+ this.mOldItem = aOldItem;
+ this.mExtResponse = aExtResponse;
+}
+ItipOpListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+
+ mOpListener: null,
+ mOldItem: null,
+ mExtResponse: null,
+
+ onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail) {
+ lazy.cal.ASSERT(Components.isSuccessCode(aStatus), "error on iTIP processing");
+ if (Components.isSuccessCode(aStatus)) {
+ calitip.checkAndSend(aOperationType, aDetail, this.mOldItem, this.mExtResponse);
+ }
+ if (this.mOpListener) {
+ this.mOpListener.onOperationComplete(aCalendar, aStatus, aOperationType, aId, aDetail);
+ }
+ },
+ onGetResult(calendar, status, itemType, detail, items) {},
+};
+
+/** local to this module file
+ * Add a parameter SCHEDULE-AGENT=CLIENT to the item before it is
+ * created or updated so that the providers knows scheduling will
+ * be handled by the client.
+ *
+ * @param {calIItemBase} item - item about to be added or updated
+ * @param {calICalendar} calendar - calendar into which the item is about to be added or updated
+ */
+function addScheduleAgentClient(item, calendar) {
+ if (calendar.getProperty("capabilities.autoschedule.supported") === true) {
+ if (item.organizer) {
+ item.organizer.setProperty("SCHEDULE-AGENT", "CLIENT");
+ }
+ }
+}
+
+var ItipItemFinderFactory = {
+ /** Map to save finder instances for given ids */
+ _findMap: {},
+
+ /**
+ * Create an item finder and track its progress. Be sure to clean up the
+ * finder for this id at some point.
+ *
+ * @param {string} aId - The item id to search for
+ * @param {calIIipItem} aItipItem - The iTIP item used for processing
+ * @param {Function} aOptionsFunc - The options function used for processing the found item
+ */
+ async findItem(aId, aItipItem, aOptionsFunc) {
+ this.cleanup(aId);
+ let finder = new ItipItemFinder(aId, aItipItem, aOptionsFunc);
+ this._findMap[aId] = finder;
+ return finder.findItem();
+ },
+
+ /**
+ * Clean up tracking for the given id. This needs to be called once for
+ * every time findItem is called.
+ *
+ * @param {string} aId - The item id to clean up for
+ */
+ cleanup(aId) {
+ if (aId in this._findMap) {
+ let finder = this._findMap[aId];
+ finder.destroy();
+ delete this._findMap[aId];
+ }
+ },
+};
+
+/** local to this module file
+ * An operation listener triggered by cal.itip.processItipItem() for lookup of the sent iTIP item's UID.
+ *
+ * @param {string} aId - The search identifier for the item to find
+ * @param {calIItipItem} itipItem - Sent iTIP item
+ * @param {Function} optionsFunc - Options func, see cal.itip.processItipItem()
+ */
+function ItipItemFinder(aId, itipItem, optionsFunc) {
+ this.mItipItem = itipItem;
+ this.mOptionsFunc = optionsFunc;
+ this.mSearchId = aId;
+}
+
+ItipItemFinder.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ mSearchId: null,
+ mItipItem: null,
+ mOptionsFunc: null,
+ mFoundItems: null,
+
+ async findItem() {
+ this.mFoundItems = [];
+ this._unobserveChanges();
+
+ let foundItem = await this.mItipItem.targetCalendar.getItem(this.mSearchId);
+ if (foundItem) {
+ this.mFoundItems.push(foundItem);
+ }
+ this.processFoundItems();
+ },
+
+ _observeChanges(aCalendar) {
+ this._unobserveChanges();
+ this.mObservedCalendar = aCalendar;
+
+ if (this.mObservedCalendar) {
+ this.mObservedCalendar.addObserver(this);
+ }
+ },
+ _unobserveChanges() {
+ if (this.mObservedCalendar) {
+ this.mObservedCalendar.removeObserver(this);
+ this.mObservedCalendar = null;
+ }
+ },
+
+ onStartBatch() {},
+ onEndBatch() {},
+ onError() {},
+ onPropertyChanged() {},
+ onPropertyDeleting() {},
+ onLoad(aCalendar) {
+ // Its possible that the item was updated. We need to re-retrieve the
+ // items now.
+ this.findItem();
+ },
+
+ onModifyItem(aNewItem, aOldItem) {
+ let refItem = aOldItem || aNewItem;
+ if (refItem.id == this.mSearchId) {
+ // Check existing found items to see if it already exists
+ let found = false;
+ for (let [idx, item] of Object.entries(this.mFoundItems)) {
+ if (item.id == refItem.id && item.calendar.id == refItem.calendar.id) {
+ if (aNewItem) {
+ this.mFoundItems.splice(idx, 1, aNewItem);
+ } else {
+ this.mFoundItems.splice(idx, 1);
+ }
+ found = true;
+ break;
+ }
+ }
+
+ // If it hasn't been found and there is to add a item, add it to the end
+ if (!found && aNewItem) {
+ this.mFoundItems.push(aNewItem);
+ }
+ this.processFoundItems();
+ }
+ },
+
+ onAddItem(aItem) {
+ // onModifyItem is set up to also handle additions
+ this.onModifyItem(aItem, null);
+ },
+
+ onDeleteItem(aItem) {
+ // onModifyItem is set up to also handle deletions
+ this.onModifyItem(null, aItem);
+ },
+
+ destroy() {
+ this._unobserveChanges();
+ },
+
+ processFoundItems() {
+ let rc = Cr.NS_OK;
+ const method = this.mItipItem.receivedMethod.toUpperCase();
+ let actionMethod = method;
+ let operations = [];
+
+ if (this.mFoundItems.length > 0) {
+ // Save the target calendar on the itip item
+ this.mItipItem.targetCalendar = this.mFoundItems[0].calendar;
+ this._observeChanges(this.mItipItem.targetCalendar);
+
+ lazy.cal.LOG("iTIP on " + method + ": found " + this.mFoundItems.length + " items.");
+ switch (method) {
+ // XXX todo: there's still a potential flaw, if multiple PUBLISH/REPLY/REQUEST on
+ // occurrences happen at once; those lead to multiple
+ // occurrence modifications. Since those modifications happen
+ // implicitly on the parent (ics/memory/storage calls modifyException),
+ // the generation check will fail. We should really consider to allow
+ // deletion/modification/addition of occurrences directly on the providers,
+ // which would ease client code a lot.
+ case "REFRESH":
+ case "PUBLISH":
+ case "REQUEST":
+ case "REPLY":
+ case "COUNTER":
+ case "DECLINECOUNTER":
+ for (let itipItemItem of this.mItipItem.getItemList()) {
+ for (let item of this.mFoundItems) {
+ let rid = itipItemItem.recurrenceId; // XXX todo support multiple
+ if (rid) {
+ // actually applies to individual occurrence(s)
+ if (item.recurrenceInfo) {
+ item = item.recurrenceInfo.getOccurrenceFor(rid);
+ if (!item) {
+ continue;
+ }
+ } else {
+ // the item has been rescheduled with master:
+ itipItemItem = itipItemItem.parentItem;
+ }
+ }
+
+ switch (method) {
+ case "REFRESH": {
+ // xxx todo test
+ let attendees = itipItemItem.getAttendees();
+ lazy.cal.ASSERT(attendees.length == 1, "invalid number of attendees in REFRESH!");
+ if (attendees.length > 0) {
+ let action = function (opListener, partStat, extResponse) {
+ if (!item.organizer) {
+ let org = calitip.createOrganizer(item.calendar);
+ if (org) {
+ item = item.clone();
+ item.organizer = org;
+ }
+ }
+ sendMessage(
+ item,
+ "REQUEST",
+ attendees,
+ { responseMode: Ci.calIItipItem.AUTO } /* don't ask */
+ );
+ };
+ operations.push(action);
+ }
+ break;
+ }
+ case "PUBLISH":
+ lazy.cal.ASSERT(
+ itipItemItem.getAttendees().length == 0,
+ "invalid number of attendees in PUBLISH!"
+ );
+ if (
+ item.calendar.getProperty("itip.disableRevisionChecks") ||
+ calitip.compare(itipItemItem, item) > 0
+ ) {
+ let newItem = updateItem(item, itipItemItem);
+ let action = function (opListener, partStat, extResponse) {
+ return newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ };
+ actionMethod = method + ":UPDATE";
+ operations.push(action);
+ }
+ break;
+ case "REQUEST": {
+ let newItem = updateItem(item, itipItemItem);
+ let att = calitip.getInvitedAttendee(newItem);
+ if (!att) {
+ // fall back to using configured organizer
+ att = calitip.createOrganizer(newItem.calendar);
+ if (att) {
+ att.isOrganizer = false;
+ }
+ }
+ if (att) {
+ let firstFoundItem = this.mFoundItems[0];
+
+ // Where the server automatically adds events to the calendar
+ // we may end up with a recurring invitation in the "NEEDS-ACTION"
+ // state. Upon receiving an exception for these, processFoundItems()
+ // will query the calendar and determine the actionMethod to
+ // be "REQUEST:NEEDS-ACTION" but process the entire series. To avoid
+ // that, we detect here if the itip item's item was indeed for
+ // the whole series or an exception.
+ if (firstFoundItem.recurrenceInfo && rid) {
+ firstFoundItem = firstFoundItem.recurrenceInfo.getOccurrenceFor(rid);
+ }
+
+ // again, fall back to using configured organizer if not found
+ let foundAttendee = firstFoundItem.getAttendeeById(att.id) || att;
+
+ // If the user hasn't responded to the invitation yet and we
+ // are viewing the current representation of the item, show the
+ // accept/decline buttons. This means newer events will show the
+ // "Update" button and older events will show the "already
+ // processed" text.
+ if (
+ foundAttendee.participationStatus == "NEEDS-ACTION" &&
+ (item.calendar.getProperty("itip.disableRevisionChecks") ||
+ calitip.compare(itipItemItem, item) == 0)
+ ) {
+ actionMethod = "REQUEST:NEEDS-ACTION";
+ operations.push((opListener, partStat, extResponse) => {
+ let changedItem = firstFoundItem.clone();
+ changedItem.removeAttendee(foundAttendee);
+ foundAttendee = foundAttendee.clone();
+ if (partStat) {
+ foundAttendee.participationStatus = partStat;
+ }
+ changedItem.addAttendee(foundAttendee);
+
+ let listener = new ItipOpListener(opListener, firstFoundItem, extResponse);
+ return changedItem.calendar.modifyItem(changedItem, firstFoundItem).then(
+ item =>
+ listener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ });
+ } else if (
+ item.calendar.getProperty("itip.disableRevisionChecks") ||
+ calitip.compare(itipItemItem, item) > 0
+ ) {
+ addScheduleAgentClient(newItem, item.calendar);
+
+ let isMinorUpdate = calitip.getSequence(newItem) == calitip.getSequence(item);
+ actionMethod = isMinorUpdate ? method + ":UPDATE-MINOR" : method + ":UPDATE";
+ operations.push((opListener, partStat, extResponse) => {
+ if (!partStat) {
+ // keep PARTSTAT
+ let att_ = calitip.getInvitedAttendee(item);
+ partStat = att_ ? att_.participationStatus : "NEEDS-ACTION";
+ }
+ newItem.removeAttendee(att);
+ att = att.clone();
+ att.participationStatus = partStat;
+ newItem.addAttendee(att);
+
+ let listener = new ItipOpListener(opListener, item, extResponse);
+ return newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ listener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ });
+ }
+ }
+ break;
+ }
+ case "DECLINECOUNTER":
+ // nothing to do right now, but once countering is implemented,
+ // we probably need some action here to remove the proposal from
+ // the countering attendee's calendar
+ break;
+ case "COUNTER":
+ case "REPLY": {
+ let attendees = itipItemItem.getAttendees();
+ if (method == "REPLY") {
+ lazy.cal.ASSERT(attendees.length == 1, "invalid number of attendees in REPLY!");
+ } else {
+ attendees = lazy.cal.itip.getAttendeesBySender(
+ attendees,
+ this.mItipItem.sender
+ );
+ lazy.cal.ASSERT(
+ attendees.length == 1,
+ "ambiguous resolution of replying attendee in COUNTER!"
+ );
+ }
+ // we get the attendee from the event stored in the calendar
+ let replyer = item.getAttendeeById(attendees[0].id);
+ if (!replyer && method == "REPLY") {
+ // We accepts REPLYs also from previously uninvited
+ // attendees, so we always have one for REPLY
+ replyer = attendees[0];
+ }
+ let noCheck = item.calendar.getProperty("itip.disableRevisionChecks");
+ let revCheck = false;
+ if (replyer && !noCheck) {
+ revCheck = calitip.compare(itipItemItem, replyer) > 0;
+ if (revCheck && method == "COUNTER") {
+ revCheck = calitip.compareSequence(itipItemItem, item) == 0;
+ }
+ }
+
+ if (replyer && (noCheck || revCheck)) {
+ let newItem = item.clone();
+ newItem.removeAttendee(replyer);
+ replyer = replyer.clone();
+ setReceivedInfo(replyer, itipItemItem);
+ let newPS = itipItemItem.getAttendeeById(replyer.id).participationStatus;
+ replyer.participationStatus = newPS;
+ newItem.addAttendee(replyer);
+
+ // Make sure the provider-specified properties are copied over
+ copyProviderProperties(this.mItipItem, itipItemItem, newItem);
+
+ let action = function (opListener, partStat, extResponse) {
+ // n.b.: this will only be processed in case of reply or
+ // declining the counter request - of sending the
+ // appropriate reply will be taken care within the
+ // opListener (defined in imip-bar.js)
+ // TODO: move that from imip-bar.js to here
+
+ let listener = newItem.calendar.getProperty("itip.notify-replies")
+ ? new ItipOpListener(opListener, item, extResponse)
+ : opListener;
+ return newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ listener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ );
+ };
+ operations.push(action);
+ }
+ break;
+ }
+ }
+ }
+ }
+ break;
+ case "CANCEL": {
+ let modifiedItems = {};
+ for (let itipItemItem of this.mItipItem.getItemList()) {
+ for (let item of this.mFoundItems) {
+ let rid = itipItemItem.recurrenceId; // XXX todo support multiple
+ if (rid) {
+ // actually a CANCEL of occurrence(s)
+ if (item.recurrenceInfo) {
+ // collect all occurrence deletions into a single parent modification:
+ let newItem = modifiedItems[item.id];
+ if (!newItem) {
+ newItem = item.clone();
+ modifiedItems[item.id] = newItem;
+
+ // Make sure the provider-specified properties are copied over
+ copyProviderProperties(this.mItipItem, itipItemItem, newItem);
+
+ operations.push((opListener, partStat, extResponse) =>
+ newItem.calendar.modifyItem(newItem, item).then(
+ item =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ null,
+ e.result || Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.MODIFY,
+ null,
+ e
+ )
+ )
+ );
+ }
+ newItem.recurrenceInfo.removeOccurrenceAt(rid);
+ } else if (item.recurrenceId && item.recurrenceId.compare(rid) == 0) {
+ // parentless occurrence to be deleted (future)
+ operations.push((opListener, partStat, extResponse) =>
+ item.calendar.deleteItem(item).then(
+ () =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ item.calendar,
+ e.result,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ e
+ )
+ )
+ );
+ }
+ } else {
+ operations.push((opListener, partStat, extResponse) =>
+ item.calendar.deleteItem(item).then(
+ () =>
+ opListener.onOperationComplete(
+ item.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ item
+ ),
+ e =>
+ opListener.onOperationComplete(
+ item.calendar,
+ e.result,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ e
+ )
+ )
+ );
+ }
+ }
+ }
+ break;
+ }
+ default:
+ rc = Cr.NS_ERROR_NOT_IMPLEMENTED;
+ break;
+ }
+ } else {
+ // not found:
+ lazy.cal.LOG("iTIP on " + method + ": no existing items.");
+ // If the item was not found, observe the target calendar anyway.
+ // It will likely be the composite calendar, so we should update
+ // if an item was added or removed
+ this._observeChanges(this.mItipItem.targetCalendar);
+
+ for (let itipItemItem of this.mItipItem.getItemList()) {
+ switch (method) {
+ case "REQUEST":
+ case "PUBLISH": {
+ let action = (opListener, partStat, extResponse) => {
+ let newItem = itipItemItem.clone();
+ setReceivedInfo(newItem, itipItemItem);
+ newItem.parentItem.calendar = this.mItipItem.targetCalendar;
+ addScheduleAgentClient(newItem, this.mItipItem.targetCalendar);
+
+ if (partStat) {
+ if (partStat != "DECLINED") {
+ lazy.cal.alarms.setDefaultValues(newItem);
+ }
+
+ let att = calitip.getInvitedAttendee(newItem);
+ if (!att) {
+ lazy.cal.WARN(
+ `Encountered item without invited attendee! id=${newItem.id}, method=${method} Exiting...`
+ );
+ return null;
+ }
+ att.participationStatus = partStat;
+ } else {
+ lazy.cal.ASSERT(
+ itipItemItem.getAttendees().length == 0,
+ "invalid number of attendees in PUBLISH!"
+ );
+ lazy.cal.alarms.setDefaultValues(newItem);
+ }
+
+ let listener =
+ method == "REQUEST"
+ ? new ItipOpListener(opListener, null, extResponse)
+ : opListener;
+ return newItem.calendar.addItem(newItem).then(
+ item =>
+ listener.onOperationComplete(
+ newItem.calendar,
+ Cr.NS_OK,
+ Ci.calIOperationListener.ADD,
+ item.id,
+ item
+ ),
+ e =>
+ listener.onOperationComplete(
+ newItem.calendar,
+ e.result,
+ Ci.calIOperationListener.ADD,
+ newItem.id,
+ e
+ )
+ );
+ };
+ operations.push(action);
+ break;
+ }
+ case "CANCEL": // has already been processed
+ case "REPLY": // item has been previously removed from the calendar
+ case "COUNTER": // the item has been previously removed form the calendar
+ break;
+ default:
+ rc = Cr.NS_ERROR_NOT_IMPLEMENTED;
+ break;
+ }
+ }
+ }
+
+ lazy.cal.LOG("iTIP operations: " + operations.length);
+ let actionFunc = null;
+ if (operations.length > 0) {
+ actionFunc = function (opListener, partStat = null, extResponse = null) {
+ for (let operation of operations) {
+ try {
+ operation(opListener, partStat, extResponse);
+ } catch (exc) {
+ lazy.cal.ERROR(exc);
+ }
+ }
+ };
+ actionFunc.method = actionMethod;
+ }
+
+ this.mOptionsFunc(this.mItipItem, rc, actionFunc, this.mFoundItems);
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calL10NUtils.jsm b/comm/calendar/base/modules/utils/calL10NUtils.jsm
new file mode 100644
index 0000000000..8897bbdc40
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calL10NUtils.jsm
@@ -0,0 +1,162 @@
+/* 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/. */
+
+/**
+ * Localization and locale functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.l10n namespace.
+
+const EXPORTED_SYMBOLS = ["call10n"];
+
+/**
+ * Gets the value of a string in a .properties file.
+ *
+ * @param {string} aComponent - Stringbundle component name
+ * @param {string} aBundleName - The name of the properties file
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+function _getString(aComponent, aBundleName, aStringName, aParams = []) {
+ let propName = `chrome://${aComponent}/locale/${aBundleName}.properties`;
+
+ try {
+ if (!(propName in _getString._bundleCache)) {
+ _getString._bundleCache[propName] = Services.strings.createBundle(propName);
+ }
+ let props = _getString._bundleCache[propName];
+
+ if (aParams && aParams.length) {
+ return props.formatStringFromName(aStringName, aParams);
+ }
+ return props.GetStringFromName(aStringName);
+ } catch (ex) {
+ let msg = `Failed to read '${aStringName}' from ${propName}.`;
+ console.error(`${msg} Error: ${ex}`);
+ return aStringName;
+ }
+}
+_getString._bundleCache = {};
+
+/**
+ * Provides locale dependent parameters for displaying calendar views
+ *
+ * @param {string} aLocale The locale to get the info for, e.g. "en-US",
+ * "de-DE" or null for the current locale
+ * @param {Bollean} aResetCache - Whether to reset the internal cache - for test
+ * purposes only don't use it otherwise atm
+ * @returns {object} The getCalendarInfo object from mozIMozIntl
+ */
+function _calendarInfo(aLocale = null, aResetCache = false) {
+ if (aResetCache) {
+ _calendarInfo._startup = {};
+ }
+ // we cache the result to prevent updates at runtime except for test
+ // purposes since changing intl.regional_prefs.use_os_locales preference
+ // would provide different result when called without aLocale and we
+ // need to investigate whether this is wanted or chaching more selctively.
+ // when starting to use it to determine the first week of a year, we would
+ // need to at least reset that cached properties on pref change.
+ if (!("firstDayOfWeek" in _calendarInfo._startup) || aLocale) {
+ let info = Services.intl.getCalendarInfo(aLocale || Services.locale.regionalPrefsLocales[0]);
+ if (aLocale) {
+ return info;
+ }
+ _calendarInfo._startup = info;
+ }
+ return _calendarInfo._startup;
+}
+_calendarInfo._startup = {};
+
+var call10n = {
+ /**
+ * Gets the value of a string in a .properties file.
+ *
+ * @param {string} aComponent - Stringbundle component name
+ * @param {string} aBundleName - The name of the properties file
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getAnyString: _getString,
+
+ /**
+ * Gets a string from a bundle from chrome://calendar/
+ *
+ * @param {string} aBundleName - The name of the properties file
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getString: _getString.bind(undefined, "calendar"),
+
+ /**
+ * Gets a string from chrome://calendar/locale/calendar.properties bundle
+ *
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getCalString: _getString.bind(undefined, "calendar", "calendar"),
+
+ /**
+ * Gets a string from chrome://lightning/locale/lightning.properties
+ *
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getLtnString: _getString.bind(undefined, "lightning", "lightning"),
+
+ /**
+ * Gets a date format string from chrome://calendar/locale/dateFormat.properties bundle
+ *
+ * @param {string} aStringName - The name of the string within the properties file
+ * @param {string[]} aParams - (optional) Parameters to format the string
+ * @returns {string} The formatted string
+ */
+ getDateFmtString: _getString.bind(undefined, "calendar", "dateFormat"),
+
+ /**
+ * Gets the month name string in the right form depending on a base string.
+ *
+ * @param {number} aMonthNum - The month number to get, 1-based.
+ * @param {string} aBundleName - The Bundle to get the string from
+ * @param {string} aStringBase - The base string name, .monthFormat will be appended
+ * @returns {string} The formatted month name
+ */
+ formatMonth(aMonthNum, aBundleName, aStringBase) {
+ let monthForm = call10n.getString(aBundleName, aStringBase + ".monthFormat") || "nominative";
+
+ if (monthForm == "nominative") {
+ // Fall back to the default name format
+ monthForm = "name";
+ }
+
+ return call10n.getDateFmtString(`month.${aMonthNum}.${monthForm}`);
+ },
+
+ /**
+ * Sort an array of strings in place, according to the current locale.
+ *
+ * @param {string[]} aStringArray - The strings to sort
+ * @returns {string[]} The sorted strings, more specifically aStringArray
+ */
+ sortArrayByLocaleCollator(aStringArray) {
+ const collator = new Intl.Collator();
+ aStringArray.sort(collator.compare);
+ return aStringArray;
+ },
+
+ /**
+ * Provides locale dependent parameters for displaying calendar views
+ *
+ * @param {string} aLocale - The locale to get the info for, e.g. "en-US",
+ * "de-DE" or null for the current locale
+ * @returns {object} The getCalendarInfo object from mozIMozIntl
+ */
+ calendarInfo: _calendarInfo,
+};
diff --git a/comm/calendar/base/modules/utils/calPrintUtils.jsm b/comm/calendar/base/modules/utils/calPrintUtils.jsm
new file mode 100644
index 0000000000..ad7b022f3c
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calPrintUtils.jsm
@@ -0,0 +1,616 @@
+/* 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/. */
+
+/**
+ * Helpers for printing.
+ *
+ * This file detects when printing starts, and if it's the calendar that is
+ * being printed, injects calendar-print.js into the printing UI.
+ *
+ * Also contains the code for formatting the to-be-printed document as chosen
+ * by the user.
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.print namespace.
+
+const EXPORTED_SYMBOLS = ["calprint"];
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+
+var calprint = {
+ ensureInitialized() {
+ // Deliberate no-op. By calling this function from outside, you've ensured
+ // the observer has been added.
+ },
+
+ async draw(document, type, startDate, endDate, filter, notDueTasks) {
+ lazy.cal.view.colorTracker.addColorsToDocument(document);
+
+ let listContainer = document.getElementById("list-container");
+ while (listContainer.lastChild) {
+ listContainer.lastChild.remove();
+ }
+ let monthContainer = document.getElementById("month-container");
+ while (monthContainer.lastChild) {
+ monthContainer.lastChild.remove();
+ }
+ let weekContainer = document.getElementById("week-container");
+ while (weekContainer.lastChild) {
+ weekContainer.lastChild.remove();
+ }
+
+ let taskContainer = document.getElementById("task-container");
+ while (taskContainer.lastChild) {
+ taskContainer.lastChild.remove();
+ }
+ document.getElementById("tasks-list-box").hidden = true;
+
+ switch (type) {
+ case "list":
+ await listView.draw(document, startDate, endDate, filter, notDueTasks);
+ break;
+ case "monthGrid":
+ await monthGridView.draw(document, startDate, endDate, filter, notDueTasks);
+ break;
+ case "weekPlanner":
+ await weekPlannerView.draw(document, startDate, endDate, filter, notDueTasks);
+ break;
+ }
+ },
+};
+
+/**
+ * Serializes the given item by setting marked nodes to the item's content.
+ * Has some expectations about the DOM document (in CSS-selector-speak), all
+ * following nodes MUST exist.
+ *
+ * - #item-template will be cloned and filled, and modified:
+ * - .item-interval gets the time interval of the item.
+ * - .item-title gets the item title
+ * - .category-color-box gets a 2px solid border in category color
+ * - .calendar-color-box gets background color of the calendar
+ *
+ * @param document The DOM Document to set things on
+ * @param item The item to serialize
+ * @param dayContainer The DOM Node to insert the container in
+ */
+function addItemToDaybox(document, item, boxDate, dayContainer) {
+ // Clone our template
+ let itemNode = document.getElementById("item-template").content.firstElementChild.cloneNode(true);
+ itemNode.removeAttribute("id");
+ itemNode.item = item;
+
+ // Fill in details of the item
+ let itemInterval = getItemIntervalString(item, boxDate);
+ itemNode.querySelector(".item-interval").textContent = itemInterval;
+ itemNode.querySelector(".item-title").textContent = item.title;
+
+ // Fill in category details
+ let categoriesArray = item.getCategories();
+ if (categoriesArray.length > 0) {
+ let cssClassesArray = categoriesArray.map(lazy.cal.view.formatStringForCSSRule);
+ itemNode.style.borderInlineEnd = `2px solid var(--category-${cssClassesArray[0]}-color)`;
+ }
+
+ // Fill in calendar color
+ let cssSafeId = lazy.cal.view.formatStringForCSSRule(item.calendar.id);
+ itemNode.style.color = `var(--calendar-${cssSafeId}-forecolor)`;
+ itemNode.style.backgroundColor = `var(--calendar-${cssSafeId}-backcolor)`;
+
+ // Add it to the day container in the right order
+ lazy.cal.data.binaryInsertNode(dayContainer, itemNode, item, lazy.cal.view.compareItems);
+}
+
+/**
+ * Serializes the given item by setting marked nodes to the item's
+ * content. Should be used for tasks with no start and due date. Has
+ * some expectations about the DOM document (in CSS-selector-speak),
+ * all following nodes MUST exist.
+ *
+ * - Nodes will be added to #task-container.
+ * - #task-list-box will have the "hidden" attribute removed.
+ * - #task-template will be cloned and filled, and modified:
+ * - .task-checkbox gets the "checked" attribute set, if completed
+ * - .task-title gets the item title.
+ *
+ * @param document The DOM Document to set things on
+ * @param item The item to serialize
+ */
+function addItemToDayboxNodate(document, item) {
+ let taskContainer = document.getElementById("task-container");
+ let taskNode = document.getElementById("task-template").content.firstElementChild.cloneNode(true);
+ taskNode.item = item;
+
+ let taskListBox = document.getElementById("tasks-list-box");
+ if (taskListBox.hasAttribute("hidden")) {
+ let tasksTitle = document.getElementById("tasks-title");
+ taskListBox.removeAttribute("hidden");
+ tasksTitle.textContent = lazy.cal.l10n.getCalString("tasksWithNoDueDate");
+ }
+
+ // Fill in details of the task
+ if (item.isCompleted) {
+ taskNode.querySelector(".task-checkbox").setAttribute("checked", "checked");
+ }
+
+ taskNode.querySelector(".task-title").textContent = item.title;
+
+ const collator = new Intl.Collator();
+ lazy.cal.data.binaryInsertNode(
+ taskContainer,
+ taskNode,
+ item,
+ collator.compare,
+ node => node.item.title
+ );
+}
+
+/**
+ * Get time interval string for the given item. Returns an empty string for all-day items.
+ *
+ * @param aItem The item providing the interval
+ * @returns The string describing the interval
+ */
+function getItemIntervalString(aItem, aBoxDate) {
+ // omit time label for all-day items
+ let formatter = lazy.cal.dtz.formatter;
+ let startDate = aItem[lazy.cal.dtz.startDateProp(aItem)];
+ let endDate = aItem[lazy.cal.dtz.endDateProp(aItem)];
+ if ((startDate && startDate.isDate) || (endDate && endDate.isDate)) {
+ return "";
+ }
+
+ // check for tasks without start and/or due date
+ if (!startDate || !endDate) {
+ return formatter.formatItemTimeInterval(aItem);
+ }
+
+ let defaultTimezone = lazy.cal.dtz.defaultTimezone;
+ startDate = startDate.getInTimezone(defaultTimezone);
+ endDate = endDate.getInTimezone(defaultTimezone);
+ let start = startDate.clone();
+ let end = endDate.clone();
+ start.isDate = true;
+ end.isDate = true;
+ if (start.compare(end) == 0) {
+ // Events that start and end in the same day.
+ return formatter.formatTimeInterval(startDate, endDate);
+ }
+ // Events that span two or more days.
+ let compareStart = aBoxDate.compare(start);
+ let compareEnd = aBoxDate.compare(end);
+ if (compareStart == 0) {
+ return "\u21e4 " + formatter.formatTime(startDate); // unicode '⇤'
+ } else if (compareStart > 0 && compareEnd < 0) {
+ return "\u21ff"; // unicode '↔'
+ } else if (compareEnd == 0) {
+ return "\u21e5 " + formatter.formatTime(endDate); // unicode '⇥'
+ }
+ return "";
+}
+
+/**
+ * Gets items from the composite calendar for printing.
+ *
+ * @param {calIDateTime} startDate
+ * @param {calIDateTime} endDate
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ * @returns {Promise<calIItemBase[]>}
+ */
+async function getItems(startDate, endDate, filter, notDueTasks) {
+ let window = Services.wm.getMostRecentWindow("mail:3pane");
+ let compositeCalendar = lazy.cal.view.getCompositeCalendar(window);
+
+ let itemList = [];
+ for await (let items of lazy.cal.iterate.streamValues(
+ compositeCalendar.getItems(filter, 0, startDate, endDate)
+ )) {
+ if (!notDueTasks) {
+ items = items.filter(i => !i.isTodo() || i.entryDate || i.dueDate);
+ }
+ itemList = itemList.concat(items);
+ }
+ return itemList;
+}
+
+/**
+ * A simple list of calendar items.
+ */
+let listView = {
+ /**
+ * Create the list view.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startDate - the first day of the months to be displayed
+ * @param {calIDateTime} endDate - the first day of the month AFTER the
+ * months to be displayed
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ */
+ async draw(document, startDate, endDate, filter, notDueTasks) {
+ let container = document.getElementById("list-container");
+ let listItemTemplate = document.getElementById("list-item-template");
+
+ // Get and sort items.
+ let items = await getItems(startDate, endDate, filter, notDueTasks);
+ items.sort((a, b) => {
+ let start_a = a[lazy.cal.dtz.startDateProp(a)];
+ if (!start_a) {
+ return -1;
+ }
+ let start_b = b[lazy.cal.dtz.startDateProp(b)];
+ if (!start_b) {
+ return 1;
+ }
+ return start_a.compare(start_b);
+ });
+
+ // Display the items.
+ for (let item of items) {
+ let itemNode = listItemTemplate.content.firstElementChild.cloneNode(true);
+
+ let setupTextRow = function (classKey, propValue, prefixKey) {
+ if (propValue) {
+ let prefix = lazy.cal.l10n.getCalString(prefixKey);
+ itemNode.querySelector("." + classKey + "key").textContent = prefix;
+ itemNode.querySelector("." + classKey).textContent = propValue;
+ } else {
+ let row = itemNode.querySelector("." + classKey + "row");
+ if (
+ row.nextSibling.nodeType == row.nextSibling.TEXT_NODE ||
+ row.nextSibling.nodeType == row.nextSibling.CDATA_SECTION_NODE
+ ) {
+ row.nextSibling.remove();
+ }
+ row.remove();
+ }
+ };
+
+ let itemStartDate = item[lazy.cal.dtz.startDateProp(item)];
+ let itemEndDate = item[lazy.cal.dtz.endDateProp(item)];
+ if (itemStartDate || itemEndDate) {
+ // This is a task with a start or due date, format accordingly
+ let prefixWhen = lazy.cal.l10n.getCalString("htmlPrefixWhen");
+ itemNode.querySelector(".intervalkey").textContent = prefixWhen;
+
+ let startNode = itemNode.querySelector(".dtstart");
+ let dateString = lazy.cal.dtz.formatter.formatItemInterval(item);
+ startNode.setAttribute("title", itemStartDate ? itemStartDate.icalString : "none");
+ startNode.textContent = dateString;
+ } else {
+ let row = itemNode.querySelector(".intervalrow");
+ row.remove();
+ if (
+ row.nextSibling &&
+ (row.nextSibling.nodeType == row.nextSibling.TEXT_NODE ||
+ row.nextSibling.nodeType == row.nextSibling.CDATA_SECTION_NODE)
+ ) {
+ row.nextSibling.remove();
+ }
+ }
+
+ let itemTitle = item.isCompleted
+ ? lazy.cal.l10n.getCalString("htmlTaskCompleted", [item.title])
+ : item.title;
+ setupTextRow("summary", itemTitle, "htmlPrefixTitle");
+
+ setupTextRow("location", item.getProperty("LOCATION"), "htmlPrefixLocation");
+ setupTextRow("description", item.getProperty("DESCRIPTION"), "htmlPrefixDescription");
+
+ container.appendChild(itemNode);
+ }
+
+ // Set the page title.
+ endDate.day--;
+ document.title = lazy.cal.dtz.formatter.formatInterval(startDate, endDate);
+ },
+};
+
+/**
+ * A layout with one calendar month per page.
+ */
+let monthGridView = {
+ dayTable: {},
+
+ /**
+ * Create the month grid view.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startDate - the first day of the months to be displayed
+ * @param {calIDateTime} endDate - the first day of the month AFTER the
+ * months to be displayed
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ */
+ async draw(document, startDate, endDate, filter, notDueTasks) {
+ let container = document.getElementById("month-container");
+
+ // Draw the month grid(s).
+ let current = startDate.clone();
+ do {
+ container.appendChild(this.drawMonth(document, current));
+ current.month += 1;
+ } while (current.compare(endDate) < 0);
+
+ // Extend the date range to include adjacent days that will be printed.
+ startDate = lazy.cal.weekInfoService.getStartOfWeek(startDate);
+ // Get the end of the week containing the last day of the month, not the
+ // week containing the first day of the next month.
+ endDate.day--;
+ endDate = lazy.cal.weekInfoService.getEndOfWeek(endDate);
+ endDate.day++; // Add a day to include items from the last day.
+
+ // Get and display the items.
+ let items = await getItems(startDate, endDate, filter, notDueTasks);
+ let defaultTimezone = lazy.cal.dtz.defaultTimezone;
+ for (let item of items) {
+ let itemStartDate =
+ item[lazy.cal.dtz.startDateProp(item)] || item[lazy.cal.dtz.endDateProp(item)];
+ let itemEndDate =
+ item[lazy.cal.dtz.endDateProp(item)] || item[lazy.cal.dtz.startDateProp(item)];
+
+ if (!itemStartDate && !itemEndDate) {
+ addItemToDayboxNodate(document, item);
+ continue;
+ }
+ itemStartDate = itemStartDate.getInTimezone(defaultTimezone);
+ itemEndDate = itemEndDate.getInTimezone(defaultTimezone);
+
+ let boxDate = itemStartDate.clone();
+ boxDate.isDate = true;
+ for (boxDate; boxDate.compare(itemEndDate) < (itemEndDate.isDate ? 0 : 1); boxDate.day++) {
+ let boxDateString = boxDate.icalString;
+ if (boxDateString in this.dayTable) {
+ for (let dayBox of this.dayTable[boxDateString]) {
+ addItemToDaybox(document, item, boxDate, dayBox.querySelector(".items"));
+ }
+ }
+ }
+ }
+
+ // Set the page title.
+ let months = container.querySelectorAll("table");
+ if (months.length == 1) {
+ document.title = months[0].querySelector(".month-title").textContent;
+ } else {
+ document.title =
+ months[0].querySelector(".month-title").textContent +
+ " – " +
+ months[months.length - 1].querySelector(".month-title").textContent;
+ }
+ },
+
+ /**
+ * Create one month from the template.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startOfMonth - the first day of the month
+ */
+ drawMonth(document, startOfMonth) {
+ let monthTemplate = document.getElementById("month-template");
+ let month = monthTemplate.content.firstElementChild.cloneNode(true);
+
+ // Set up the month title
+ let monthName = lazy.cal.l10n.formatMonth(startOfMonth.month + 1, "calendar", "monthInYear");
+ let monthTitle = lazy.cal.l10n.getCalString("monthInYear", [monthName, startOfMonth.year]);
+ month.rows[0].cells[0].firstElementChild.textContent = monthTitle;
+
+ // Set up the weekday titles
+ let weekStart = Services.prefs.getIntPref("calendar.week.start", 0);
+ for (let i = 0; i < 7; i++) {
+ let dayNumber = ((i + weekStart) % 7) + 1;
+ month.rows[1].cells[i].firstElementChild.textContent = lazy.cal.l10n.getDateFmtString(
+ `day.${dayNumber}.Mmm`
+ );
+ }
+
+ // Set up each week
+ let endOfMonthView = lazy.cal.weekInfoService.getEndOfWeek(startOfMonth.endOfMonth);
+ let startOfMonthView = lazy.cal.weekInfoService.getStartOfWeek(startOfMonth);
+ let mainMonth = startOfMonth.month;
+
+ for (
+ let weekStart = startOfMonthView;
+ weekStart.compare(endOfMonthView) < 0;
+ weekStart.day += 7
+ ) {
+ month.tBodies[0].appendChild(this.drawWeek(document, weekStart, mainMonth));
+ }
+
+ return month;
+ },
+
+ /**
+ * Create one week from the template.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startOfWeek - the first day of the week
+ * @param {number} mainMonth - the month that this week is being added to
+ * (for marking days that are in adjacent months)
+ */
+ drawWeek(document, startOfWeek, mainMonth) {
+ const weekdayMap = [
+ "d0sundaysoff",
+ "d1mondaysoff",
+ "d2tuesdaysoff",
+ "d3wednesdaysoff",
+ "d4thursdaysoff",
+ "d5fridaysoff",
+ "d6saturdaysoff",
+ ];
+
+ let weekTemplate = document.getElementById("month-week-template");
+ let week = weekTemplate.content.firstElementChild.cloneNode(true);
+
+ // Set up day numbers for all days in this week
+ let date = startOfWeek.clone();
+ for (let i = 0; i < 7; i++) {
+ let dayBox = week.cells[i];
+ dayBox.querySelector(".day-title").textContent = date.day;
+
+ let weekDay = date.weekday;
+ let dayOffPrefName = "calendar.week." + weekdayMap[weekDay];
+ if (Services.prefs.getBoolPref(dayOffPrefName, false)) {
+ dayBox.classList.add("day-off");
+ }
+
+ if (date.month != mainMonth) {
+ dayBox.classList.add("out-of-month");
+ }
+
+ if (date.icalString in this.dayTable) {
+ this.dayTable[date.icalString].push(dayBox);
+ } else {
+ this.dayTable[date.icalString] = [dayBox];
+ }
+ date.day++;
+ }
+
+ return week;
+ },
+};
+
+/**
+ * A layout with seven days per page. The week layout is NOT aware of the
+ * start-of-week preferences. It always begins on a Monday.
+ */
+let weekPlannerView = {
+ dayTable: {},
+
+ /**
+ * Create the week planner view.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} startDate - the Monday of the first week to be displayed
+ * @param {calIDateTime} endDate - the Monday AFTER the last week to be displayed
+ * @param {integer} filter - calICalendar ITEM_FILTER flags
+ * @param {boolean} notDueTasks - if true, include tasks with no due date
+ */
+ async draw(document, startDate, endDate, filter, notDueTasks) {
+ let container = document.getElementById("week-container");
+
+ // Draw the week grid(s).
+ for (let current = startDate.clone(); current.compare(endDate) < 0; current.day += 7) {
+ container.appendChild(this.drawWeek(document, current));
+ }
+
+ // Get and display the items.
+ let items = await getItems(startDate, endDate, filter, notDueTasks);
+ let defaultTimezone = lazy.cal.dtz.defaultTimezone;
+ for (let item of items) {
+ let itemStartDate =
+ item[lazy.cal.dtz.startDateProp(item)] || item[lazy.cal.dtz.endDateProp(item)];
+ let itemEndDate =
+ item[lazy.cal.dtz.endDateProp(item)] || item[lazy.cal.dtz.startDateProp(item)];
+
+ if (!itemStartDate && !itemEndDate) {
+ addItemToDayboxNodate(document, item);
+ continue;
+ }
+ itemStartDate = itemStartDate.getInTimezone(defaultTimezone);
+ itemEndDate = itemEndDate.getInTimezone(defaultTimezone);
+
+ let boxDate = itemStartDate.clone();
+ boxDate.isDate = true;
+ for (boxDate; boxDate.compare(itemEndDate) < (itemEndDate.isDate ? 0 : 1); boxDate.day++) {
+ let boxDateString = boxDate.icalString;
+ if (boxDateString in this.dayTable) {
+ addItemToDaybox(document, item, boxDate, this.dayTable[boxDateString]);
+ }
+ }
+ }
+
+ // Set the page title.
+ let weeks = container.querySelectorAll("table");
+ if (weeks.length == 1) {
+ document.title = lazy.cal.l10n.getCalString("singleLongCalendarWeek", [weeks[0].number]);
+ } else {
+ document.title = lazy.cal.l10n.getCalString("severalLongCalendarWeeks", [
+ weeks[0].number,
+ weeks[weeks.length - 1].number,
+ ]);
+ }
+ },
+
+ /**
+ * Create one week from the template.
+ *
+ * @param {HTMLDocument} document
+ * @param {calIDateTime} monday - the Monday of the week
+ */
+ drawWeek(document, monday) {
+ // In the order they appear on the page.
+ const weekdayMap = [
+ "d1mondaysoff",
+ "d2tuesdaysoff",
+ "d3wednesdaysoff",
+ "d4thursdaysoff",
+ "d5fridaysoff",
+ "d6saturdaysoff",
+ "d0sundaysoff",
+ ];
+
+ let weekTemplate = document.getElementById("week-template");
+ let week = weekTemplate.content.firstElementChild.cloneNode(true);
+
+ // Set up the week number title
+ week.number = lazy.cal.weekInfoService.getWeekTitle(monday);
+ week.querySelector(".week-title").textContent = lazy.cal.l10n.getCalString("WeekTitle", [
+ week.number,
+ ]);
+
+ // Set up the day boxes
+ let currentDate = monday.clone();
+ for (let i = 0; i < 7; i++) {
+ let day = week.rows[1].cells[i];
+
+ let titleNode = day.querySelector(".day-title");
+ titleNode.textContent = lazy.cal.dtz.formatter.formatDateLong(currentDate);
+
+ this.dayTable[currentDate.icalString] = day.querySelector(".items");
+
+ if (Services.prefs.getBoolPref("calendar.week." + weekdayMap[i], false)) {
+ day.classList.add("day-off");
+ }
+
+ currentDate.day++;
+ }
+
+ return week;
+ },
+};
+
+Services.obs.addObserver(
+ {
+ async observe(subDialogWindow) {
+ if (!subDialogWindow.location.href.startsWith("chrome://global/content/print.html?")) {
+ return;
+ }
+
+ await new Promise(resolve =>
+ subDialogWindow.document.addEventListener("print-settings", resolve, { once: true })
+ );
+
+ if (
+ subDialogWindow.PrintEventHandler.activeCurrentURI !=
+ "chrome://calendar/content/printing-template.html"
+ ) {
+ return;
+ }
+
+ Services.scriptloader.loadSubScript(
+ "chrome://calendar/content/widgets/calendar-minimonth.js",
+ subDialogWindow
+ );
+ Services.scriptloader.loadSubScript(
+ "chrome://calendar/content/calendar-print.js",
+ subDialogWindow
+ );
+ },
+ },
+ "subdialog-loaded"
+);
diff --git a/comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm b/comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm
new file mode 100644
index 0000000000..ce76dbdd01
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calProviderDetectionUtils.jsm
@@ -0,0 +1,182 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["calproviderdetection"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+/**
+ * Code to call the calendar provider detection mechanism.
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider.detection namespace.
+
+/**
+ * The base class marker for detection errors. Useful in instanceof checks.
+ */
+class DetectionError extends Error {}
+
+/**
+ * Creates an error class that extends the base detection error.
+ *
+ * @param {string} aName - The name of the constructor, used for the base error class.
+ * @returns {DetectionError} A class extending DetectionError.
+ */
+function DetectionErrorClass(aName) {
+ return class extends DetectionError {
+ constructor(message) {
+ super(message);
+ this.name = aName;
+ }
+ };
+}
+
+/**
+ * The exported `calproviderdetection` object.
+ */
+var calproviderdetection = {
+ /**
+ * A map of providers that implement detection. Maps the type identifier
+ * (e.g. "ics", "caldav") to the provider object.
+ *
+ * @type {Map<string, calICalendarProvider>}
+ */
+ get providers() {
+ let providers = new Map();
+ for (let [type, provider] of cal.provider.providers) {
+ if (provider.detectCalendars) {
+ providers.set(type, provider);
+ }
+ }
+ return providers;
+ },
+
+ /**
+ * Known domains for Google OAuth. This is just to catch the most common case,
+ * MX entries should be checked for remaining cases.
+ *
+ * @type {Set<string>}
+ */
+ googleOAuthDomains: new Set(["gmail.com", "googlemail.com", "apidata.googleusercontent.com"]),
+
+ /**
+ * Translate location and username to an uri. If the location is empty, the
+ * domain part of the username is taken. If the location is a hostname it is
+ * converted to a https:// uri, if it is an uri string then use that.
+ *
+ * @param {string} aLocation - The location string.
+ * @param {string} aUsername - The username string.
+ * @returns {nsIURI} The resulting location uri.
+ */
+ locationToUri(aLocation, aUsername) {
+ let uri = null;
+ if (!aLocation) {
+ let match = aUsername.match(/[^@]+@([^.]+\..*)/);
+ if (match) {
+ uri = Services.io.newURI("https://" + match[1]);
+ }
+ } else if (aLocation.includes("://")) {
+ // Try to parse it as an uri
+ uri = Services.io.newURI(aLocation);
+ } else {
+ // Maybe its just a simple hostname
+ uri = Services.io.newURI("https://" + aLocation);
+ }
+ return uri;
+ },
+
+ /**
+ * Detect calendars using the given information. The location can be a number
+ * of things and handling this is up to the provider. It could be a hostname,
+ * a specific URL, the origin URL, etc.
+ *
+ * @param {string} aUsername - The username for logging in.
+ * @param {string} aPassword - The password for logging in.
+ * @param {string} aLocation - The location information.
+ * @param {boolean} aSavePassword - If true, the credentials will be saved
+ * in the password manager if used.
+ * @param {ProviderFilter[]} aPreDetectFilters - Functions for filtering out providers.
+ * @param {object} aExtraProperties - Extra properties to pass on to the
+ * providers.
+ * @returns {Promise<Map<string, calICalendar[]>>} A promise resolving with a Map of
+ * provider type to calendars found.
+ */
+ async detect(
+ aUsername,
+ aPassword,
+ aLocation,
+ aSavePassword,
+ aPreDetectFilters,
+ aExtraProperties
+ ) {
+ let providers = this.providers;
+
+ if (!providers.size) {
+ throw new calproviderdetection.NoneFoundError(
+ "No providers available that implement calendar detection"
+ );
+ }
+
+ // Filter out the providers that should not be used (for the location, username, etc.).
+ for (let func of aPreDetectFilters) {
+ let typesToFilterOut = func(providers.keys(), aLocation, aUsername);
+ typesToFilterOut.forEach(type => providers.delete(type));
+ }
+
+ let resolutions = await Promise.allSettled(
+ [...providers.values()].map(provider => {
+ let detectionResult = provider.detectCalendars(
+ aUsername,
+ aPassword,
+ aLocation,
+ aSavePassword,
+ aExtraProperties
+ );
+ return detectionResult.then(
+ result => ({ provider, status: Cr.NS_OK, detail: result }),
+ failure => ({ provider, status: Cr.NS_ERROR_FAILURE, detail: failure })
+ );
+ })
+ );
+
+ let failCount = 0;
+ let lastError;
+ let results = new Map(
+ resolutions.reduce((res, resolution) => {
+ let { provider, status, detail } = resolution.value || resolution.reason;
+
+ if (Components.isSuccessCode(status) && detail && detail.length) {
+ res.push([provider, detail]);
+ } else {
+ failCount++;
+ if (detail instanceof DetectionError) {
+ lastError = detail;
+ }
+ }
+
+ return res;
+ }, [])
+ );
+
+ // If everything failed due to one of the detection errors, then pass that on.
+ if (failCount == resolutions.length) {
+ throw lastError || new calproviderdetection.NoneFoundError();
+ }
+
+ return results;
+ },
+
+ /** The base detection error class */
+ Error: DetectionError,
+
+ /** An error that can be thrown if authentication failed */
+ AuthFailedError: DetectionErrorClass("AuthFailedError"),
+
+ /** An error that can be thrown if the location is invalid or has no calendars */
+ NoneFoundError: DetectionErrorClass("NoneFoundError"),
+
+ /** An error that can be thrown if the user canceled the operation */
+ CanceledError: DetectionErrorClass("CanceledError"),
+};
diff --git a/comm/calendar/base/modules/utils/calProviderUtils.jsm b/comm/calendar/base/modules/utils/calProviderUtils.jsm
new file mode 100644
index 0000000000..5e78aad37b
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calProviderUtils.jsm
@@ -0,0 +1,907 @@
+/* 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/. */
+
+var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+/**
+ * Helpers and base class for calendar providers
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider namespace.
+
+const EXPORTED_SYMBOLS = ["calprovider"];
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ cal: "resource:///modules/calendar/calUtils.jsm",
+ CalPeriod: "resource:///modules/CalPeriod.jsm",
+ CalReadableStreamFactory: "resource:///modules/CalReadableStreamFactory.jsm",
+});
+
+var calprovider = {
+ /**
+ * Prepare HTTP channel with standard request headers and upload data/content-type if needed.
+ *
+ * @param {nsIURI} aUri - The channel URI, only used for a new channel.
+ * @param {nsIInputStream | string} aUploadData - Data to be uploaded, if any. If a string,
+ * it will be converted to an nsIInputStream.
+ * @param {string} aContentType - Value for Content-Type header, if any.
+ * @param {nsIInterfaceRequestor} aNotificationCallbacks - Typically a CalDavRequestBase which
+ * implements nsIInterfaceRequestor and nsIChannelEventSink, and provides access to the
+ * calICalendar associated with the channel.
+ * @param {nsIChannel} [aExistingChannel] - An existing channel to modify (optional).
+ * @param {boolean} [aForceNewAuth=false] - If true, use a new user context to avoid cached
+ * authentication (see code comments). Optional, ignored if aExistingChannel is passed.
+ * @returns {nsIChannel} - The prepared channel.
+ */
+ prepHttpChannel(
+ aUri,
+ aUploadData,
+ aContentType,
+ aNotificationCallbacks,
+ aExistingChannel = null,
+ aForceNewAuth = false
+ ) {
+ let originAttributes = {};
+
+ // The current nsIHttpChannel implementation separates connections only
+ // by hosts, which causes issues with cookies and password caching for
+ // two or more simultaneous connections to the same host and different
+ // authenticated users. This can be solved by providing the additional
+ // userContextId, which also separates connections (a.k.a. containers).
+ // Connections for userA @ server1 and userA @ server2 can exist in the
+ // same container, as nsIHttpChannel will separate them. Connections
+ // for userA @ server1 and userB @ server1 however must be placed into
+ // different containers. It is therefore sufficient to add individual
+ // userContextIds per username.
+
+ if (aForceNewAuth) {
+ // A random "username" that won't be the same as any existing one.
+ // The value is not used for any other reason, so a UUID will do.
+ originAttributes.userContextId = lazy.cal.auth.containerMap.getUserContextIdForUsername(
+ lazy.cal.getUUID()
+ );
+ } else if (!aExistingChannel) {
+ try {
+ // Use a try/catch because there may not be a calICalendar interface.
+ // For example, when there is no calendar associated with a request,
+ // as in calendar detection.
+ let calendar = aNotificationCallbacks.getInterface(Ci.calICalendar);
+ if (calendar && calendar.getProperty("capabilities.username.supported") === true) {
+ originAttributes.userContextId = lazy.cal.auth.containerMap.getUserContextIdForUsername(
+ calendar.getProperty("username")
+ );
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NO_INTERFACE) {
+ throw e;
+ }
+ }
+ }
+
+ // We cannot use a system principal here since the connection setup will fail if
+ // same-site cookie protection is enabled in TB and server-side.
+ let principal = aExistingChannel
+ ? null
+ : Services.scriptSecurityManager.createContentPrincipal(aUri, originAttributes);
+ let channel =
+ aExistingChannel ||
+ Services.io.newChannelFromURI(
+ aUri,
+ null,
+ principal,
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ let httpchannel = channel.QueryInterface(Ci.nsIHttpChannel);
+
+ httpchannel.setRequestHeader("Accept", "text/xml", false);
+ httpchannel.setRequestHeader("Accept-Charset", "utf-8,*;q=0.1", false);
+ httpchannel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ httpchannel.notificationCallbacks = aNotificationCallbacks;
+
+ if (aUploadData) {
+ httpchannel = httpchannel.QueryInterface(Ci.nsIUploadChannel);
+ let stream;
+ if (aUploadData instanceof Ci.nsIInputStream) {
+ // Make sure the stream is reset
+ stream = aUploadData.QueryInterface(Ci.nsISeekableStream);
+ stream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0);
+ } else {
+ // Otherwise its something that should be a string, convert it.
+ stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.setUTF8Data(aUploadData, aUploadData.length);
+ }
+
+ httpchannel.setUploadStream(stream, aContentType, -1);
+ }
+
+ return httpchannel;
+ },
+
+ /**
+ * Send prepared HTTP request asynchronously
+ *
+ * @param {nsIStreamLoader} aStreamLoader - Stream loader for request
+ * @param {nsIChannel} aChannel - Channel for request
+ * @param {nsIStreamLoaderObserver} aListener - Listener for method completion
+ */
+ sendHttpRequest(aStreamLoader, aChannel, aListener) {
+ aStreamLoader.init(aListener);
+ aChannel.asyncOpen(aStreamLoader);
+ },
+
+ /**
+ * Shortcut to create an nsIStreamLoader
+ *
+ * @returns {nsIStreamLoader} A fresh streamloader
+ */
+ createStreamLoader() {
+ return Cc["@mozilla.org/network/stream-loader;1"].createInstance(Ci.nsIStreamLoader);
+ },
+
+ /**
+ * getInterface method for providers. This should be called in the context of
+ * the respective provider, i.e
+ *
+ * return cal.provider.InterfaceRequestor_getInterface.apply(this, arguments);
+ *
+ * or
+ * ...
+ * getInterface: cal.provider.InterfaceRequestor_getInterface,
+ * ...
+ *
+ * NOTE: If the server only provides one realm for all calendars, be sure that
+ * the |this| object implements calICalendar. In this case the calendar name
+ * will be appended to the realm. If you need that feature disabled, see the
+ * capabilities section of calICalendar.idl
+ *
+ * @param {nsIIDRef} aIID - The interface ID to return
+ * @returns {nsISupports} The requested interface
+ */
+ InterfaceRequestor_getInterface(aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ // Support Auth Prompt Interfaces
+ if (aIID.equals(Ci.nsIAuthPrompt2)) {
+ if (!this.calAuthPrompt) {
+ this.calAuthPrompt = new lazy.cal.auth.Prompt();
+ }
+ return this.calAuthPrompt;
+ } else if (aIID.equals(Ci.nsIAuthPromptProvider) || aIID.equals(Ci.nsIPrompt)) {
+ return Services.ww.getNewPrompter(null);
+ }
+ throw e;
+ }
+ },
+
+ /**
+ * Bad Certificate Handler for Network Requests. Shows the Network Exception
+ * Dialog if a certificate Problem occurs.
+ */
+ BadCertHandler: class {
+ /**
+ * @param {calICalendar} [calendar] - A calendar associated with the request, may be null.
+ */
+ constructor(calendar) {
+ this.calendar = calendar;
+ this.timer = null;
+ }
+
+ notifyCertProblem(secInfo, targetSite) {
+ // Unfortunately we can't pass js objects using the window watcher, so
+ // we'll just take the first available calendar window. We also need to
+ // do this on a timer so that the modal window doesn't block the
+ // network request.
+ let calWindow = lazy.cal.window.getCalendarWindow();
+
+ let timerCallback = {
+ calendar: this.calendar,
+ notify(timer) {
+ let params = {
+ exceptionAdded: false,
+ securityInfo: secInfo,
+ prefetchCert: true,
+ location: targetSite,
+ };
+ calWindow.openDialog(
+ "chrome://pippki/content/exceptionDialog.xhtml",
+ "",
+ "chrome,centerscreen,modal",
+ params
+ );
+ if (this.calendar && this.calendar.canRefresh && params.exceptionAdded) {
+ // Refresh the calendar if the exception certificate was added
+ this.calendar.refresh();
+ }
+ },
+ };
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this.timer.initWithCallback(timerCallback, 0, Ci.nsITimer.TYPE_ONE_SHOT);
+ return true;
+ }
+ },
+
+ /**
+ * Check for bad server certificates on SSL/TLS connections.
+ *
+ * @param {nsIRequest} request - request from the Stream loader.
+ * @param {number} status - A Components.results result.
+ * @param {calICalendar} [calendar] - A calendar associated with the request, may be null.
+ */
+ checkBadCertStatus(request, status, calendar) {
+ let nssErrorsService = Cc["@mozilla.org/nss_errors_service;1"].getService(
+ Ci.nsINSSErrorsService
+ );
+ let isCertError = false;
+ try {
+ let errorType = nssErrorsService.getErrorClass(status);
+ if (errorType == Ci.nsINSSErrorsService.ERROR_CLASS_BAD_CERT) {
+ isCertError = true;
+ }
+ } catch (e) {
+ // nsINSSErrorsService.getErrorClass throws if given a non-TLS, non-cert error, so ignore this.
+ }
+
+ if (isCertError && request.securityInfo) {
+ let secInfo = request.securityInfo.QueryInterface(Ci.nsITransportSecurityInfo);
+ let badCertHandler = new calprovider.BadCertHandler(calendar);
+ badCertHandler.notifyCertProblem(secInfo, request.originalURI.displayHostPort);
+ }
+ },
+
+ /**
+ * Freebusy interval implementation. All parameters are optional.
+ *
+ * @param aCalId The calendar id to set up with.
+ * @param aFreeBusyType The type from calIFreeBusyInterval.
+ * @param aStart The start of the interval.
+ * @param aEnd The end of the interval.
+ * @returns The fresh calIFreeBusyInterval.
+ */
+ FreeBusyInterval: class {
+ QueryInterface() {
+ return ChromeUtils.generateQI(["calIFreeBusyInterval"]);
+ }
+
+ constructor(aCalId, aFreeBusyType, aStart, aEnd) {
+ this.calId = aCalId;
+ this.interval = new lazy.CalPeriod();
+ this.interval.start = aStart;
+ this.interval.end = aEnd;
+
+ this.freeBusyType = aFreeBusyType || Ci.calIFreeBusyInterval.UNKNOWN;
+ }
+ },
+
+ /**
+ * Gets the iTIP/iMIP transport if the passed calendar has configured email.
+ *
+ * @param {calICalendar} aCalendar - The calendar to get the transport for
+ * @returns {?calIItipTransport} The email transport, or null if no identity configured
+ */
+ getImipTransport(aCalendar) {
+ // assure an identity is configured for the calendar
+ if (aCalendar && aCalendar.getProperty("imip.identity")) {
+ return this.defaultImipTransport;
+ }
+ return null;
+ },
+
+ /**
+ * Gets the configured identity and account of a particular calendar instance, or null.
+ *
+ * @param {calICalendar} aCalendar - Calendar instance
+ * @param {?object} outAccount - Optional out value for account
+ * @returns {nsIMsgIdentity} The configured identity
+ */
+ getEmailIdentityOfCalendar(aCalendar, outAccount) {
+ lazy.cal.ASSERT(aCalendar, "no calendar!", Cr.NS_ERROR_INVALID_ARG);
+ let key = aCalendar.getProperty("imip.identity.key");
+ if (key === null) {
+ // take default account/identity:
+ let findIdentity = function (account) {
+ if (account && account.identities.length) {
+ return account.defaultIdentity || account.identities[0];
+ }
+ return null;
+ };
+
+ let foundAccount = MailServices.accounts.defaultAccount;
+ let foundIdentity = findIdentity(foundAccount);
+
+ if (!foundAccount || !foundIdentity) {
+ for (let account of MailServices.accounts.accounts) {
+ let identity = findIdentity(account);
+
+ if (account && identity) {
+ foundAccount = account;
+ foundIdentity = identity;
+ break;
+ }
+ }
+ }
+
+ if (outAccount) {
+ outAccount.value = foundIdentity ? foundAccount : null;
+ }
+ return foundIdentity;
+ }
+ if (key.length == 0) {
+ // i.e. "None"
+ return null;
+ }
+ let identity = null;
+ lazy.cal.email.iterateIdentities((identity_, account) => {
+ if (identity_.key == key) {
+ identity = identity_;
+ if (outAccount) {
+ outAccount.value = account;
+ }
+ }
+ return identity_.key != key;
+ });
+
+ if (!identity) {
+ // dangling identity:
+ lazy.cal.WARN(
+ "Calendar " +
+ (aCalendar.uri ? aCalendar.uri.spec : aCalendar.id) +
+ " has a dangling E-Mail identity configured."
+ );
+ }
+ return identity;
+ },
+
+ /**
+ * Opens the calendar conflict dialog
+ *
+ * @param {string} aMode - The conflict mode, either "modify" or "delete"
+ * @param {calIItemBase} aItem - The item to raise a conflict for
+ * @returns {boolean} True, if the item should be overwritten
+ */
+ promptOverwrite(aMode, aItem) {
+ let window = lazy.cal.window.getCalendarWindow();
+ let args = {
+ item: aItem,
+ mode: aMode,
+ overwrite: false,
+ };
+
+ window.openDialog(
+ "chrome://calendar/content/calendar-conflicts-dialog.xhtml",
+ "calendarConflictsDialog",
+ "chrome,titlebar,modal",
+ args
+ );
+
+ return args.overwrite;
+ },
+
+ /**
+ * Gets the calendar directory, defaults to <profile-dir>/calendar-data
+ *
+ * @returns {nsIFile} The calendar-data directory as nsIFile
+ */
+ getCalendarDirectory() {
+ if (calprovider.getCalendarDirectory.mDir === undefined) {
+ let dir = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ dir.append("calendar-data");
+ if (!dir.exists()) {
+ try {
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o700);
+ } catch (exc) {
+ lazy.cal.ASSERT(false, exc);
+ throw exc;
+ }
+ }
+ calprovider.getCalendarDirectory.mDir = dir;
+ }
+ return calprovider.getCalendarDirectory.mDir.clone();
+ },
+
+ /**
+ * Base prototype to be used implementing a calICalendar.
+ */
+ BaseClass: class {
+ /**
+ * The transient properties that are not pesisted to storage
+ */
+ static get mTransientProperties() {
+ return {
+ "cache.uncachedCalendar": true,
+ currentStatus: true,
+ "itip.transport": true,
+ "imip.identity": true,
+ "imip.account": true,
+ "imip.identity.disabled": true,
+ organizerId: true,
+ organizerCN: true,
+ };
+ }
+
+ QueryInterface = ChromeUtils.generateQI(["calICalendar", "calISchedulingSupport"]);
+
+ /**
+ * Initialize the base class, this should be migrated to an ES6 constructor once all
+ * subclasses are also es6 classes. Call this from the constructor.
+ */
+ initProviderBase() {
+ this.wrappedJSObject = this;
+ this.mID = null;
+ this.mUri = null;
+ this.mACLEntry = null;
+ this.mBatchCount = 0;
+ this.transientProperties = false;
+ this.mObservers = new lazy.cal.data.ObserverSet(Ci.calIObserver);
+ this.mProperties = {};
+ this.mProperties.currentStatus = Cr.NS_OK;
+ }
+
+ /**
+ * Returns the calIObservers for this calendar
+ */
+ get observers() {
+ return this.mObservers;
+ }
+
+ // attribute AUTF8String id;
+ get id() {
+ return this.mID;
+ }
+ set id(aValue) {
+ if (this.mID) {
+ throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED);
+ }
+ this.mID = aValue;
+
+ // make all properties persistent that have been set so far:
+ for (let aName in this.mProperties) {
+ if (!this.constructor.mTransientProperties[aName]) {
+ let value = this.mProperties[aName];
+ if (value !== null) {
+ lazy.cal.manager.setCalendarPref_(this, aName, value);
+ }
+ }
+ }
+ }
+
+ // attribute AUTF8String name;
+ get name() {
+ return this.getProperty("name");
+ }
+ set name(aValue) {
+ this.setProperty("name", aValue);
+ }
+
+ // readonly attribute calICalendarACLManager aclManager;
+ get aclManager() {
+ const defaultACLProviderClass = "@mozilla.org/calendar/acl-manager;1?type=default";
+ let providerClass = this.getProperty("aclManagerClass");
+ if (!providerClass || !Cc[providerClass]) {
+ providerClass = defaultACLProviderClass;
+ }
+ return Cc[providerClass].getService(Ci.calICalendarACLManager);
+ }
+
+ // readonly attribute calICalendarACLEntry aclEntry;
+ get aclEntry() {
+ return this.mACLEntry;
+ }
+
+ // attribute calICalendar superCalendar;
+ get superCalendar() {
+ // If we have a superCalendar, check this calendar for a superCalendar.
+ // This will make sure the topmost calendar is returned
+ return this.mSuperCalendar ? this.mSuperCalendar.superCalendar : this;
+ }
+ set superCalendar(val) {
+ this.mSuperCalendar = val;
+ }
+
+ // attribute nsIURI uri;
+ get uri() {
+ return this.mUri;
+ }
+ set uri(aValue) {
+ this.mUri = aValue;
+ }
+
+ // attribute boolean readOnly;
+ get readOnly() {
+ return this.getProperty("readOnly");
+ }
+ set readOnly(aValue) {
+ this.setProperty("readOnly", aValue);
+ }
+
+ // readonly attribute boolean canRefresh;
+ get canRefresh() {
+ return false;
+ }
+
+ // void startBatch();
+ startBatch() {
+ if (this.mBatchCount++ == 0) {
+ this.mObservers.notify("onStartBatch", [this]);
+ }
+ }
+
+ // void endBatch();
+ endBatch() {
+ if (this.mBatchCount > 0) {
+ if (--this.mBatchCount == 0) {
+ this.mObservers.notify("onEndBatch", [this]);
+ }
+ } else {
+ lazy.cal.ASSERT(this.mBatchCount > 0, "unexpected endBatch!");
+ }
+ }
+
+ /**
+ * Implementation of calICalendar.getItems(). This should be overridden by
+ * all child classes.
+ *
+ * @param {number} itemFilter
+ * @param {number} count
+ * @param {calIDateTime} rangeStart
+ * @param {calIDateTime} rangeEnd
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ return lazy.CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ /**
+ * Implementation of calICalendar.getItemsAsArray().
+ *
+ * @param {number} itemFilter
+ * @param {number} count
+ * @param {calIDateTime} rangeStart
+ * @param {calIDateTime} rangeEnd
+ *
+ * @returns {calIItemBase[]}
+ */
+ async getItemsAsArray(itemFilter, count, rangeStart, rangeEnd) {
+ return lazy.cal.iterate.streamToArray(this.getItems(itemFilter, count, rangeStart, rangeEnd));
+ }
+
+ /**
+ * Notifies the given listener for onOperationComplete, ignoring (but logging) any
+ * exceptions that occur. If no listener is passed the function is a no-op.
+ *
+ * @param {?calIOperationListener} aListener - The listener to notify
+ * @param {number} aStatus - A Components.results result
+ * @param {number} aOperationType - The operation type component
+ * @param {string} aId - The item id
+ * @param {*} aDetail - The item detail for the listener
+ */
+ notifyPureOperationComplete(aListener, aStatus, aOperationType, aId, aDetail) {
+ if (aListener) {
+ try {
+ aListener.onOperationComplete(this.superCalendar, aStatus, aOperationType, aId, aDetail);
+ } catch (exc) {
+ lazy.cal.ERROR(exc);
+ }
+ }
+ }
+
+ /**
+ * Notifies the given listener for onOperationComplete, also setting various calendar status
+ * variables and notifying about the error.
+ *
+ * @param {?calIOperationListener} aListener - The listener to notify
+ * @param {number} aStatus - A Components.results result
+ * @param {number} aOperationType - The operation type component
+ * @param {string} aId - The item id
+ * @param {*} aDetail - The item detail for the listener
+ * @param {string} aExtraMessage - An extra message to pass to notifyError
+ */
+ notifyOperationComplete(aListener, aStatus, aOperationType, aId, aDetail, aExtraMessage) {
+ this.notifyPureOperationComplete(aListener, aStatus, aOperationType, aId, aDetail);
+
+ if (aStatus == Ci.calIErrors.OPERATION_CANCELLED) {
+ return; // cancellation doesn't change current status, no notification
+ }
+ if (Components.isSuccessCode(aStatus)) {
+ this.setProperty("currentStatus", aStatus);
+ } else {
+ if (aDetail instanceof Ci.nsIException) {
+ this.notifyError(aDetail); // will set currentStatus
+ } else {
+ this.notifyError(aStatus, aDetail); // will set currentStatus
+ }
+ this.notifyError(
+ aOperationType == Ci.calIOperationListener.GET
+ ? Ci.calIErrors.READ_FAILED
+ : Ci.calIErrors.MODIFICATION_FAILED,
+ aExtraMessage || ""
+ );
+ }
+ }
+
+ /**
+ * Notify observers using the onError notification with a readable error message
+ *
+ * @param {number | nsIException} aErrNo The error number from Components.results, or
+ * the exception which contains the error number
+ * @param {?string} aMessage - The message to show for the error
+ */
+ notifyError(aErrNo, aMessage = null) {
+ if (aErrNo == Ci.calIErrors.OPERATION_CANCELLED) {
+ return; // cancellation doesn't change current status, no notification
+ }
+ if (aErrNo instanceof Ci.nsIException) {
+ if (!aMessage) {
+ aMessage = aErrNo.message;
+ }
+ aErrNo = aErrNo.result;
+ }
+ this.setProperty("currentStatus", aErrNo);
+ this.observers.notify("onError", [this.superCalendar, aErrNo, aMessage]);
+ }
+
+ // nsIVariant getProperty(in AUTF8String aName);
+ getProperty(aName) {
+ switch (aName) {
+ case "itip.transport": // iTIP/iMIP default:
+ return calprovider.getImipTransport(this);
+ case "itip.notify-replies": // iTIP/iMIP default:
+ return Services.prefs.getBoolPref("calendar.itip.notify-replies", false);
+ // temporary hack to get the uncached calendar instance:
+ case "cache.uncachedCalendar":
+ return this;
+ }
+
+ let ret = this.mProperties[aName];
+ if (ret === undefined) {
+ ret = null;
+ switch (aName) {
+ case "imip.identity": // we want to cache the identity object a little, because
+ // it is heavily used by the invitation checks
+ ret = calprovider.getEmailIdentityOfCalendar(this);
+ break;
+ case "imip.account": {
+ let outAccount = {};
+ if (calprovider.getEmailIdentityOfCalendar(this, outAccount)) {
+ ret = outAccount.value;
+ }
+ break;
+ }
+ case "organizerId": {
+ // itip/imip default: derived out of imip.identity
+ let identity = this.getProperty("imip.identity");
+ ret = identity ? "mailto:" + identity.QueryInterface(Ci.nsIMsgIdentity).email : null;
+ break;
+ }
+ case "organizerCN": {
+ // itip/imip default: derived out of imip.identity
+ let identity = this.getProperty("imip.identity");
+ ret = identity ? identity.QueryInterface(Ci.nsIMsgIdentity).fullName : null;
+ break;
+ }
+ }
+ if (
+ ret === null &&
+ !this.constructor.mTransientProperties[aName] &&
+ !this.transientProperties
+ ) {
+ if (this.id) {
+ ret = lazy.cal.manager.getCalendarPref_(this, aName);
+ }
+ switch (aName) {
+ case "suppressAlarms":
+ if (this.getProperty("capabilities.alarms.popup.supported") === false) {
+ // If popup alarms are not supported,
+ // automatically suppress alarms
+ ret = true;
+ }
+ break;
+ }
+ }
+ this.mProperties[aName] = ret;
+ }
+ return ret;
+ }
+
+ // void setProperty(in AUTF8String aName, in nsIVariant aValue);
+ setProperty(aName, aValue) {
+ let oldValue = this.getProperty(aName);
+ if (oldValue != aValue) {
+ this.mProperties[aName] = aValue;
+ switch (aName) {
+ case "imip.identity.key": // invalidate identity and account object if key is set:
+ delete this.mProperties["imip.identity"];
+ delete this.mProperties["imip.account"];
+ delete this.mProperties.organizerId;
+ delete this.mProperties.organizerCN;
+ break;
+ }
+ if (!this.transientProperties && !this.constructor.mTransientProperties[aName] && this.id) {
+ lazy.cal.manager.setCalendarPref_(this, aName, aValue);
+ }
+ this.mObservers.notify("onPropertyChanged", [this.superCalendar, aName, aValue, oldValue]);
+ }
+ return aValue;
+ }
+
+ // void deleteProperty(in AUTF8String aName);
+ deleteProperty(aName) {
+ this.mObservers.notify("onPropertyDeleting", [this.superCalendar, aName]);
+ delete this.mProperties[aName];
+ lazy.cal.manager.deleteCalendarPref_(this, aName);
+ }
+
+ // calIOperation refresh
+ refresh() {
+ return null;
+ }
+
+ // void addObserver( in calIObserver observer );
+ addObserver(aObserver) {
+ this.mObservers.add(aObserver);
+ }
+
+ // void removeObserver( in calIObserver observer );
+ removeObserver(aObserver) {
+ this.mObservers.delete(aObserver);
+ }
+
+ // calISchedulingSupport: Implementation corresponding to our iTIP/iMIP support
+ isInvitation(aItem) {
+ if (!this.mACLEntry || !this.mACLEntry.hasAccessControl) {
+ // No ACL support - fallback to the old method
+ let id = aItem.getProperty("X-MOZ-INVITED-ATTENDEE") || this.getProperty("organizerId");
+ if (id) {
+ let org = aItem.organizer;
+ if (!org || !org.id || org.id.toLowerCase() == id.toLowerCase()) {
+ return false;
+ }
+ return aItem.getAttendeeById(id) != null;
+ }
+ return false;
+ }
+
+ let org = aItem.organizer;
+ if (!org || !org.id) {
+ // HACK
+ // if we don't have an organizer, this is perhaps because it's an exception
+ // to a recurring event. We check the parent item.
+ if (aItem.parentItem) {
+ org = aItem.parentItem.organizer;
+ if (!org || !org.id) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ // We check if :
+ // - the organizer of the event is NOT within the owner's identities of this calendar
+ // - if the one of the owner's identities of this calendar is in the attendees
+ let ownerIdentities = this.mACLEntry.getOwnerIdentities();
+ for (let i = 0; i < ownerIdentities.length; i++) {
+ let identity = "mailto:" + ownerIdentities[i].email.toLowerCase();
+ if (org.id.toLowerCase() == identity) {
+ return false;
+ }
+
+ if (aItem.getAttendeeById(identity) != null) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ // calIAttendee getInvitedAttendee(in calIItemBase aItem);
+ getInvitedAttendee(aItem) {
+ let id = this.getProperty("organizerId");
+ let attendee = id ? aItem.getAttendeeById(id) : null;
+
+ if (!attendee && this.mACLEntry && this.mACLEntry.hasAccessControl) {
+ let ownerIdentities = this.mACLEntry.getOwnerIdentities();
+ if (ownerIdentities.length > 0) {
+ let identity;
+ for (let i = 0; !attendee && i < ownerIdentities.length; i++) {
+ identity = "mailto:" + ownerIdentities[i].email.toLowerCase();
+ attendee = aItem.getAttendeeById(identity);
+ }
+ }
+ }
+
+ return attendee;
+ }
+
+ // boolean canNotify(in AUTF8String aMethod, in calIItemBase aItem);
+ canNotify(aMethod, aItem) {
+ return false; // use outbound iTIP for all
+ }
+ },
+
+ // Provider Registration
+
+ /**
+ * Register a provider.
+ *
+ * @param {calICalendarProvider} provider - The provider object.
+ */
+ register(provider) {
+ this.providers.set(provider.type, provider);
+ },
+
+ /**
+ * Unregister a provider.
+ *
+ * @param {string} type - The type of the provider to unregister.
+ * @returns {boolean} True if the provider was unregistered, false if
+ * it was not registered in the first place.
+ */
+ unregister(type) {
+ return this.providers.delete(type);
+ },
+
+ /**
+ * Get a provider by its type property, e.g. "ics", "caldav".
+ *
+ * @param {string} type - Type of the provider to get.
+ * @returns {calICalendarProvider | undefined} Provider or undefined if none
+ * is registered for the type.
+ */
+ byType(type) {
+ return this.providers.get(type);
+ },
+
+ /**
+ * The built-in "ics" provider.
+ *
+ * @type {calICalendarProvider}
+ */
+ get ics() {
+ return this.byType("ics");
+ },
+
+ /**
+ * The built-in "caldav" provider.
+ *
+ * @type {calICalendarProvider}
+ */
+ get caldav() {
+ return this.byType("caldav");
+ },
+};
+
+// Initialize `cal.provider.providers` with the built-in providers.
+XPCOMUtils.defineLazyGetter(calprovider, "providers", () => {
+ const { CalICSProvider } = ChromeUtils.import("resource:///modules/CalICSProvider.jsm");
+ const { CalDavProvider } = ChromeUtils.import("resource:///modules/CalDavProvider.jsm");
+ return new Map([
+ ["ics", CalICSProvider],
+ ["caldav", CalDavProvider],
+ ]);
+});
+
+// This is the transport returned by getImipTransport().
+XPCOMUtils.defineLazyGetter(calprovider, "defaultImipTransport", () => {
+ const { CalItipEmailTransport } = ChromeUtils.import(
+ "resource:///modules/CalItipEmailTransport.jsm"
+ );
+ return CalItipEmailTransport.createInstance();
+});
+
+// Set up the `cal.provider.detection` module.
+XPCOMUtils.defineLazyModuleGetter(
+ calprovider,
+ "detection",
+ "resource:///modules/calendar/utils/calProviderDetectionUtils.jsm",
+ "calproviderdetection"
+);
diff --git a/comm/calendar/base/modules/utils/calUnifinderUtils.jsm b/comm/calendar/base/modules/utils/calUnifinderUtils.jsm
new file mode 100644
index 0000000000..aeaf8d24ed
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calUnifinderUtils.jsm
@@ -0,0 +1,206 @@
+/* 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/. */
+
+/**
+ * Helpers for the unifinder
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.unifinder namespace.
+
+const EXPORTED_SYMBOLS = ["calunifinder"];
+
+var calunifinder = {
+ /**
+ * Retrieves the value that is used for comparison for the item with the given
+ * property.
+ *
+ * @param {calIItemBaes} aItem - The item to retrieve the sort key for
+ * @param {string} aKey - The property name that should be sorted
+ * @returns {*} The value used in sort comparison
+ */
+ getItemSortKey(aItem, aKey) {
+ const taskStatus = ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"];
+ const eventStatus = ["TENTATIVE", "CONFIRMED", "CANCELLED"];
+
+ switch (aKey) {
+ case "priority":
+ return aItem.priority || 5;
+
+ case "title":
+ return aItem.title || "";
+
+ case "entryDate":
+ case "startDate":
+ case "dueDate":
+ case "endDate":
+ case "completedDate":
+ if (aItem[aKey] == null) {
+ return -62168601600000000; // ns value for (0000/00/00 00:00:00)
+ }
+ return aItem[aKey].nativeTime;
+
+ case "percentComplete":
+ return aItem.percentComplete;
+
+ case "categories":
+ return aItem.getCategories().join(", ");
+
+ case "location":
+ return aItem.getProperty("LOCATION") || "";
+
+ case "status": {
+ let statusSet = aItem.isEvent() ? eventStatus : taskStatus;
+ return statusSet.indexOf(aItem.status);
+ }
+ case "calendar":
+ return aItem.calendar.name || "";
+
+ default:
+ return null;
+ }
+ },
+
+ /**
+ * Returns a sort function for the given sort type.
+ *
+ * @param {string} aSortKey - The sort key to get the compare function for
+ * @returns {Function} The function to be used for sorting values of the type
+ */
+ sortEntryComparer(aSortKey) {
+ switch (aSortKey) {
+ case "title":
+ case "categories":
+ case "location":
+ case "calendar":
+ return sortCompare.string;
+
+ // All dates use "date_filled"
+ case "completedDate":
+ case "startDate":
+ case "endDate":
+ case "dueDate":
+ case "entryDate":
+ return sortCompare.date_filled;
+
+ case "priority":
+ case "percentComplete":
+ case "status":
+ return sortCompare.number;
+ default:
+ return sortCompare.unknown;
+ }
+ },
+
+ /**
+ * Sort the unifinder items by the given sort key, using the modifier to flip direction. The
+ * items are sorted in place.
+ *
+ * @param {calIItemBase[]} aItems - The items to sort
+ * @param {string} aSortKey - The item sort key
+ * @param {?number} aModifier - Either 1 or -1, to indicate sort direction
+ */
+ sortItems(aItems, aSortKey, aModifier = 1) {
+ let comparer = calunifinder.sortEntryComparer(aSortKey);
+ aItems.sort((a, b) => {
+ let sortvalA = calunifinder.getItemSortKey(a, aSortKey);
+ let sortvalB = calunifinder.getItemSortKey(b, aSortKey);
+ return comparer(sortvalA, sortvalB, aModifier);
+ });
+ },
+};
+
+/**
+ * Sort compare functions that can be used with Array sort(). The modifier can flip the sort
+ * direction by passing -1 or 1.
+ */
+const sortCompare = (calunifinder.sortEntryComparer._sortCompare = {
+ /**
+ * Compare two things as if they were numbers.
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ number(a, b, modifier = 1) {
+ return sortCompare.general(Number(a), Number(b), modifier);
+ },
+
+ /**
+ * Compare two things as if they were dates.
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ date(a, b, modifier = 1) {
+ return sortCompare.general(a, b, modifier);
+ },
+
+ /**
+ * Compare two things generally, using the typical ((a > b) - (a < b))
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ general(a, b, modifier = 1) {
+ return ((a > b) - (a < b)) * modifier;
+ },
+
+ /**
+ * Compare two dates, keeping the nativeTime zero date in mind.
+ *
+ * @param {*} a - The first date to compare
+ * @param {*} b - The second date to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ date_filled(a, b, modifier = 1) {
+ const NULL_DATE = -62168601600000000;
+
+ if (a == b) {
+ return 0;
+ } else if (a == NULL_DATE) {
+ return 1;
+ } else if (b == NULL_DATE) {
+ return -1;
+ }
+ return sortCompare.general(a, b, modifier);
+ },
+
+ /**
+ * Compare two strings, sorting empty values to the end by default
+ *
+ * @param {*} a - The first string to compare
+ * @param {*} b - The second string to compare
+ * @param {number} modifier - -1 to flip direction, or 1
+ * @returns {number} Either -1, 0, or 1
+ */
+ string(a, b, modifier = 1) {
+ if (a.length == 0 || b.length == 0) {
+ // sort empty values to end (so when users first sort by a
+ // column, they can see and find the desired values in that
+ // column without scrolling past all the empty values).
+ return -(a.length - b.length) * modifier;
+ }
+
+ return a.localeCompare(b, undefined, { numeric: true }) * modifier;
+ },
+
+ /**
+ * Catch-all function to compare two unknown values. Will return 0.
+ *
+ * @param {*} a - The first thing to compare
+ * @param {*} b - The second thing to compare
+ * @param {number} modifier - Provided for consistency, but unused
+ * @returns {number} Will always return 0
+ */
+ unknown(a, b, modifier = 1) {
+ return 0;
+ },
+});
diff --git a/comm/calendar/base/modules/utils/calViewUtils.jsm b/comm/calendar/base/modules/utils/calViewUtils.jsm
new file mode 100644
index 0000000000..dd4d1fd9ba
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calViewUtils.jsm
@@ -0,0 +1,521 @@
+/* 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/. */
+
+/**
+ * View and DOM related helper functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.view namespace.
+
+const EXPORTED_SYMBOLS = ["calview"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gParserUtils",
+ "@mozilla.org/parserutils;1",
+ "nsIParserUtils"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gTextToHtmlConverter",
+ "@mozilla.org/txttohtmlconv;1",
+ "mozITXTToHTMLConv"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "calendarSortOrder",
+ "calendar.list.sortOrder",
+ null,
+ null,
+ val => (val ? val.split(" ") : [])
+);
+
+var calview = {
+ /**
+ * Returns a parentnode - or the passed node - with the given attribute
+ * value for the given attributename by traversing up the DOM hierarchy.
+ *
+ * @param aChildNode The childnode.
+ * @param aAttibuteName The name of the attribute that is to be compared with
+ * @param aAttibuteValue The value of the attribute that is to be compared with
+ * @returns The parent with the given attributeName set that has
+ * the same value as the given given attributevalue
+ * 'aAttributeValue'. If no appropriate
+ * parent node can be retrieved it is returned 'null'.
+ */
+ getParentNodeOrThisByAttribute(aChildNode, aAttributeName, aAttributeValue) {
+ let node = aChildNode;
+ while (node && node.getAttribute(aAttributeName) != aAttributeValue) {
+ node = node.parentNode;
+ if (node.tagName == undefined) {
+ return null;
+ }
+ }
+ return node;
+ },
+
+ /**
+ * Format the given string to work inside a CSS rule selector
+ * (and as part of a non-unicode preference key).
+ *
+ * Replaces each space ' ' char with '_'.
+ * Replaces each char other than ascii digits and letters, with '-uxHHH-'
+ * where HHH is unicode in hexadecimal (variable length, terminated by the '-').
+ *
+ * Ensures: result only contains ascii digits, letters,'-', and '_'.
+ * Ensures: result is invertible, so (f(a) = f(b)) implies (a = b).
+ * also means f is not idempotent, so (a != f(a)) implies (f(a) != f(f(a))).
+ * Ensures: result must be lowercase.
+ * Rationale: preference keys require 8bit chars, and ascii chars are legible
+ * in most fonts (in case user edits PROFILE/prefs.js).
+ * CSS class names in Gecko 1.8 seem to require lowercase,
+ * no punctuation, and of course no spaces.
+ * nmchar [_a-zA-Z0-9-]|{nonascii}|{escape}
+ * name {nmchar}+
+ * http://www.w3.org/TR/CSS21/grammar.html#scanner
+ *
+ * @param aString The unicode string to format
+ * @returns The formatted string using only chars [_a-zA-Z0-9-]
+ */
+ formatStringForCSSRule(aString) {
+ function toReplacement(char) {
+ // char code is natural number (positive integer)
+ let nat = char.charCodeAt(0);
+ switch (nat) {
+ case 0x20: // space
+ return "_";
+ default:
+ return "-ux" + nat.toString(16) + "-"; // lowercase
+ }
+ }
+ // Result must be lowercase or style rule will not work.
+ return aString.toLowerCase().replace(/[^a-zA-Z0-9]/g, toReplacement);
+ },
+
+ /**
+ * Gets the cached instance of the composite calendar.
+ *
+ * @param aWindow The window to get the composite calendar for.
+ */
+ getCompositeCalendar(aWindow) {
+ if (typeof aWindow._compositeCalendar == "undefined") {
+ let comp = (aWindow._compositeCalendar = Cc[
+ "@mozilla.org/calendar/calendar;1?type=composite"
+ ].createInstance(Ci.calICompositeCalendar));
+ const prefix = "calendar-main";
+
+ const calManagerObserver = {
+ QueryInterface: ChromeUtils.generateQI([Ci.calICalendarManagerObserver]),
+
+ onCalendarRegistered(calendar) {
+ let inComposite = calendar.getProperty(prefix + "-in-composite");
+ if (inComposite === null && !calendar.getProperty("disabled")) {
+ comp.addCalendar(calendar);
+ }
+ },
+ onCalendarUnregistering(calendar) {
+ comp.removeCalendar(calendar);
+ if (!comp.defaultCalendar || comp.defaultCalendar.id == calendar.id) {
+ comp.defaultCalendar = comp.getCalendars()[0];
+ }
+ },
+ onCalendarDeleting(calendar) {},
+ };
+ lazy.cal.manager.addObserver(calManagerObserver);
+ aWindow.addEventListener("unload", () => lazy.cal.manager.removeObserver(calManagerObserver));
+
+ comp.prefPrefix = prefix; // populate calendar from existing calendars
+
+ if (typeof aWindow.gCalendarStatusFeedback != "undefined") {
+ // If we are in a window that has calendar status feedback, set
+ // up our status observer.
+ comp.setStatusObserver(aWindow.gCalendarStatusFeedback, aWindow);
+ }
+ }
+ return aWindow._compositeCalendar;
+ },
+
+ /**
+ * Hash the given string into a color from the color palette of the standard
+ * color picker.
+ *
+ * @param str The string to hash into a color.
+ * @returns The hashed color.
+ */
+ hashColor(str) {
+ // This is the palette of colors in the current colorpicker implementation.
+ // Unfortunately, there is no easy way to extract these colors from the
+ // binding directly.
+ const colorPalette = [
+ "#FFFFFF",
+ "#FFCCCC",
+ "#FFCC99",
+ "#FFFF99",
+ "#FFFFCC",
+ "#99FF99",
+ "#99FFFF",
+ "#CCFFFF",
+ "#CCCCFF",
+ "#FFCCFF",
+ "#CCCCCC",
+ "#FF6666",
+ "#FF9966",
+ "#FFFF66",
+ "#FFFF33",
+ "#66FF99",
+ "#33FFFF",
+ "#66FFFF",
+ "#9999FF",
+ "#FF99FF",
+ "#C0C0C0",
+ "#FF0000",
+ "#FF9900",
+ "#FFCC66",
+ "#FFFF00",
+ "#33FF33",
+ "#66CCCC",
+ "#33CCFF",
+ "#6666CC",
+ "#CC66CC",
+ "#999999",
+ "#CC0000",
+ "#FF6600",
+ "#FFCC33",
+ "#FFCC00",
+ "#33CC00",
+ "#00CCCC",
+ "#3366FF",
+ "#6633FF",
+ "#CC33CC",
+ "#666666",
+ "#990000",
+ "#CC6600",
+ "#CC9933",
+ "#999900",
+ "#009900",
+ "#339999",
+ "#3333FF",
+ "#6600CC",
+ "#993399",
+ "#333333",
+ "#660000",
+ "#993300",
+ "#996633",
+ "#666600",
+ "#006600",
+ "#336666",
+ "#000099",
+ "#333399",
+ "#663366",
+ "#000000",
+ "#330000",
+ "#663300",
+ "#663333",
+ "#333300",
+ "#003300",
+ "#003333",
+ "#000066",
+ "#330099",
+ "#330033",
+ ];
+
+ let sum = Array.from(str || " ", e => e.charCodeAt(0)).reduce((a, b) => a + b);
+ return colorPalette[sum % colorPalette.length];
+ },
+
+ /**
+ * Pick whichever of "black" or "white" will look better when used as a text
+ * color against a background of bgColor.
+ *
+ * @param bgColor the background color as a "#RRGGBB" string
+ */
+ getContrastingTextColor(bgColor) {
+ let calcColor = bgColor.replace(/#/g, "");
+ let red = parseInt(calcColor.substring(0, 2), 16);
+ let green = parseInt(calcColor.substring(2, 4), 16);
+ let blue = parseInt(calcColor.substring(4, 6), 16);
+
+ // Calculate the brightness (Y) value using the YUV color system.
+ let brightness = 0.299 * red + 0.587 * green + 0.114 * blue;
+
+ // Consider all colors with less than 56% brightness as dark colors and
+ // use white as the foreground color, otherwise use black.
+ if (brightness < 144) {
+ return "white";
+ }
+
+ return "#222";
+ },
+
+ /**
+ * Item comparator for inserting items into dayboxes.
+ *
+ * @param a The first item
+ * @param b The second item
+ * @returns The usual -1, 0, 1
+ */
+ compareItems(a, b) {
+ if (!a) {
+ return -1;
+ }
+ if (!b) {
+ return 1;
+ }
+
+ let aIsEvent = a.isEvent();
+ let aIsTodo = a.isTodo();
+
+ let bIsEvent = b.isEvent();
+ let bIsTodo = b.isTodo();
+
+ // sort todos before events
+ if (aIsTodo && bIsEvent) {
+ return -1;
+ }
+ if (aIsEvent && bIsTodo) {
+ return 1;
+ }
+
+ // sort items of the same type according to date-time
+ let aStartDate = a.startDate || a.entryDate || a.dueDate;
+ let bStartDate = b.startDate || b.entryDate || b.dueDate;
+ let aEndDate = a.endDate || a.dueDate || a.entryDate;
+ let bEndDate = b.endDate || b.dueDate || b.entryDate;
+ if (!aStartDate || !bStartDate) {
+ return 0;
+ }
+
+ // sort all day events before events with a duration
+ if (aStartDate.isDate && !bStartDate.isDate) {
+ return -1;
+ }
+ if (!aStartDate.isDate && bStartDate.isDate) {
+ return 1;
+ }
+
+ let cmp = aStartDate.compare(bStartDate);
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ if (!aEndDate || !bEndDate) {
+ return 0;
+ }
+ cmp = aEndDate.compare(bEndDate);
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ if (a.calendar && b.calendar) {
+ cmp =
+ lazy.calendarSortOrder.indexOf(a.calendar.id) -
+ lazy.calendarSortOrder.indexOf(b.calendar.id);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+
+ cmp = (a.title > b.title) - (a.title < b.title);
+ return cmp;
+ },
+
+ get calendarSortOrder() {
+ return lazy.calendarSortOrder;
+ },
+
+ /**
+ * Converts plain or HTML text into an HTML document fragment.
+ *
+ * @param {string} text - The text to convert.
+ * @param {Document} doc - The document where the fragment will be appended.
+ * @param {string} html - HTML if it's already available.
+ * @returns {DocumentFragment} An HTML document fragment.
+ */
+ textToHtmlDocumentFragment(text, doc, html) {
+ if (!html) {
+ let mode =
+ Ci.mozITXTToHTMLConv.kStructPhrase |
+ Ci.mozITXTToHTMLConv.kGlyphSubstitution |
+ Ci.mozITXTToHTMLConv.kURLs;
+ html = lazy.gTextToHtmlConverter.scanTXT(text, mode);
+ html = html.replace(/\r?\n/g, "<br>");
+ }
+
+ // Sanitize and convert the HTML into a document fragment.
+ let flags =
+ lazy.gParserUtils.SanitizerLogRemovals |
+ lazy.gParserUtils.SanitizerDropForms |
+ lazy.gParserUtils.SanitizerDropMedia;
+
+ let uri = Services.io.newURI(doc.baseURI);
+ return lazy.gParserUtils.parseFragment(html, flags, false, uri, doc.createElement("div"));
+ },
+
+ /**
+ * Correct the description of a Google Calendar item so that it will display
+ * as intended.
+ *
+ * @param {calIItemBase} item - The item to correct.
+ */
+ fixGoogleCalendarDescription(item) {
+ // Google Calendar inserts bare HTML into its description field instead of
+ // using the standard Alternate Text Representation mechanism. However,
+ // the HTML is a poor representation of how it displays descriptions on
+ // the site: links may be included as bare URLs and line breaks may be
+ // included as raw newlines, so in order to display descriptions as Google
+ // intends, we need to make some corrections.
+ if (item.descriptionText) {
+ // Convert HTML entities which scanHTML won't handle into their standard
+ // text representation.
+ let description = item.descriptionText.replace(/&#?\w+;?/g, potentialEntity => {
+ // Attempt to parse the pattern match as an HTML entity.
+ let body = new DOMParser().parseFromString(potentialEntity, "text/html").body;
+
+ // Don't replace text that didn't parse as an entity or that parsed as
+ // an entity which could break HTML parsing below.
+ return body.innerText.length == 1 && !'"&<>'.includes(body.innerText)
+ ? body.innerText
+ : potentialEntity;
+ });
+
+ // Replace bare URLs with links and convert remaining entities.
+ description = lazy.gTextToHtmlConverter.scanHTML(description, Ci.mozITXTToHTMLConv.kURLs);
+
+ // Setting the HTML description will mark the item dirty, but we want to
+ // avoid unnecessary updates; preserve modification time.
+ let stamp = item.stampTime;
+ let lastModified = item.lastModifiedTime;
+
+ item.descriptionHTML = description.replace(/\r?\n/g, "<br>");
+
+ // Restore modification time.
+ item.setProperty("DTSTAMP", stamp);
+ item.setProperty("LAST-MODIFIED", lastModified);
+ }
+ },
+};
+
+/**
+ * Adds CSS variables for each calendar to registered windows for coloring
+ * UI elements. Automatically tracks calendar creation, changes, and deletion.
+ */
+calview.colorTracker = {
+ calendars: null,
+ categoryBranch: null,
+ windows: new Set(),
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver", "calIObserver"]),
+
+ // Deregistration is not required.
+ registerWindow(aWindow) {
+ if (this.calendars === null) {
+ this.calendars = new Set(lazy.cal.manager.getCalendars());
+ lazy.cal.manager.addObserver(this);
+ lazy.cal.manager.addCalendarObserver(this);
+
+ this.categoryBranch = Services.prefs.getBranch("calendar.category.color.");
+ this.categoryBranch.addObserver("", this);
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ }
+
+ this.windows.add(aWindow);
+ aWindow.addEventListener("unload", () => this.windows.delete(aWindow));
+
+ this.addColorsToDocument(aWindow.document);
+ },
+ addColorsToDocument(aDocument) {
+ for (let calendar of this.calendars) {
+ this._addCalendarToDocument(aDocument, calendar);
+ }
+ this._addAllCategoriesToDocument(aDocument);
+ },
+
+ _addCalendarToDocument(aDocument, aCalendar) {
+ let cssSafeId = calview.formatStringForCSSRule(aCalendar.id);
+ let style = aDocument.documentElement.style;
+ let backColor = aCalendar.getProperty("color") || "#a8c2e1";
+ let foreColor = calview.getContrastingTextColor(backColor);
+ style.setProperty(`--calendar-${cssSafeId}-backcolor`, backColor);
+ style.setProperty(`--calendar-${cssSafeId}-forecolor`, foreColor);
+ },
+ _removeCalendarFromDocument(aDocument, aCalendar) {
+ let cssSafeId = calview.formatStringForCSSRule(aCalendar.id);
+ let style = aDocument.documentElement.style;
+ style.removeProperty(`--calendar-${cssSafeId}-backcolor`);
+ style.removeProperty(`--calendar-${cssSafeId}-forecolor`);
+ },
+ _addCategoryToDocument(aDocument, aCategoryName) {
+ // aCategoryName should already be formatted for CSS, because that's
+ // what is stored in the prefs, and this function is only called with
+ // arguments that come from the prefs.
+ if (/[^\w-]/.test(aCategoryName)) {
+ return;
+ }
+
+ let style = aDocument.documentElement.style;
+ let color = this.categoryBranch.getStringPref(aCategoryName, "");
+ if (color == "") {
+ // Don't use the getStringPref default, the value might actually be ""
+ // and we don't want that.
+ color = "transparent";
+ }
+ style.setProperty(`--category-${aCategoryName}-color`, color);
+ },
+ _addAllCategoriesToDocument(aDocument) {
+ for (let categoryName of this.categoryBranch.getChildList("")) {
+ this._addCategoryToDocument(aDocument, categoryName);
+ }
+ },
+
+ // calICalendarManagerObserver methods
+ onCalendarRegistered(aCalendar) {
+ this.calendars.add(aCalendar);
+ for (let window of this.windows) {
+ this._addCalendarToDocument(window.document, aCalendar);
+ }
+ },
+ onCalendarUnregistering(aCalendar) {
+ this.calendars.delete(aCalendar);
+ for (let window of this.windows) {
+ this._removeCalendarFromDocument(window.document, aCalendar);
+ }
+ },
+ onCalendarDeleting(aCalendar) {},
+
+ // calIObserver methods
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem(aItem) {},
+ onModifyItem(aNewItem, aOldItem) {},
+ onDeleteItem(aDeletedItem) {},
+ onError(aCalendar, aErrNo, aMessage) {},
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ if (aName == "color") {
+ for (let window of this.windows) {
+ this._addCalendarToDocument(window.document, aCalendar);
+ }
+ }
+ },
+ onPropertyDeleting(aCalendar, aName) {},
+
+ // nsIObserver method
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ for (let window of this.windows) {
+ this._addCategoryToDocument(window.document, aData);
+ }
+ // TODO Currently, the only way to find out if categories are removed is
+ // to initially grab the calendar.categories.names preference and then
+ // observe changes to it. It would be better if we had hooks for this.
+ } else if (aTopic == "xpcom-shutdown") {
+ this.categoryBranch.removeObserver("", this);
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ }
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calWindowUtils.jsm b/comm/calendar/base/modules/utils/calWindowUtils.jsm
new file mode 100644
index 0000000000..21626d9ef0
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calWindowUtils.jsm
@@ -0,0 +1,182 @@
+/* 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/. */
+
+/**
+ * Calendar window helpers, e.g. to open our dialogs
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.window namespace.
+
+const EXPORTED_SYMBOLS = ["calwindow"];
+
+const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "l10nDeletePrompt",
+ () => new Localization(["calendar/calendar-delete-prompt.ftl"], true)
+);
+
+var calwindow = {
+ /**
+ * Opens the Create Calendar wizard
+ *
+ * @param aWindow the window to open the dialog on, or null for the main calendar window
+ * @param aCallback a function to be performed after calendar creation
+ */
+ openCalendarWizard(aWindow, aCallback) {
+ let window = aWindow || calwindow.getCalendarWindow();
+ window.openDialog(
+ "chrome://calendar/content/calendar-creation.xhtml",
+ "caEditServer",
+ "chrome,titlebar,resizable,centerscreen",
+ aCallback
+ );
+ },
+
+ /**
+ * @typedef {object} OpenCalendarPropertiesArgs
+ * @property {calICalendar} calendar - The calendar whose properties should be displayed.
+ * @property {boolean} [canDisable=true] - Whether the user can disable the calendar.
+ */
+
+ /**
+ * Opens the calendar properties window for aCalendar.
+ *
+ * @param {ChromeWindow | null} aWindow The window to open the dialog on,
+ * or null for the main calendar window.
+ * @param {OpenCalendarPropertiesArgs} args - Passed directly to the window.
+ */
+ openCalendarProperties(aWindow, args) {
+ let window = aWindow || calwindow.getCalendarWindow();
+ window.openDialog(
+ "chrome://calendar/content/calendar-properties-dialog.xhtml",
+ "CalendarPropertiesDialog",
+ "chrome,titlebar,resizable,centerscreen",
+ { canDisable: true, ...args }
+ );
+ },
+
+ /**
+ * Returns the most recent calendar window in an application independent way
+ */
+ getCalendarWindow() {
+ return (
+ Services.wm.getMostRecentWindow("calendarMainWindow") ||
+ Services.wm.getMostRecentWindow("mail:3pane")
+ );
+ },
+
+ /**
+ * Open (or focus if already open) the calendar tab, even if the imip bar is
+ * in a message window, and even if there is no main three pane Thunderbird
+ * window open. Called when clicking the imip bar's calendar button.
+ */
+ goToCalendar() {
+ let openCal = mainWindow => {
+ mainWindow.focus();
+ mainWindow.document.getElementById("tabmail").openTab("calendar");
+ };
+
+ let mainWindow = Services.wm.getMostRecentWindow("mail:3pane");
+
+ if (mainWindow) {
+ openCal(mainWindow);
+ } else {
+ mainWindow = Services.ww.openWindow(
+ null,
+ "chrome://messenger/content/messenger.xhtml",
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar",
+ null
+ );
+
+ // Wait until calendar is set up in the new window.
+ let calStartupObserver = {
+ observe(subject, topic, data) {
+ openCal(mainWindow);
+ Services.obs.removeObserver(calStartupObserver, "calendar-startup-done");
+ },
+ };
+ Services.obs.addObserver(calStartupObserver, "calendar-startup-done");
+ }
+ },
+
+ /**
+ * Brings up a dialog prompting the user about the deletion of the passed
+ * item(s).
+ *
+ * @param {calIItemBase|calItemBase[]} items - One or more items that will be deleted.
+ * @param {boolean} byPassPref - If true the pref for this prompt will be ignored.
+ *
+ * @returns {boolean} True if the user confirms deletion, false if otherwise.
+ */
+ promptDeleteItems(items, byPassPref) {
+ items = Array.isArray(items) ? items : [items];
+ let pref = Services.prefs.getBoolPref("calendar.item.promptDelete", true);
+
+ // Recurring events will be handled by the recurring event prompt.
+ if ((!pref && !byPassPref) || items.some(item => item.parentItem != item)) {
+ return true;
+ }
+
+ let deletingEvents;
+ let deletingTodos;
+ for (let item of items) {
+ if (!deletingEvents) {
+ deletingEvents = item.isEvent();
+ }
+ if (!deletingTodos) {
+ deletingTodos = item.isTodo();
+ }
+ }
+
+ let title;
+ let message;
+ let disableMessage;
+ if (deletingEvents && !deletingTodos) {
+ [title, message, disableMessage] = lazy.l10nDeletePrompt.formatValuesSync([
+ { id: "calendar-delete-event-prompt-title", args: { count: items.length } },
+ { id: "calendar-delete-event-prompt-message", args: { count: items.length } },
+ "calendar-delete-prompt-disable-message",
+ ]);
+ } else if (!deletingEvents && deletingTodos) {
+ [title, message, disableMessage] = lazy.l10nDeletePrompt.formatValuesSync([
+ { id: "calendar-delete-task-prompt-title", args: { count: items.length } },
+ { id: "calendar-delete-task-prompt-message", args: { count: items.length } },
+ "calendar-delete-prompt-disable-message",
+ ]);
+ } else {
+ [title, message, disableMessage] = lazy.l10nDeletePrompt.formatValuesSync([
+ { id: "calendar-delete-items-prompt-title", args: { count: items.length } },
+ { id: "calendar-delete-items-prompt-message", args: { count: items.length } },
+ "calendar-delete-prompt-disable-message",
+ ]);
+ }
+
+ if (byPassPref) {
+ return Services.prompt.confirm(null, title, message);
+ }
+
+ let checkResult = { value: false };
+ let result = Services.prompt.confirmEx(
+ null,
+ title,
+ message,
+ Services.prompt.STD_YES_NO_BUTTONS + Services.prompt.BUTTON_DELAY_ENABLE,
+ null,
+ null,
+ null,
+ disableMessage,
+ checkResult
+ );
+
+ if (checkResult.value) {
+ Services.prefs.setBoolPref("calendar.item.promptDelete", false);
+ }
+ return result != 1;
+ },
+};
diff --git a/comm/calendar/base/modules/utils/calXMLUtils.jsm b/comm/calendar/base/modules/utils/calXMLUtils.jsm
new file mode 100644
index 0000000000..936a6c957e
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calXMLUtils.jsm
@@ -0,0 +1,188 @@
+/* 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/. */
+
+/**
+ * Helper functions for parsing and serializing XML
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.xml namespace.
+
+const EXPORTED_SYMBOLS = ["calxml"];
+
+var calxml = {
+ /**
+ * Evaluate an XPath query for the given node. Be careful with the return value
+ * here, as it may be:
+ *
+ * - null, if there are no results
+ * - a number, string or boolean value
+ * - an array of strings or DOM elements
+ *
+ * @param aNode The context node to search from
+ * @param aExpr The XPath expression to search for
+ * @param aResolver (optional) The namespace resolver to use for the expression
+ * @param aType (optional) Force a result type, must be an XPathResult constant
+ * @returns The result, see above for details.
+ */
+ evalXPath(aNode, aExpr, aResolver, aType) {
+ const XPR = {
+ // XPathResultType
+ ANY_TYPE: 0,
+ NUMBER_TYPE: 1,
+ STRING_TYPE: 2,
+ BOOLEAN_TYPE: 3,
+ UNORDERED_NODE_ITERATOR_TYPE: 4,
+ ORDERED_NODE_ITERATOR_TYPE: 5,
+ UNORDERED_NODE_SNAPSHOT_TYPE: 6,
+ ORDERED_NODE_SNAPSHOT_TYPE: 7,
+ ANY_UNORDERED_NODE_TYPE: 8,
+ FIRST_ORDERED_NODE_TYPE: 9,
+ };
+ let doc = aNode.ownerDocument ? aNode.ownerDocument : aNode;
+ let resolver = aResolver || doc.createNSResolver(doc.documentElement);
+ let resultType = aType || XPR.ANY_TYPE;
+
+ let result = doc.evaluate(aExpr, aNode, resolver, resultType, null);
+ let returnResult, next;
+ switch (result.resultType) {
+ case XPR.NUMBER_TYPE:
+ returnResult = result.numberValue;
+ break;
+ case XPR.STRING_TYPE:
+ returnResult = result.stringValue;
+ break;
+ case XPR.BOOLEAN_TYPE:
+ returnResult = result.booleanValue;
+ break;
+ case XPR.UNORDERED_NODE_ITERATOR_TYPE:
+ case XPR.ORDERED_NODE_ITERATOR_TYPE:
+ returnResult = [];
+ while ((next = result.iterateNext())) {
+ if (next.nodeType == next.TEXT_NODE || next.nodeType == next.CDATA_SECTION_NODE) {
+ returnResult.push(next.wholeText);
+ } else if (ChromeUtils.getClassName(next) === "Attr") {
+ returnResult.push(next.value);
+ } else {
+ returnResult.push(next);
+ }
+ }
+ break;
+ case XPR.UNORDERED_NODE_SNAPSHOT_TYPE:
+ case XPR.ORDERED_NODE_SNAPSHOT_TYPE:
+ returnResult = [];
+ for (let i = 0; i < result.snapshotLength; i++) {
+ next = result.snapshotItem(i);
+ if (next.nodeType == next.TEXT_NODE || next.nodeType == next.CDATA_SECTION_NODE) {
+ returnResult.push(next.wholeText);
+ } else if (ChromeUtils.getClassName(next) === "Attr") {
+ returnResult.push(next.value);
+ } else {
+ returnResult.push(next);
+ }
+ }
+ break;
+ case XPR.ANY_UNORDERED_NODE_TYPE:
+ case XPR.FIRST_ORDERED_NODE_TYPE:
+ returnResult = result.singleNodeValue;
+ break;
+ default:
+ returnResult = null;
+ break;
+ }
+
+ if (Array.isArray(returnResult) && returnResult.length == 0) {
+ returnResult = null;
+ }
+
+ return returnResult;
+ },
+
+ /**
+ * Convenience function to evaluate an XPath expression and return null or the
+ * first result. Helpful if you just expect one value in a text() expression,
+ * but its possible that there will be more than one. The result may be:
+ *
+ * - null, if there are no results
+ * - A string, number, boolean or DOM Element value
+ *
+ * @param aNode The context node to search from
+ * @param aExpr The XPath expression to search for
+ * @param aResolver (optional) The namespace resolver to use for the expression
+ * @param aType (optional) Force a result type, must be an XPathResult constant
+ * @returns The result, see above for details.
+ */
+ evalXPathFirst(aNode, aExpr, aResolver, aType) {
+ let result = calxml.evalXPath(aNode, aExpr, aResolver, aType);
+
+ if (Array.isArray(result)) {
+ return result[0];
+ }
+ return result;
+ },
+
+ /**
+ * Parse the given string into a DOM tree
+ *
+ * @param str The string to parse
+ * @returns The parsed DOM Document
+ */
+ parseString(str) {
+ let parser = new DOMParser();
+ parser.forceEnableXULXBL();
+ return parser.parseFromString(str, "application/xml");
+ },
+
+ /**
+ * Read an XML file synchronously. This method should be avoided, consider
+ * rewriting the caller to be asynchronous.
+ *
+ * @param uri The URI to read.
+ * @returns The DOM Document resulting from the file.
+ */
+ parseFile(uri) {
+ let req = new XMLHttpRequest();
+ req.open("GET", uri, false);
+ req.overrideMimeType("text/xml");
+ req.send(null);
+ return req.responseXML;
+ },
+
+ /**
+ * Serialize the DOM tree into a string.
+ *
+ * @param doc The DOM document to serialize
+ * @returns The DOM document as a string.
+ */
+ serializeDOM(doc) {
+ let serializer = new XMLSerializer();
+ return serializer.serializeToString(doc);
+ },
+
+ /**
+ * Escape a string for use in XML
+ *
+ * @param str The string to escape
+ * @param isAttribute If true, " and ' are also escaped
+ * @returns The escaped string
+ */
+ escapeString(str, isAttribute) {
+ return str.replace(/[&<>'"]/g, chr => {
+ switch (chr) {
+ case "&":
+ return "&amp;";
+ case "<":
+ return "&lt;";
+ case ">":
+ return "&gt;";
+ case '"':
+ return isAttribute ? "&quot;" : chr;
+ case "'":
+ return isAttribute ? "&apos;" : chr;
+ default:
+ return chr;
+ }
+ });
+ },
+};