diff options
Diffstat (limited to 'comm/calendar/base/modules')
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 "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return isAttribute ? """ : chr; + case "'": + return isAttribute ? "'" : chr; + default: + return chr; + } + }); + }, +}; |