summaryrefslogtreecommitdiffstats
path: root/comm/calendar/providers
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/providers
parentInitial commit. (diff)
downloadthunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz
thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/providers')
-rw-r--r--comm/calendar/providers/caldav/CalDavCalendar.jsm2464
-rw-r--r--comm/calendar/providers/caldav/CalDavProvider.jsm426
-rw-r--r--comm/calendar/providers/caldav/components.conf14
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavRequest.jsm1211
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm1091
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavSession.jsm573
-rw-r--r--comm/calendar/providers/caldav/modules/CalDavUtils.jsm110
-rw-r--r--comm/calendar/providers/caldav/moz.build25
-rw-r--r--comm/calendar/providers/caldav/public/calICalDavCalendar.idl20
-rw-r--r--comm/calendar/providers/caldav/public/moz.build10
-rw-r--r--comm/calendar/providers/composite/CalCompositeCalendar.jsm426
-rw-r--r--comm/calendar/providers/composite/components.conf14
-rw-r--r--comm/calendar/providers/composite/moz.build12
-rw-r--r--comm/calendar/providers/ics/CalICSCalendar.sys.mjs1235
-rw-r--r--comm/calendar/providers/ics/CalICSProvider.jsm447
-rw-r--r--comm/calendar/providers/ics/components.conf14
-rw-r--r--comm/calendar/providers/ics/moz.build16
-rw-r--r--comm/calendar/providers/memory/CalMemoryCalendar.jsm538
-rw-r--r--comm/calendar/providers/memory/components.conf14
-rw-r--r--comm/calendar/providers/memory/moz.build12
-rw-r--r--comm/calendar/providers/moz.build12
-rw-r--r--comm/calendar/providers/storage/CalStorageCachedItemModel.jsm219
-rw-r--r--comm/calendar/providers/storage/CalStorageCalendar.jsm563
-rw-r--r--comm/calendar/providers/storage/CalStorageDatabase.jsm333
-rw-r--r--comm/calendar/providers/storage/CalStorageItemModel.jsm1374
-rw-r--r--comm/calendar/providers/storage/CalStorageMetaDataModel.jsm94
-rw-r--r--comm/calendar/providers/storage/CalStorageModelBase.jsm65
-rw-r--r--comm/calendar/providers/storage/CalStorageModelFactory.jsm52
-rw-r--r--comm/calendar/providers/storage/CalStorageOfflineModel.jsm54
-rw-r--r--comm/calendar/providers/storage/CalStorageStatements.jsm751
-rw-r--r--comm/calendar/providers/storage/calStorageHelpers.jsm121
-rw-r--r--comm/calendar/providers/storage/calStorageUpgrade.jsm1889
-rw-r--r--comm/calendar/providers/storage/components.conf14
-rw-r--r--comm/calendar/providers/storage/moz.build28
34 files changed, 14241 insertions, 0 deletions
diff --git a/comm/calendar/providers/caldav/CalDavCalendar.jsm b/comm/calendar/providers/caldav/CalDavCalendar.jsm
new file mode 100644
index 0000000000..a2bf7f0467
--- /dev/null
+++ b/comm/calendar/providers/caldav/CalDavCalendar.jsm
@@ -0,0 +1,2464 @@
+/* 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 EXPORTED_SYMBOLS = ["CalDavCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var {
+ CalDavGenericRequest,
+ CalDavLegacySAXRequest,
+ CalDavItemRequest,
+ CalDavDeleteItemRequest,
+ CalDavPropfindRequest,
+ CalDavHeaderRequest,
+ CalDavPrincipalPropertySearchRequest,
+ CalDavOutboxRequest,
+ CalDavFreeBusyRequest,
+} = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+var { CalDavEtagsHandler, CalDavWebDavSyncHandler, CalDavMultigetSyncHandler } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavRequestHandlers.jsm"
+);
+
+var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+var XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+var MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+var cIOL = Ci.calIOperationListener;
+
+function CalDavCalendar() {
+ this.initProviderBase();
+ this.unmappedProperties = [];
+ this.mUriParams = null;
+ this.mItemInfoCache = {};
+ this.mDisabledByDavError = false;
+ this.mCalHomeSet = null;
+ this.mInboxUrl = null;
+ this.mOutboxUrl = null;
+ this.mCalendarUserAddress = null;
+ this.mCheckedServerInfo = null;
+ this.mPrincipalUrl = null;
+ this.mSenderAddress = null;
+ this.mHrefIndex = {};
+ this.mAuthScheme = null;
+ this.mAuthRealm = null;
+ this.mObserver = null;
+ this.mFirstRefreshDone = false;
+ this.mOfflineStorage = null;
+ this.mQueuedQueries = [];
+ this.mCtag = null;
+ this.mProposedCtag = null;
+
+ // By default, support both events and todos.
+ this.mGenerallySupportedItemTypes = ["VEVENT", "VTODO"];
+ this.mSupportedItemTypes = this.mGenerallySupportedItemTypes.slice(0);
+ this.mACLProperties = {};
+}
+
+// used for etag checking
+var CALDAV_MODIFY_ITEM = "modify";
+var CALDAV_DELETE_ITEM = "delete";
+
+var calDavCalendarClassID = Components.ID("{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}");
+var calDavCalendarInterfaces = [
+ "calICalDavCalendar",
+ "calICalendar",
+ "calIChangeLog",
+ "calIFreeBusyProvider",
+ "calIItipTransport",
+ "calISchedulingSupport",
+ "nsIInterfaceRequestor",
+];
+CalDavCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+ classID: calDavCalendarClassID,
+ QueryInterface: cal.generateQI(calDavCalendarInterfaces),
+ classInfo: cal.generateCI({
+ classID: calDavCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=caldav",
+ classDescription: "Calendar CalDAV back-end",
+ interfaces: calDavCalendarInterfaces,
+ }),
+
+ // An array of components that are supported by the server. The default is
+ // to support VEVENT and VTODO, if queries for these components return a 4xx
+ // error, then they will be removed from this array.
+ mGenerallySupportedItemTypes: null,
+ mSupportedItemTypes: null,
+ suportedItemTypes: null,
+ get supportedItemTypes() {
+ return this.mSupportedItemTypes;
+ },
+
+ get isCached() {
+ return this != this.superCalendar;
+ },
+
+ mLastRedirectStatus: null,
+
+ ensureTargetCalendar() {
+ if (!this.isCached && !this.mOfflineStorage) {
+ // If this is a cached calendar, the actual cache is taken care of
+ // by the calCachedCalendar facade. In any other case, we use a
+ // memory calendar to cache things.
+ this.mOfflineStorage = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calISyncWriteCalendar
+ );
+
+ this.mOfflineStorage.superCalendar = this;
+ this.mObserver = new calDavObserver(this);
+ this.mOfflineStorage.addObserver(this.mObserver);
+ this.mOfflineStorage.setProperty("relaxedMode", true);
+ }
+ },
+
+ get id() {
+ return this.mID;
+ },
+ set id(val) {
+ let setter = this.__proto__.__proto__.__lookupSetter__("id");
+ val = setter.call(this, val);
+
+ if (this.id) {
+ // Recreate the session ID that was used when discovering this calendar,
+ // as the password is stored with it. This only matters for OAuth
+ // calendars, in all other cases the password is stored by username.
+ this.session = new CalDavSession(
+ this.getProperty("username") || this.getProperty("sessionId") || this.id,
+ this.name
+ );
+ }
+ },
+
+ // calIChangeLog interface
+ get offlineStorage() {
+ return this.mOfflineStorage;
+ },
+
+ set offlineStorage(storage) {
+ this.mOfflineStorage = storage;
+ this.fetchCachedMetaData();
+ },
+
+ resetLog() {
+ if (this.isCached && this.mOfflineStorage) {
+ this.mOfflineStorage.startBatch();
+ try {
+ for (let itemId in this.mItemInfoCache) {
+ this.mOfflineStorage.deleteMetaData(itemId);
+ delete this.mItemInfoCache[itemId];
+ }
+ } finally {
+ this.mOfflineStorage.endBatch();
+ }
+ }
+ },
+
+ get offlineCachedProperties() {
+ return [
+ "mAuthScheme",
+ "mAuthRealm",
+ "mHasWebdavSyncSupport",
+ "mCtag",
+ "mWebdavSyncToken",
+ "mSupportedItemTypes",
+ "mPrincipalUrl",
+ "mCalHomeSet",
+ "mShouldPollInbox",
+ "mHasAutoScheduling",
+ "mHaveScheduling",
+ "mCalendarUserAddress",
+ "mOutboxUrl",
+ "hasFreeBusy",
+ ];
+ },
+
+ get checkedServerInfo() {
+ if (Services.io.offline) {
+ return true;
+ }
+ return this.mCheckedServerInfo;
+ },
+
+ set checkedServerInfo(val) {
+ this.mCheckedServerInfo = val;
+ },
+
+ saveCalendarProperties() {
+ let properties = {};
+ for (let property of this.offlineCachedProperties) {
+ if (this[property] !== undefined) {
+ properties[property] = this[property];
+ }
+ }
+ this.mOfflineStorage.setMetaData("calendar-properties", JSON.stringify(properties));
+ },
+ restoreCalendarProperties(data) {
+ let properties = JSON.parse(data);
+ for (let property of this.offlineCachedProperties) {
+ if (properties[property] !== undefined) {
+ this[property] = properties[property];
+ }
+ }
+ // migration code from bug 1299610
+ if ("hasAutoScheduling" in properties && properties.hasAutoScheduling !== undefined) {
+ this.mHasAutoScheduling = properties.hasAutoScheduling;
+ }
+ },
+
+ // in calIGenericOperationListener aListener
+ replayChangesOn(aChangeLogListener) {
+ if (this.checkedServerInfo) {
+ this.safeRefresh(aChangeLogListener);
+ } else {
+ // If we haven't refreshed yet, then we should check the resource
+ // type first. This will call refresh() again afterwards.
+ this.checkDavResourceType(aChangeLogListener);
+ }
+ },
+ setMetaData(id, path, etag, isInboxItem) {
+ if (this.mOfflineStorage.setMetaData) {
+ if (id) {
+ let dataString = [etag, path, isInboxItem ? "true" : "false"].join("\u001A");
+ this.mOfflineStorage.setMetaData(id, dataString);
+ } else {
+ cal.LOG("CalDAV: cannot store meta data without an id");
+ }
+ } else {
+ cal.ERROR("CalDAV: calendar storage does not support meta data");
+ }
+ },
+
+ /**
+ * Ensure that cached items have associated meta data, otherwise server side
+ * changes may not be reflected
+ */
+ async ensureMetaData() {
+ let refreshNeeded = false;
+
+ for await (let items of cal.iterate.streamValues(
+ this.mOfflineStorage.wrappedJSObject.getItems(
+ Ci.calICalendar.ITEM_FILTER_ALL_ITEMS,
+ 0,
+ null,
+ null
+ )
+ )) {
+ for (let item of items) {
+ if (!(item.id in this.mItemInfoCache)) {
+ let path = this.getItemLocationPath(item);
+ cal.LOG("Adding meta-data for cached item " + item.id);
+ this.mItemInfoCache[item.id] = {
+ etag: null,
+ isNew: false,
+ locationPath: path,
+ isInboxItem: false,
+ };
+ this.mHrefIndex[this.mLocationPath + path] = item.id;
+ refreshNeeded = true;
+ }
+ }
+ }
+
+ if (refreshNeeded) {
+ // resetting the cached ctag forces an item refresh when
+ // safeRefresh is called later
+ this.mCtag = null;
+ this.mProposedCtag = null;
+ }
+ },
+
+ fetchCachedMetaData() {
+ cal.LOG("CalDAV: Retrieving server info from cache for " + this.name);
+ let cacheIds = this.mOfflineStorage.getAllMetaDataIds();
+ let cacheValues = this.mOfflineStorage.getAllMetaDataValues();
+
+ for (let count = 0; count < cacheIds.length; count++) {
+ let itemId = cacheIds[count];
+ let itemData = cacheValues[count];
+ if (itemId == "ctag") {
+ this.mCtag = itemData;
+ this.mProposedCtag = null;
+ this.mOfflineStorage.deleteMetaData("ctag");
+ } else if (itemId == "webdav-sync-token") {
+ this.mWebdavSyncToken = itemData;
+ this.mOfflineStorage.deleteMetaData("sync-token");
+ } else if (itemId == "calendar-properties") {
+ this.restoreCalendarProperties(itemData);
+ this.setProperty("currentStatus", Cr.NS_OK);
+ if (this.mHaveScheduling || this.hasAutoScheduling || this.hasFreeBusy) {
+ cal.freeBusyService.addProvider(this);
+ }
+ } else {
+ let itemDataArray = itemData.split("\u001A");
+ let etag = itemDataArray[0];
+ let resourcePath = itemDataArray[1];
+ let isInboxItem = itemDataArray[2];
+ if (itemDataArray.length == 3) {
+ this.mHrefIndex[resourcePath] = itemId;
+ let locationPath = resourcePath.substr(this.mLocationPath.length);
+ let item = {
+ etag,
+ isNew: false,
+ locationPath,
+ isInboxItem: isInboxItem == "true",
+ };
+ this.mItemInfoCache[itemId] = item;
+ }
+ }
+ }
+
+ this.ensureMetaData();
+ },
+
+ //
+ // calICalendar interface
+ //
+
+ // readonly attribute AUTF8String type;
+ get type() {
+ return "caldav";
+ },
+
+ mDisabledByDavError: true,
+
+ mCalendarUserAddress: null,
+ get calendarUserAddress() {
+ return this.mCalendarUserAddress;
+ },
+
+ mPrincipalUrl: null,
+ get principalUrl() {
+ return this.mPrincipalUrl;
+ },
+
+ get canRefresh() {
+ // A cached calendar doesn't need to be refreshed.
+ return !this.isCached;
+ },
+
+ // mUriParams stores trailing ?parameters from the
+ // supplied calendar URI. Needed for (at least) Cosmo
+ // tickets
+ mUriParams: null,
+
+ get uri() {
+ return this.mUri;
+ },
+
+ set uri(aUri) {
+ this.mUri = aUri;
+ },
+
+ get calendarUri() {
+ let calSpec = this.mUri.spec;
+ let parts = calSpec.split("?");
+ if (parts.length > 1) {
+ calSpec = parts.shift();
+ this.mUriParams = "?" + parts.join("?");
+ }
+ if (!calSpec.endsWith("/")) {
+ calSpec += "/";
+ }
+ return Services.io.newURI(calSpec);
+ },
+
+ setCalHomeSet(removeLastPathSegment) {
+ if (removeLastPathSegment) {
+ let split1 = this.mUri.spec.split("?");
+ let baseUrl = split1[0];
+ if (baseUrl.charAt(baseUrl.length - 1) == "/") {
+ baseUrl = baseUrl.substring(0, baseUrl.length - 2);
+ }
+ let split2 = baseUrl.split("/");
+ split2.pop();
+ this.mCalHomeSet = Services.io.newURI(split2.join("/") + "/");
+ } else {
+ this.mCalHomeSet = this.calendarUri;
+ }
+ },
+
+ mOutboxUrl: null,
+ get outboxUrl() {
+ return this.mOutboxUrl;
+ },
+
+ mInboxUrl: null,
+ get inboxUrl() {
+ return this.mInboxUrl;
+ },
+
+ mHaveScheduling: false,
+ mShouldPollInbox: true,
+ get hasScheduling() {
+ // Whether to use inbox/outbox scheduling
+ return this.mHaveScheduling;
+ },
+ set hasScheduling(value) {
+ this.mHaveScheduling =
+ Services.prefs.getBoolPref("calendar.caldav.sched.enabled", false) && value;
+ },
+ mHasAutoScheduling: false, // Whether server automatically takes care of scheduling
+ get hasAutoScheduling() {
+ return this.mHasAutoScheduling;
+ },
+
+ hasFreebusy: false,
+
+ mAuthScheme: null,
+
+ mAuthRealm: null,
+
+ mFirstRefreshDone: false,
+
+ mQueuedQueries: null,
+
+ mCtag: null,
+ mProposedCtag: null,
+
+ mOfflineStorage: null,
+ // Contains the last valid synctoken returned
+ // from the server with Webdav Sync enabled servers
+ mWebdavSyncToken: null,
+ // Indicates that the server supports Webdav Sync
+ // see: http://tools.ietf.org/html/draft-daboo-webdav-sync
+ mHasWebdavSyncSupport: false,
+
+ get authRealm() {
+ return this.mAuthRealm;
+ },
+
+ /**
+ * Builds a correctly encoded nsIURI based on the baseUri and the insert
+ * string. The returned uri is basically the baseURI + aInsertString
+ *
+ * @param {string} aInsertString - String to append to the base uri, for example,
+ * when creating an event this would be the
+ * event file name (event.ics). If null, an empty
+ * string is used.
+ * @param {nsIURI} aBaseUri - Base uri, if null, this.calendarUri will be used.
+ */
+ makeUri(aInsertString, aBaseUri) {
+ let baseUri = aBaseUri || this.calendarUri;
+ // Build a string containing the full path, decoded, so it looks like
+ // this:
+ // /some path/insert string.ics
+ let decodedPath = this.ensureDecodedPath(baseUri.pathQueryRef + (aInsertString || ""));
+
+ // Build the nsIURI by specifying a string with a fully encoded path
+ // the end result will be something like this:
+ // http://caldav.example.com:8080/some%20path/insert%20string.ics
+ return Services.io.newURI(
+ baseUri.prePath + this.ensureEncodedPath(decodedPath) + (this.mUriParams || "")
+ );
+ },
+
+ get mLocationPath() {
+ return this.ensureDecodedPath(this.calendarUri.pathQueryRef);
+ },
+
+ getItemLocationPath(aItem) {
+ if (aItem.id && aItem.id in this.mItemInfoCache && this.mItemInfoCache[aItem.id].locationPath) {
+ // modifying items use the cached location path
+ return this.mItemInfoCache[aItem.id].locationPath;
+ }
+ // New items just use id.ics
+ return aItem.id + ".ics";
+ },
+
+ getProperty(aName) {
+ if (aName in this.mACLProperties && this.mACLProperties[aName]) {
+ return this.mACLProperties[aName];
+ }
+
+ switch (aName) {
+ case "organizerId":
+ if (this.calendarUserAddress) {
+ return this.calendarUserAddress;
+ } // else use configured email identity
+ break;
+ case "organizerCN":
+ return null; // xxx todo
+ case "itip.transport":
+ if (this.hasAutoScheduling || this.hasScheduling) {
+ return this.QueryInterface(Ci.calIItipTransport);
+ } // else use outbound email-based iTIP (from cal.provider.BaseClass)
+ break;
+ case "capabilities.tasks.supported":
+ return this.supportedItemTypes.includes("VTODO");
+ case "capabilities.events.supported":
+ return this.supportedItemTypes.includes("VEVENT");
+ case "capabilities.autoschedule.supported":
+ return this.hasAutoScheduling;
+ case "capabilities.username.supported":
+ return true;
+ }
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ },
+
+ promptOverwrite(aMethod, aItem, aListener, aOldItem) {
+ let overwrite = cal.provider.promptOverwrite(aMethod, aItem, aListener, aOldItem);
+ if (overwrite) {
+ if (aMethod == CALDAV_MODIFY_ITEM) {
+ this.doModifyItem(aItem, aOldItem, aListener, true);
+ } else {
+ this.doDeleteItem(aItem, aListener, true, false, null);
+ }
+ } else {
+ this.getUpdatedItem(aItem, aListener);
+ }
+ },
+
+ mItemInfoCache: null,
+
+ mHrefIndex: null,
+
+ get supportsScheduling() {
+ return true;
+ },
+
+ getSchedulingSupport() {
+ return this;
+ },
+
+ /**
+ * addItem()
+ * we actually use doAdoptItem()
+ *
+ * @param aItem item to add
+ */
+ async addItem(aItem) {
+ return this.adoptItem(aItem);
+ },
+
+ // Used to allow the cachedCalendar provider to hook into adoptItem() before
+ // it returns.
+ _cachedAdoptItemCallback: null,
+
+ /**
+ * adoptItem()
+ * we actually use doAdoptItem()
+ *
+ * @param aItem item to check
+ */
+ async adoptItem(aItem) {
+ let adoptCallback = this._cachedAdoptItemCallback;
+ return new Promise((resolve, reject) => {
+ this.doAdoptItem(aItem.clone(), {
+ get wrappedJSObject() {
+ return this;
+ },
+ async onOperationComplete(calendar, status, opType, id, detail) {
+ if (adoptCallback) {
+ await adoptCallback(calendar, status, opType, id, detail);
+ }
+ return Components.isSuccessCode(status) ? resolve(detail) : reject(detail);
+ },
+ });
+ });
+ },
+
+ /**
+ * Performs the actual addition of the item to CalDAV store
+ *
+ * @param aItem item to add
+ * @param aListener listener for method completion
+ * @param aIgnoreEtag flag to indicate ignoring of Etag
+ */
+ doAdoptItem(aItem, aListener, aIgnoreEtag) {
+ let notifyListener = (status, detail, pure = false) => {
+ let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete";
+ this[method](aListener, status, cIOL.ADD, aItem.id, detail);
+ };
+ if (aItem.id == null && aItem.isMutable) {
+ aItem.id = cal.getUUID();
+ }
+
+ if (aItem.id == null) {
+ notifyListener(Cr.NS_ERROR_FAILURE, "Can't set ID on non-mutable item to addItem");
+ return;
+ }
+
+ if (!cal.item.isItemSupported(aItem, this)) {
+ notifyListener(Cr.NS_ERROR_FAILURE, "Server does not support item type");
+ return;
+ }
+
+ let parentItem = aItem.parentItem;
+ parentItem.calendar = this.superCalendar;
+
+ let locationPath = this.getItemLocationPath(parentItem);
+ let itemUri = this.makeUri(locationPath);
+ cal.LOG("CalDAV: itemUri.spec = " + itemUri.spec);
+
+ let serializedItem = this.getSerializedItem(aItem);
+
+ let sendEtag = aIgnoreEtag ? null : "*";
+ let request = new CalDavItemRequest(this.session, this, itemUri, aItem, sendEtag);
+
+ request.commit().then(
+ response => {
+ let status = Cr.NS_OK;
+ let detail = parentItem;
+
+ // Translate the HTTP status code to a status and message for the listener
+ if (response.ok) {
+ cal.LOG(`CalDAV: Item added to ${this.name} successfully`);
+
+ let uriComponentParts = this.makeUri()
+ .pathQueryRef.replace(/\/{2,}/g, "/")
+ .split("/").length;
+ let targetParts = response.uri.pathQueryRef.split("/");
+ targetParts.splice(0, uriComponentParts - 1);
+
+ this.mItemInfoCache[parentItem.id] = { locationPath: targetParts.join("/") };
+ // TODO: onOpComplete adds the item to the cache, probably after getUpdatedItem!
+
+ // Some CalDAV servers will modify items on PUT (add X-props,
+ // for instance) so we'd best re-fetch in order to know
+ // the current state of the item
+ // Observers will be notified in getUpdatedItem()
+ this.getUpdatedItem(parentItem, aListener);
+ return;
+ } else if (response.serverError) {
+ status = Cr.NS_ERROR_NOT_AVAILABLE;
+ detail = "Server Replied with " + response.status;
+ } else if (response.status) {
+ // There is a response status, but we haven't handled it yet. Any
+ // error occurring here should consider being handled!
+ cal.ERROR(
+ "CalDAV: Unexpected status adding item to " +
+ this.name +
+ ": " +
+ response.status +
+ "\n" +
+ serializedItem
+ );
+
+ status = Cr.NS_ERROR_FAILURE;
+ detail = "Server Replied with " + response.status;
+ }
+
+ // Still need to visually notify for uncached calendars.
+ if (!this.isCached && !Components.isSuccessCode(status)) {
+ this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail);
+ }
+
+ // Finally, notify listener.
+ notifyListener(status, detail, true);
+ },
+ e => {
+ notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel: " + e);
+ }
+ );
+ },
+
+ // Used to allow the cachedCalendar provider to hook into modifyItem() before
+ // it returns.
+ _cachedModifyItemCallback: null,
+
+ /**
+ * modifyItem(); required by calICalendar.idl
+ * we actually use doModifyItem()
+ *
+ * @param aItem item to check
+ */
+ async modifyItem(aNewItem, aOldItem) {
+ let modifyCallback = this._cachedModifyItemCallback;
+ return new Promise((resolve, reject) => {
+ this.doModifyItem(
+ aNewItem,
+ aOldItem,
+ {
+ get wrappedJSObject() {
+ return this;
+ },
+ async onOperationComplete(calendar, status, opType, id, detail) {
+ if (modifyCallback) {
+ await modifyCallback(calendar, status, opType, id, detail);
+ }
+ return Components.isSuccessCode(status) ? resolve(detail) : reject(detail);
+ },
+ },
+ false
+ );
+ });
+ },
+
+ /**
+ * Modifies existing item in CalDAV store.
+ *
+ * @param aItem item to check
+ * @param aOldItem previous version of item to be modified
+ * @param aListener listener from original request
+ * @param aIgnoreEtag ignore item etag
+ */
+ doModifyItem(aNewItem, aOldItem, aListener, aIgnoreEtag) {
+ let notifyListener = (status, detail, pure = false) => {
+ let method = pure ? "notifyPureOperationComplete" : "notifyOperationComplete";
+ this[method](aListener, status, cIOL.MODIFY, aNewItem.id, detail);
+ };
+ if (aNewItem.id == null) {
+ notifyListener(Cr.NS_ERROR_FAILURE, "ID for modifyItem doesn't exist or is null");
+ return;
+ }
+
+ let wasInboxItem = this.mItemInfoCache[aNewItem.id].isInboxItem;
+
+ let newItem_ = aNewItem;
+ aNewItem = aNewItem.parentItem.clone();
+ if (newItem_.parentItem != newItem_) {
+ aNewItem.recurrenceInfo.modifyException(newItem_, false);
+ }
+ aNewItem.generation += 1;
+
+ let eventUri = this.makeUri(this.mItemInfoCache[aNewItem.id].locationPath);
+ let modifiedItemICS = this.getSerializedItem(aNewItem);
+
+ let sendEtag = aIgnoreEtag ? null : this.mItemInfoCache[aNewItem.id].etag;
+ let request = new CalDavItemRequest(this.session, this, eventUri, aNewItem, sendEtag);
+
+ request.commit().then(
+ response => {
+ let status = Cr.NS_OK;
+ let detail = aNewItem;
+
+ let shouldNotify = true;
+ if (response.ok) {
+ cal.LOG("CalDAV: Item modified successfully on " + this.name);
+
+ // Some CalDAV servers will modify items on PUT (add X-props, for instance) so we'd
+ // best re-fetch in order to know the current state of the item Observers will be
+ // notified in getUpdatedItem()
+ this.getUpdatedItem(aNewItem, aListener);
+
+ // SOGo has calendarUri == inboxUri so we need to be careful about deletions
+ if (wasInboxItem && this.mShouldPollInbox) {
+ this.doDeleteItem(aNewItem, null, true, true, null);
+ }
+ shouldNotify = false;
+ } else if (response.conflict) {
+ // promptOverwrite will ask the user and then re-request
+ this.promptOverwrite(CALDAV_MODIFY_ITEM, aNewItem, aListener, aOldItem);
+ shouldNotify = false;
+ } else if (response.serverError) {
+ status = Cr.NS_ERROR_NOT_AVAILABLE;
+ detail = "Server Replied with " + response.status;
+ } else if (response.status) {
+ // There is a response status, but we haven't handled it yet. Any error occurring
+ // here should consider being handled!
+ cal.ERROR(
+ "CalDAV: Unexpected status modifying item to " +
+ this.name +
+ ": " +
+ response.status +
+ "\n" +
+ modifiedItemICS
+ );
+
+ status = Cr.NS_ERROR_FAILURE;
+ detail = "Server Replied with " + response.status;
+ }
+
+ if (shouldNotify) {
+ // Still need to visually notify for uncached calendars.
+ if (!this.isCached && !Components.isSuccessCode(status)) {
+ this.reportDavError(Ci.calIErrors.DAV_PUT_ERROR, status, detail);
+ }
+
+ notifyListener(status, detail, true);
+ }
+ },
+ () => {
+ notifyListener(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
+ }
+ );
+ },
+
+ /**
+ * deleteItem(); required by calICalendar.idl
+ * the actual deletion is done in doDeleteItem()
+ *
+ * @param {calIItemBase} item The item to delete
+ *
+ * @returns {Promise<void>}
+ */
+ async deleteItem(item) {
+ return this.doDeleteItem(item, false, null, null);
+ },
+
+ /**
+ * Deletes item from CalDAV store.
+ *
+ * @param {calIItemBase} item Item to delete.
+ * @param {boolean} ignoreEtag Ignore item etag.
+ * @param {boolean} fromInbox Delete from inbox rather than calendar.
+ * @param {string} uri Uri of item to delete.
+ *
+ * @returns {Promise<void>}
+ */
+ async doDeleteItem(item, ignoreEtag, fromInbox, uri) {
+ let onError = async (status, detail) => {
+ // Still need to visually notify for uncached calendars.
+ if (!this.isCached) {
+ this.reportDavError(Ci.calIErrors.DAV_REMOVE_ERROR, status, detail);
+ }
+ this.notifyOperationComplete(null, status, cIOL.DELETE, null, detail);
+ return Promise.reject(new Components.Exception(detail, status));
+ };
+
+ if (item.id == null) {
+ return onError(Cr.NS_ERROR_FAILURE, "ID doesn't exist for deleteItem");
+ }
+
+ let eventUri;
+ if (uri) {
+ eventUri = uri;
+ } else if (fromInbox || this.mItemInfoCache[item.id].isInboxItem) {
+ eventUri = this.makeUri(this.mItemInfoCache[item.id].locationPath, this.mInboxUrl);
+ } else {
+ eventUri = this.makeUri(this.mItemInfoCache[item.id].locationPath);
+ }
+
+ if (eventUri.pathQueryRef == this.calendarUri.pathQueryRef) {
+ return onError(
+ Cr.NS_ERROR_FAILURE,
+ "eventUri and calendarUri paths are the same, will not go on to delete entire calendar"
+ );
+ }
+
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: Deleting " + eventUri.spec);
+ }
+
+ let sendEtag = ignoreEtag ? null : this.mItemInfoCache[item.id].etag;
+ let request = new CalDavDeleteItemRequest(this.session, this, eventUri, sendEtag);
+
+ let response;
+ try {
+ response = await request.commit();
+ } catch (e) {
+ return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Error preparing http channel");
+ }
+
+ if (response.ok) {
+ if (!fromInbox) {
+ let decodedPath = this.ensureDecodedPath(eventUri.pathQueryRef);
+ delete this.mHrefIndex[decodedPath];
+ delete this.mItemInfoCache[item.id];
+ cal.LOG("CalDAV: Item deleted successfully from calendar " + this.name);
+
+ if (this.isCached) {
+ this.notifyOperationComplete(null, Cr.NS_OK, cIOL.DELETE, null, null);
+ return null;
+ }
+ // If the calendar is not cached, we need to remove
+ // the item from our memory calendar now. The
+ // listeners will be notified there.
+ return this.mOfflineStorage.deleteItem(item);
+ }
+ return null;
+ } else if (response.conflict) {
+ // item has either been modified or deleted by someone else check to see which
+ cal.LOG("CalDAV: Item has been modified on server, checking if it has been deleted");
+ let headRequest = new CalDavGenericRequest(this.session, this, "HEAD", eventUri);
+ let headResponse = await headRequest.commit();
+
+ if (headResponse.notFound) {
+ // Nothing to do. Someone else has already deleted it
+ this.notifyPureOperationComplete(null, Cr.NS_OK, cIOL.DELETE, null, null);
+ return null;
+ } else if (headResponse.serverError) {
+ return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + headResponse.status);
+ } else if (headResponse.status) {
+ // The item still exists. We need to ask the user if he
+ // really wants to delete the item. Remember, we only
+ // made this request since the actual delete gave 409/412
+ let item = await this.getItem(item.id);
+ return cal.provider.promptOverwrite(CALDAV_DELETE_ITEM, item)
+ ? this.doDeleteItem(item, true, false, null)
+ : null;
+ }
+ } else if (response.serverError) {
+ return onError(Cr.NS_ERROR_NOT_AVAILABLE, "Server Replied with " + response.status);
+ } else if (response.status) {
+ cal.ERROR(
+ "CalDAV: Unexpected status deleting item from " +
+ this.name +
+ ": " +
+ response.status +
+ "\n" +
+ "uri: " +
+ eventUri.spec
+ );
+ }
+ return onError(Cr.NS_ERROR_FAILURE, "Server Replied with status " + response.status);
+ },
+
+ /**
+ * Add an item to the target calendar
+ *
+ * @param path Item path MUST NOT BE ENCODED
+ * @param calData iCalendar string representation of the item
+ * @param aUri Base URI of the request
+ * @param aListener Listener
+ */
+ async addTargetCalendarItem(path, calData, aUri, etag, aListener) {
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ // aUri.pathQueryRef may contain double slashes whereas path does not
+ // this confuses our counting, so remove multiple successive slashes
+ let strippedUriPath = aUri.pathQueryRef.replace(/\/{2,}/g, "/");
+ let uriPathComponentLength = strippedUriPath.split("/").length;
+ try {
+ parser.parseString(calData);
+ } catch (e) {
+ // Warn and continue.
+ // TODO As soon as we have activity manager integration,
+ // this should be replace with logic to notify that a
+ // certain event failed.
+ cal.WARN("Failed to parse item: " + calData + "\n\nException:" + e);
+ return;
+ }
+ // with CalDAV there really should only be one item here
+ let items = parser.getItems();
+ let propertiesList = parser.getProperties();
+ let method;
+ for (let prop of propertiesList) {
+ if (prop.propertyName == "METHOD") {
+ method = prop.value;
+ break;
+ }
+ }
+ let isReply = method == "REPLY";
+ let item = items[0];
+
+ if (!item) {
+ cal.WARN("Failed to parse item: " + calData);
+ return;
+ }
+
+ item.calendar = this.superCalendar;
+ if (isReply && this.isInbox(aUri.spec)) {
+ if (this.hasScheduling) {
+ this.processItipReply(item, path);
+ }
+ cal.WARN("REPLY method but calendar does not support scheduling");
+ return;
+ }
+
+ // Strip of the same number of components as the request
+ // uri's path has. This way we make sure to handle servers
+ // that pass paths like /dav/user/Calendar while
+ // the request uri is like /dav/user@example.org/Calendar.
+ let resPathComponents = path.split("/");
+ resPathComponents.splice(0, uriPathComponentLength - 1);
+ let locationPath = resPathComponents.join("/");
+ let isInboxItem = this.isInbox(aUri.spec);
+
+ if (this.mHrefIndex[path] && !this.mItemInfoCache[item.id]) {
+ // If we get here it means a meeting has kept the same filename
+ // but changed its uid, which can happen server side.
+ // Delete the meeting before re-adding it
+ this.deleteTargetCalendarItem(path);
+ }
+
+ if (this.mItemInfoCache[item.id]) {
+ this.mItemInfoCache[item.id].isNew = false;
+ } else {
+ this.mItemInfoCache[item.id] = { isNew: true };
+ }
+ this.mItemInfoCache[item.id].locationPath = locationPath;
+ this.mItemInfoCache[item.id].isInboxItem = isInboxItem;
+
+ this.mHrefIndex[path] = item.id;
+ this.mItemInfoCache[item.id].etag = etag;
+
+ if (this.isCached) {
+ this.setMetaData(item.id, path, etag, isInboxItem);
+
+ // If we have a listener, then the caller will take care of adding the item
+ // Otherwise, we have to do it ourself
+ // XXX This is quite fragile, but saves us a double modify/add
+
+ if (aListener) {
+ await new Promise(resolve => {
+ let wrappedListener = {
+ onGetResult(...args) {
+ aListener.onGetResult(...args);
+ },
+ onOperationComplete(...args) {
+ // We must use wrappedJSObject to receive a returned Promise.
+ let promise = aListener.wrappedJSObject.onOperationComplete(...args);
+ if (promise) {
+ promise.then(resolve);
+ } else {
+ resolve();
+ }
+ },
+ };
+
+ // In the cached case, notifying operation complete will add the item to the cache
+ if (this.mItemInfoCache[item.id].isNew) {
+ this.notifyOperationComplete(wrappedListener, Cr.NS_OK, cIOL.ADD, item.id, item);
+ } else {
+ this.notifyOperationComplete(wrappedListener, Cr.NS_OK, cIOL.MODIFY, item.id, item);
+ }
+ });
+ return;
+ }
+ }
+
+ // Either there's no listener, or we're uncached.
+
+ if (this.mItemInfoCache[item.id].isNew) {
+ await this.mOfflineStorage.adoptItem(item).then(
+ () => aListener?.onOperationComplete(item.calendar, Cr.NS_OK, cIOL.ADD, item.id, item),
+ e => aListener?.onOperationComplete(null, e.result, null, null, e)
+ );
+ } else {
+ await this.mOfflineStorage.modifyItem(item, null).then(
+ item => aListener?.onOperationComplete(item.calendar, Cr.NS_OK, cIOL.MODIFY, item.id, item),
+ e => aListener?.onOperationComplete(null, e.result, null, null, e)
+ );
+ }
+ },
+
+ /**
+ * Deletes an item from the target calendar
+ *
+ * @param path Path of the item to delete, must not be encoded
+ */
+ async deleteTargetCalendarItem(path) {
+ let foundItem = await this.mOfflineStorage.getItem(this.mHrefIndex[path]);
+ let wasInboxItem = this.mItemInfoCache[foundItem.id].isInboxItem;
+ if ((wasInboxItem && this.isInbox(path)) || (wasInboxItem === false && !this.isInbox(path))) {
+ cal.LOG("CalDAV: deleting item: " + path + ", uid: " + foundItem.id);
+ delete this.mHrefIndex[path];
+ delete this.mItemInfoCache[foundItem.id];
+ if (this.isCached) {
+ this.mOfflineStorage.deleteMetaData(foundItem.id);
+ }
+ await this.mOfflineStorage.deleteItem(foundItem);
+ }
+ },
+
+ /**
+ * Perform tasks required after updating items in the calendar such as
+ * notifying the observers and listeners
+ *
+ * @param aChangeLogListener Change log listener
+ * @param calendarURI URI of the calendar whose items just got
+ * changed
+ */
+ finalizeUpdatedItems(aChangeLogListener, calendarURI) {
+ cal.LOG(
+ "aChangeLogListener=" +
+ aChangeLogListener +
+ "\n" +
+ "calendarURI=" +
+ (calendarURI ? calendarURI.spec : "undefined") +
+ " \n" +
+ "iscached=" +
+ this.isCached +
+ "\n" +
+ "this.mQueuedQueries.length=" +
+ this.mQueuedQueries.length
+ );
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status: Cr.NS_OK }, Cr.NS_OK);
+ } else {
+ this.mObservers.notify("onLoad", [this]);
+ }
+
+ if (this.mProposedCtag) {
+ this.mCtag = this.mProposedCtag;
+ this.mProposedCtag = null;
+ }
+
+ this.mFirstRefreshDone = true;
+ while (this.mQueuedQueries.length) {
+ let query = this.mQueuedQueries.pop();
+ let { filter, count, rangeStart, rangeEnd } = query;
+ query.onStream(this.mOfflineStorage.getItems(filter, count, rangeStart, rangeEnd));
+ }
+ if (this.hasScheduling && !this.isInbox(calendarURI.spec)) {
+ this.pollInbox();
+ }
+ },
+
+ /**
+ * Notifies the caller that a get request has failed.
+ *
+ * @param errorMsg Error message
+ * @param aListener (optional) Listener of the request
+ * @param aChangeLogListener (optional)Listener for cached calendars
+ */
+ notifyGetFailed(errorMsg, aListener, aChangeLogListener) {
+ cal.WARN("CalDAV: Get failed: " + errorMsg);
+
+ // Notify changelog listener
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+
+ // Notify operation listener
+ this.notifyOperationComplete(aListener, Cr.NS_ERROR_FAILURE, cIOL.GET, null, errorMsg);
+ // If an error occurs here, we also need to unqueue the
+ // requests previously queued.
+ while (this.mQueuedQueries.length) {
+ this.mQueuedQueries.pop().onError(new Components.Exception(errorMsg, Cr.NS_ERROR_FAILURE));
+ }
+ },
+
+ /**
+ * Retrieves a specific item from the CalDAV store.
+ * Use when an outdated copy of the item is in hand.
+ *
+ * @param aItem item to fetch
+ * @param aListener listener for method completion
+ */
+ getUpdatedItem(aItem, aListener, aChangeLogListener) {
+ if (aItem == null) {
+ this.notifyOperationComplete(
+ aListener,
+ Cr.NS_ERROR_FAILURE,
+ cIOL.GET,
+ null,
+ "passed in null item"
+ );
+ return;
+ }
+
+ let locationPath = this.getItemLocationPath(aItem);
+ let itemUri = this.makeUri(locationPath);
+
+ let multiget = new CalDavMultigetSyncHandler(
+ [this.ensureDecodedPath(itemUri.pathQueryRef)],
+ this,
+ this.makeUri(),
+ null,
+ false,
+ aListener,
+ aChangeLogListener
+ );
+ multiget.doMultiGet();
+ },
+
+ // Promise<calIItemBase|null> getItem(in string id);
+ async getItem(aId) {
+ return this.mOfflineStorage.getItem(aId);
+ },
+
+ // ReadableStream<calIItemBase> getItems(in unsigned long filter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ getItems(filter, count, rangeStart, rangeEnd) {
+ if (this.isCached) {
+ if (this.mOfflineStorage) {
+ return this.mOfflineStorage.getItems(...arguments);
+ }
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ } else if (
+ this.checkedServerInfo ||
+ this.getProperty("currentStatus") == Ci.calIErrors.READ_FAILED
+ ) {
+ return this.mOfflineStorage.getItems(...arguments);
+ }
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ return new Promise((resolve, reject) => {
+ self.mQueuedQueries.push({
+ filter,
+ count,
+ rangeStart,
+ rangeEnd,
+ failed: false,
+ onError(e) {
+ this.failed = true;
+ reject(e);
+ },
+ async onStream(stream) {
+ for await (let items of cal.iterate.streamValues(stream)) {
+ if (this.failed) {
+ break;
+ }
+ controller.enqueue(items);
+ }
+ if (!this.failed) {
+ controller.close();
+ resolve();
+ }
+ },
+ });
+ });
+ },
+ }
+ );
+ },
+
+ fillACLProperties() {
+ let orgId = this.calendarUserAddress;
+ if (orgId) {
+ this.mACLProperties.organizerId = orgId;
+ }
+
+ if (this.mACLEntry && this.mACLEntry.hasAccessControl) {
+ let ownerIdentities = this.mACLEntry.getOwnerIdentities();
+ if (ownerIdentities.length > 0) {
+ let identity = ownerIdentities[0];
+ this.mACLProperties.organizerId = identity.email;
+ this.mACLProperties.organizerCN = identity.fullName;
+ this.mACLProperties["imip.identity"] = identity;
+ }
+ }
+ },
+
+ safeRefresh(aChangeLogListener) {
+ let notifyListener = status => {
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status }, status);
+ }
+ };
+
+ if (!this.mACLEntry) {
+ let self = this;
+ let opListener = {
+ QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]),
+ onGetResult(calendar, status, itemType, detail, items) {
+ cal.ASSERT(false, "unexpected!");
+ },
+ onOperationComplete(opCalendar, opStatus, opType, opId, opDetail) {
+ self.mACLEntry = opDetail;
+ self.fillACLProperties();
+ self.safeRefresh(aChangeLogListener);
+ },
+ };
+
+ this.aclManager.getCalendarEntry(this, opListener);
+ return;
+ }
+
+ this.ensureTargetCalendar();
+
+ if (this.mAuthScheme == "Digest") {
+ // the auth could have timed out and be in need of renegotiation we can't risk several
+ // calendars doing this simultaneously so we'll force the renegotiation in a sync query,
+ // using OPTIONS to keep it quick
+ let headchannel = cal.provider.prepHttpChannel(this.makeUri(), null, null, this);
+ headchannel.requestMethod = "OPTIONS";
+ headchannel.open();
+ headchannel.QueryInterface(Ci.nsIHttpChannel);
+ try {
+ if (headchannel.responseStatus != 200) {
+ throw new Error("OPTIONS returned unexpected status code: " + headchannel.responseStatus);
+ }
+ } catch (e) {
+ cal.WARN("CalDAV: Exception: " + e);
+ notifyListener(Cr.NS_ERROR_FAILURE);
+ }
+ }
+
+ // Call getUpdatedItems right away if its the first refresh *OR* if webdav Sync is enabled
+ // (It is redundant to send a request to get the collection tag (getctag) on a calendar if
+ // it supports webdav sync, the sync request will only return data if something changed).
+ if (!this.mCtag || !this.mFirstRefreshDone || this.mHasWebdavSyncSupport) {
+ this.getUpdatedItems(this.calendarUri, aChangeLogListener);
+ return;
+ }
+ let request = new CalDavPropfindRequest(this.session, this, this.makeUri(), ["CS:getctag"]);
+
+ request.commit().then(response => {
+ cal.LOG(`CalDAV: Status ${response.status} checking ctag for calendar ${this.name}`);
+
+ if (response.status == -1) {
+ notifyListener(Cr.NS_OK);
+ return;
+ } else if (response.notFound) {
+ cal.LOG(`CalDAV: Disabling calendar ${this.name} due to 404`);
+ notifyListener(Cr.NS_ERROR_FAILURE);
+ return;
+ } else if (response.ok && this.mDisabledByDavError) {
+ // Looks like the calendar is there again, check its resource
+ // type first.
+ this.checkDavResourceType(aChangeLogListener);
+ return;
+ } else if (!response.ok) {
+ cal.LOG("CalDAV: Failed to get ctag from server for calendar " + this.name);
+ notifyListener(Cr.NS_OK);
+ return;
+ }
+
+ let ctag = response.firstProps["CS:getctag"];
+ if (!ctag || ctag != this.mCtag) {
+ // ctag mismatch, need to fetch calendar-data
+ this.mProposedCtag = ctag;
+ this.getUpdatedItems(this.calendarUri, aChangeLogListener);
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: ctag mismatch on refresh, fetching data for calendar " + this.name);
+ }
+ } else {
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: ctag matches, no need to fetch data for calendar " + this.name);
+ }
+
+ // Notify the listener, but don't return just yet...
+ notifyListener(Cr.NS_OK);
+
+ // ...we may still need to poll the inbox
+ if (this.firstInRealm()) {
+ this.pollInbox();
+ }
+ }
+ });
+ },
+
+ refresh() {
+ this.replayChangesOn(null);
+ },
+
+ firstInRealm() {
+ let calendars = cal.manager.getCalendars();
+ for (let i = 0; i < calendars.length; i++) {
+ if (calendars[i].type != "caldav" || calendars[i].getProperty("disabled")) {
+ continue;
+ }
+ // XXX We should probably expose the inner calendar via an
+ // interface, but for now use wrappedJSObject.
+ let calendar = calendars[i].wrappedJSObject;
+ if (calendar.mUncachedCalendar) {
+ calendar = calendar.mUncachedCalendar;
+ }
+ if (calendar.uri.prePath == this.uri.prePath && calendar.authRealm == this.mAuthRealm) {
+ if (calendar.id == this.id) {
+ return true;
+ }
+ break;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Get updated items
+ *
+ * @param {nsIURI} aUri - The uri to request the items from.
+ * NOTE: This must be the uri without any uri
+ * params. They will be appended in this function.
+ * @param aChangeLogListener - (optional) The listener to notify for cached
+ * calendars.
+ */
+ getUpdatedItems(aUri, aChangeLogListener) {
+ if (this.mDisabledByDavError) {
+ // check if maybe our calendar has become available
+ this.checkDavResourceType(aChangeLogListener);
+ return;
+ }
+
+ if (this.mHasWebdavSyncSupport) {
+ let webDavSync = new CalDavWebDavSyncHandler(this, aUri, aChangeLogListener);
+ webDavSync.doWebDAVSync();
+ return;
+ }
+
+ let queryXml =
+ XML_HEADER +
+ '<D:propfind xmlns:D="DAV:">' +
+ "<D:prop>" +
+ "<D:getcontenttype/>" +
+ "<D:resourcetype/>" +
+ "<D:getetag/>" +
+ "</D:prop>" +
+ "</D:propfind>";
+
+ let requestUri = this.makeUri(null, aUri);
+ let handler = new CalDavEtagsHandler(this, aUri, aChangeLogListener);
+
+ let onSetupChannel = channel => {
+ channel.requestMethod = "PROPFIND";
+ channel.setRequestHeader("Depth", "1", false);
+ };
+ let request = new CalDavLegacySAXRequest(
+ this.session,
+ this,
+ requestUri,
+ queryXml,
+ MIME_TEXT_XML,
+ handler,
+ onSetupChannel
+ );
+
+ request.commit().catch(() => {
+ if (aChangeLogListener && this.isCached) {
+ aChangeLogListener.onResult(
+ { status: Cr.NS_ERROR_NOT_AVAILABLE },
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ });
+ },
+
+ /**
+ * @see nsIInterfaceRequestor
+ * @see calProviderUtils.jsm
+ */
+ getInterface: cal.provider.InterfaceRequestor_getInterface,
+
+ //
+ // Helper functions
+ //
+
+ oauthConnect(authSuccessCb, authFailureCb, aRefresh = false) {
+ // Use the async prompter to avoid multiple primary password prompts
+ let self = this;
+ let promptlistener = {
+ onPromptStartAsync(callback) {
+ this.onPromptAuthAvailable(callback);
+ },
+ onPromptAuthAvailable(callback) {
+ self.oauth.connect(
+ () => {
+ authSuccessCb();
+ if (callback) {
+ callback.onAuthResult(true);
+ }
+ },
+ () => {
+ authFailureCb();
+ if (callback) {
+ callback.onAuthResult(false);
+ }
+ },
+ true,
+ aRefresh
+ );
+ },
+ onPromptCanceled: authFailureCb,
+ onPromptStart() {},
+ };
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(self.uri.spec, false, promptlistener);
+ },
+
+ /**
+ * Called when a response has had its URL redirected. Shows a dialog
+ * to allow the user to accept or reject the redirect. If they accept,
+ * change the calendar's URI to the target URI of the redirect.
+ *
+ * @param {PropfindResponse} response - Response to handle. Typically a
+ * PropfindResponse but could be any
+ * subclass of CalDavResponseBase.
+ * @returns {boolean} True if the user accepted the redirect.
+ * False, if the calendar should be disabled.
+ */
+ openUriRedirectDialog(response) {
+ let args = {
+ calendarName: this.name,
+ originalURI: response.nsirequest.originalURI.spec,
+ targetURI: response.uri.spec,
+ returnValue: false,
+ };
+
+ cal.window
+ .getCalendarWindow()
+ .openDialog(
+ "chrome://calendar/content/calendar-uri-redirect-dialog.xhtml",
+ "Calendar:URIRedirectDialog",
+ "chrome,modal,titlebar,resizable,centerscreen",
+ args
+ );
+
+ if (args.returnValue) {
+ this.uri = response.uri;
+ this.setProperty("uri", response.uri.spec);
+ }
+
+ return args.returnValue;
+ },
+
+ /**
+ * Checks that the calendar URI exists and is a CalDAV calendar. This is the beginning of a
+ * chain of asynchronous calls. This function will, when done, call the next function related to
+ * checking resource type, server capabilities, etc.
+ *
+ * checkDavResourceType * You are here
+ * checkServerCaps
+ * findPrincipalNS
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo
+ */
+ checkDavResourceType(aChangeLogListener) {
+ this.ensureTargetCalendar();
+
+ let request = new CalDavPropfindRequest(this.session, this, this.makeUri(), [
+ "D:resourcetype",
+ "D:owner",
+ "D:current-user-principal",
+ "D:current-user-privilege-set",
+ "D:supported-report-set",
+ "C:supported-calendar-component-set",
+ "CS:getctag",
+ ]);
+
+ request.commit().then(
+ response => {
+ cal.LOG(`CalDAV: Status ${response.status} on initial PROPFIND for calendar ${this.name}`);
+
+ // If the URI was redirected, and the user rejects the redirect, disable the calendar.
+ if (response.redirected && !this.openUriRedirectDialog(response)) {
+ this.setProperty("disabled", "true");
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+ return;
+ }
+
+ if (response.clientError) {
+ // 4xx codes, which is either an authentication failure or something like method not
+ // allowed. This is a failure worth disabling the calendar.
+ this.setProperty("disabled", "true");
+ this.setProperty("auto-enabled", "true");
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+ return;
+ } else if (response.serverError) {
+ // 5xx codes, a server error. This could be a temporary failure, i.e a backend
+ // server being disabled.
+ cal.LOG(
+ "CalDAV: Server not available " +
+ request.responseStatus +
+ ", abort sync for calendar " +
+ this.name
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_ABORT);
+ return;
+ }
+
+ let wwwauth = request.getHeader("Authorization");
+ this.mAuthScheme = wwwauth ? wwwauth.split(" ")[0] : "none";
+
+ if (this.mUriParams) {
+ this.mAuthScheme = "Ticket";
+ }
+ cal.LOG(`CalDAV: Authentication scheme for ${this.name} is ${this.mAuthScheme}`);
+
+ // We only really need the authrealm for Digest auth since only Digest is going to time
+ // out on us
+ if (this.mAuthScheme == "Digest") {
+ let realmChop = wwwauth.split('realm="')[1];
+ this.mAuthRealm = realmChop.split('", ')[0];
+ cal.LOG("CalDAV: realm " + this.mAuthRealm);
+ }
+
+ if (!response.text || response.notFound) {
+ // No response, or the calendar no longer exists.
+ cal.LOG("CalDAV: Failed to determine resource type for" + this.name);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ return;
+ }
+
+ let multistatus = response.xml;
+ if (!multistatus) {
+ cal.LOG(`CalDAV: Failed to determine resource type for ${this.name}`);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ return;
+ }
+
+ // check for webdav-sync capability
+ // http://tools.ietf.org/html/draft-daboo-webdav-sync
+ if (response.firstProps["D:supported-report-set"]?.has("D:sync-collection")) {
+ cal.LOG("CalDAV: Collection has webdav sync support");
+ this.mHasWebdavSyncSupport = true;
+ }
+
+ // check for server-side ctag support only if webdav sync is not available
+ let ctag = response.firstProps["CS:getctag"];
+ if (!this.mHasWebdavSyncSupport && ctag) {
+ // We compare the stored ctag with the one we just got, if
+ // they don't match, we update the items in safeRefresh.
+ if (ctag == this.mCtag) {
+ this.mFirstRefreshDone = true;
+ }
+
+ this.mProposedCtag = ctag;
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: initial ctag ${ctag} for calendar ${this.name}`);
+ }
+ }
+
+ // Use supported-calendar-component-set if the server supports it; some do not.
+ let supportedComponents = response.firstProps["C:supported-calendar-component-set"];
+ if (supportedComponents?.size) {
+ this.mSupportedItemTypes = [...this.mGenerallySupportedItemTypes].filter(itype => {
+ return supportedComponents.has(itype);
+ });
+ cal.LOG(
+ `Adding supported items: ${this.mSupportedItemTypes.join(",")} for calendar: ${
+ this.name
+ }`
+ );
+ }
+
+ // check if current-user-principal or owner is specified; might save some work finding
+ // the principal URL.
+ let owner = response.firstProps["D:owner"];
+ let cuprincipal = response.firstProps["D:current-user-principal"];
+ if (cuprincipal) {
+ this.mPrincipalUrl = cuprincipal;
+ cal.LOG(
+ "CalDAV: Found principal url from DAV:current-user-principal " + this.mPrincipalUrl
+ );
+ } else if (owner) {
+ this.mPrincipalUrl = owner;
+ cal.LOG("CalDAV: Found principal url from DAV:owner " + this.mPrincipalUrl);
+ }
+
+ let resourceType = response.firstProps["D:resourcetype"] || new Set();
+ if (resourceType.has("C:calendar")) {
+ // This is a valid calendar resource
+ if (this.mDisabledByDavError) {
+ this.mDisabledByDavError = false;
+ }
+
+ let privs = response.firstProps["D:current-user-privilege-set"];
+ // Don't clear this.readOnly, only set it. The user may have write
+ // privileges but not want to use them.
+ if (!this.readOnly && privs && privs instanceof Set) {
+ this.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
+ priv => privs.has(priv)
+ );
+ }
+
+ this.setCalHomeSet(true);
+ this.checkServerCaps(aChangeLogListener);
+ } else if (resourceType.has("D:collection")) {
+ // Not a CalDAV calendar
+ cal.LOG(`CalDAV: ${this.name} points to a DAV resource, but not a CalDAV calendar`);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_DAV_NOT_CALDAV);
+ } else {
+ // Something else?
+ cal.LOG(
+ `CalDAV: No resource type received, ${this.name} doesn't seem to point to a DAV resource`
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Error during initial PROPFIND for calendar ${this.name}: ${e}`);
+ this.completeCheckServerInfo(aChangeLogListener, Ci.calIErrors.DAV_NOT_DAV);
+ }
+ );
+ },
+
+ /**
+ * Checks server capabilities.
+ *
+ * checkDavResourceType
+ * checkServerCaps * You are here
+ * findPrincipalNS
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo
+ */
+ checkServerCaps(aChangeLogListener, calHomeSetUrlRetry) {
+ let request = new CalDavHeaderRequest(this.session, this, this.makeUri(null, this.mCalHomeSet));
+
+ request.commit().then(
+ response => {
+ if (!response.ok) {
+ if (!calHomeSetUrlRetry && response.notFound) {
+ // try again with calendar URL, see https://bugzilla.mozilla.org/show_bug.cgi?id=588799
+ cal.LOG(
+ "CalDAV: Calendar homeset was not found at parent url of calendar URL" +
+ ` while querying options ${this.name}, will try calendar URL itself now`
+ );
+ this.setCalHomeSet(false);
+ this.checkServerCaps(aChangeLogListener, true);
+ } else {
+ cal.LOG(
+ `CalDAV: Unexpected status ${response.status} while querying options ${this.name}`
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+
+ // No further processing needed, we have called subsequent (async) functions above.
+ return;
+ }
+
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: DAV features: " + [...response.features.values()].join(", "));
+ }
+
+ if (response.features.has("calendar-auto-schedule")) {
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: Calendar ${this.name} supports calendar-auto-schedule`);
+ }
+ this.mHasAutoScheduling = true;
+ // leave outbound inbox/outbox scheduling off
+ } else if (response.features.has("calendar-schedule")) {
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: Calendar ${this.name} generally supports calendar-schedule`);
+ }
+ this.hasScheduling = true;
+ }
+
+ if (this.hasAutoScheduling || response.features.has("calendar-schedule")) {
+ // XXX - we really shouldn't register with the fb service if another calendar with
+ // the same principal-URL has already done so. We also shouldn't register with the
+ // fb service if we don't have an outbox.
+ if (!this.hasFreeBusy) {
+ // This may have already been set by fetchCachedMetaData, we only want to add
+ // the freebusy provider once.
+ this.hasFreeBusy = true;
+ cal.freeBusyService.addProvider(this);
+ }
+ this.findPrincipalNS(aChangeLogListener);
+ } else {
+ cal.LOG("CalDAV: Server does not support CalDAV scheduling.");
+ this.completeCheckServerInfo(aChangeLogListener);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Error checking server capabilities for calendar ${this.name}: ${e}`);
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+ );
+ },
+
+ /**
+ * Locates the principal namespace. This function should solely be called
+ * from checkServerCaps to find the principal namespace.
+ *
+ * checkDavResourceType
+ * checkServerCaps
+ * findPrincipalNS * You are here
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo
+ */
+ findPrincipalNS(aChangeLogListener) {
+ if (this.principalUrl) {
+ // We already have a principal namespace, use it.
+ this.checkPrincipalsNameSpace([this.principalUrl], aChangeLogListener);
+ return;
+ }
+
+ let homeSet = this.makeUri(null, this.mCalHomeSet);
+ let request = new CalDavPropfindRequest(this.session, this, homeSet, [
+ "D:principal-collection-set",
+ ]);
+
+ request.commit().then(
+ response => {
+ if (response.ok) {
+ let pcs = response.firstProps["D:principal-collection-set"];
+ let nsList = pcs ? pcs.map(path => this.ensureDecodedPath(path)) : [];
+
+ this.checkPrincipalsNameSpace(nsList, aChangeLogListener);
+ } else {
+ cal.LOG(
+ "CalDAV: Unexpected status " +
+ response.status +
+ " while querying principal namespace for " +
+ this.name
+ );
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Failed to propstat principal namespace for calendar ${this.name}: ${e}`);
+ this.completeCheckServerInfo(aChangeLogListener, Cr.NS_ERROR_FAILURE);
+ }
+ );
+ },
+
+ /**
+ * Checks the principals namespace for scheduling info. This function should
+ * solely be called from findPrincipalNS
+ *
+ * checkDavResourceType
+ * checkServerCaps
+ * findPrincipalNS
+ * checkPrincipalsNameSpace * You are here
+ * completeCheckServerInfo
+ *
+ * @param aNameSpaceList List of available namespaces
+ */
+ checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener) {
+ let doesntSupportScheduling = () => {
+ this.hasScheduling = false;
+ this.mInboxUrl = null;
+ this.mOutboxUrl = null;
+ this.completeCheckServerInfo(aChangeLogListener);
+ };
+
+ if (!aNameSpaceList.length) {
+ if (this.verboseLogging()) {
+ cal.LOG(
+ "CalDAV: principal namespace list empty, calendar " +
+ this.name +
+ " doesn't support scheduling"
+ );
+ }
+ doesntSupportScheduling();
+ return;
+ }
+
+ // We want a trailing slash, ensure it.
+ let nextNS = aNameSpaceList.pop().replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
+ let requestUri = Services.io.newURI(this.calendarUri.prePath + this.ensureEncodedPath(nextNS));
+ let requestProps = [
+ "C:calendar-home-set",
+ "C:calendar-user-address-set",
+ "C:schedule-inbox-URL",
+ "C:schedule-outbox-URL",
+ ];
+
+ let request;
+ if (this.mPrincipalUrl) {
+ request = new CalDavPropfindRequest(this.session, this, requestUri, requestProps);
+ } else {
+ let homePath = this.ensureEncodedPath(this.mCalHomeSet.spec.replace(/\/$/, ""));
+ request = new CalDavPrincipalPropertySearchRequest(
+ this.session,
+ this,
+ requestUri,
+ homePath,
+ "C:calendar-home-set",
+ requestProps
+ );
+ }
+
+ request.commit().then(
+ response => {
+ let homeSetMatches = homeSet => {
+ let normalized = homeSet.replace(/([^\/])$/, "$1/"); // eslint-disable-line no-useless-escape
+ let chs = this.mCalHomeSet;
+ return normalized == chs.path || normalized == chs.spec;
+ };
+ let createBoxUrl = path => {
+ if (!path) {
+ return null;
+ }
+ let newPath = this.ensureDecodedPath(path);
+ // Make sure the uri has a / at the end, as we do with the calendarUri.
+ if (newPath.charAt(newPath.length - 1) != "/") {
+ newPath += "/";
+ }
+ return this.mUri.mutate().setPathQueryRef(newPath).finalize();
+ };
+
+ if (!response.ok) {
+ cal.LOG(
+ `CalDAV: Bad response to in/outbox query, status ${response.status} for ${this.name}`
+ );
+ doesntSupportScheduling();
+ return;
+ }
+
+ // If there are multiple home sets, we need to match the email addresses for scheduling.
+ // If there is only one, assume its the right one.
+ // TODO with multiple address sets, we should just use the ACL manager.
+ let homeSets = response.firstProps["C:calendar-home-set"];
+ if (homeSets.length == 1 || homeSets.some(homeSetMatches)) {
+ for (let addr of response.firstProps["C:calendar-user-address-set"]) {
+ if (addr.match(/^mailto:/i)) {
+ this.mCalendarUserAddress = addr;
+ }
+ }
+
+ this.mInboxUrl = createBoxUrl(response.firstProps["C:schedule-inbox-URL"]);
+ this.mOutboxUrl = createBoxUrl(response.firstProps["C:schedule-outbox-URL"]);
+
+ if (!this.mInboxUrl || this.calendarUri.spec == this.mInboxUrl.spec) {
+ // If the inbox matches the calendar uri (i.e SOGo), then we
+ // don't need to poll the inbox.
+ this.mShouldPollInbox = false;
+ }
+ }
+
+ if (!this.calendarUserAddress || !this.mInboxUrl || !this.mOutboxUrl) {
+ if (aNameSpaceList.length) {
+ // Check the next namespace to find the info we need.
+ this.checkPrincipalsNameSpace(aNameSpaceList, aChangeLogListener);
+ } else {
+ if (this.verboseLogging()) {
+ cal.LOG(
+ "CalDAV: principal namespace list empty, calendar " +
+ this.name +
+ " doesn't support scheduling"
+ );
+ }
+ doesntSupportScheduling();
+ }
+ } else {
+ // We have everything, complete.
+ this.completeCheckServerInfo(aChangeLogListener);
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Failure checking principal namespace for calendar ${this.name}: ${e}`);
+ doesntSupportScheduling();
+ }
+ );
+ },
+
+ /**
+ * This is called to complete checking the server info. It should be the
+ * final call when checking server options. This will either report the
+ * error or if it is a success then refresh the calendar.
+ *
+ * checkDavResourceType
+ * checkServerCaps
+ * findPrincipalNS
+ * checkPrincipalsNameSpace
+ * completeCheckServerInfo * You are here
+ */
+ completeCheckServerInfo(aChangeLogListener, aError = Cr.NS_OK) {
+ if (Components.isSuccessCode(aError)) {
+ this.saveCalendarProperties();
+ this.checkedServerInfo = true;
+ this.setProperty("currentStatus", Cr.NS_OK);
+ if (this.isCached) {
+ this.safeRefresh(aChangeLogListener);
+ } else {
+ this.refresh();
+ }
+ } else {
+ this.reportDavError(aError);
+ if (this.isCached && aChangeLogListener) {
+ aChangeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ }
+ },
+
+ /**
+ * Called to report a certain DAV error. Strings and modification type are
+ * handled here.
+ */
+ reportDavError(aErrNo, status, extraInfo) {
+ let mapError = {};
+ mapError[Ci.calIErrors.DAV_NOT_DAV] = "dav_notDav";
+ mapError[Ci.calIErrors.DAV_DAV_NOT_CALDAV] = "dav_davNotCaldav";
+ mapError[Ci.calIErrors.DAV_PUT_ERROR] = "itemPutError";
+ mapError[Ci.calIErrors.DAV_REMOVE_ERROR] = "itemDeleteError";
+ mapError[Ci.calIErrors.DAV_REPORT_ERROR] = "disabledMode";
+
+ let mapModification = {};
+ mapModification[Ci.calIErrors.DAV_NOT_DAV] = false;
+ mapModification[Ci.calIErrors.DAV_DAV_NOT_CALDAV] = false;
+ mapModification[Ci.calIErrors.DAV_PUT_ERROR] = true;
+ mapModification[Ci.calIErrors.DAV_REMOVE_ERROR] = true;
+ mapModification[Ci.calIErrors.DAV_REPORT_ERROR] = false;
+
+ let message = mapError[aErrNo];
+ let localizedMessage;
+ let modificationError = mapModification[aErrNo];
+
+ if (!message) {
+ // Only notify if there is a message for this error
+ return;
+ }
+ localizedMessage = cal.l10n.getCalString(message, [this.mUri.spec]);
+ this.mDisabledByDavError = true;
+ this.notifyError(aErrNo, localizedMessage);
+ this.notifyError(
+ modificationError ? Ci.calIErrors.MODIFICATION_FAILED : Ci.calIErrors.READ_FAILED,
+ this.buildDetailedMessage(status, extraInfo)
+ );
+ },
+
+ buildDetailedMessage(status, extraInfo) {
+ if (!status) {
+ return "";
+ }
+
+ let props = Services.strings.createBundle("chrome://calendar/locale/calendar.properties");
+ let statusString;
+ try {
+ statusString = props.GetStringFromName("caldavRequestStatusCodeString" + status);
+ } catch (e) {
+ // Fallback on generic string if no string is defined for the status code
+ statusString = props.GetStringFromName("caldavRequestStatusCodeStringGeneric");
+ }
+ return (
+ props.formatStringFromName("caldavRequestStatusCode", [status]) +
+ ", " +
+ statusString +
+ "\n\n" +
+ (extraInfo ? extraInfo : "")
+ );
+ },
+
+ //
+ // calIFreeBusyProvider interface
+ //
+
+ getFreeBusyIntervals(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) {
+ // We explicitly don't check for hasScheduling here to allow free-busy queries
+ // even in case sched is turned off.
+ if (!this.outboxUrl || !this.calendarUserAddress) {
+ cal.LOG(
+ "CalDAV: Calendar " +
+ this.name +
+ " doesn't support scheduling;" +
+ " freebusy query not possible"
+ );
+ aListener.onResult(null, null);
+ return;
+ }
+
+ if (!this.firstInRealm()) {
+ // don't spam every known outbox with freebusy queries
+ aListener.onResult(null, null);
+ return;
+ }
+
+ // We tweak the organizer lookup here: If e.g. scheduling is turned off, then the
+ // configured email takes place being the organizerId for scheduling which need
+ // not match against the calendar-user-address:
+ let orgId = this.getProperty("organizerId");
+ if (orgId && orgId.toLowerCase() == aCalId.toLowerCase()) {
+ aCalId = this.calendarUserAddress; // continue with calendar-user-address
+ }
+
+ // the caller prepends MAILTO: to calid strings containing @
+ // but apple needs that to be mailto:
+ let aCalIdParts = aCalId.split(":");
+ aCalIdParts[0] = aCalIdParts[0].toLowerCase();
+ if (aCalIdParts[0] != "mailto" && aCalIdParts[0] != "http" && aCalIdParts[0] != "https") {
+ aListener.onResult(null, null);
+ return;
+ }
+
+ let organizer = this.calendarUserAddress;
+ let recipient = aCalIdParts.join(":");
+ let fbUri = this.makeUri(null, this.outboxUrl);
+
+ let request = new CalDavFreeBusyRequest(
+ this.session,
+ this,
+ fbUri,
+ organizer,
+ recipient,
+ aRangeStart,
+ aRangeEnd
+ );
+
+ request.commit().then(
+ response => {
+ if (!response.xml || response.status != 200) {
+ cal.LOG(
+ "CalDAV: Received status " + response.status + " from freebusy query for " + this.name
+ );
+ aListener.onResult(null, null);
+ return;
+ }
+
+ let fbTypeMap = {
+ UNKNOWN: Ci.calIFreeBusyInterval.UNKNOWN,
+ FREE: Ci.calIFreeBusyInterval.FREE,
+ BUSY: Ci.calIFreeBusyInterval.BUSY,
+ "BUSY-UNAVAILABLE": Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE,
+ "BUSY-TENTATIVE": Ci.calIFreeBusyInterval.BUSY_TENTATIVE,
+ };
+
+ let status = response.firstRecipient.status;
+ if (!status || !status.startsWith("2")) {
+ cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`);
+ aListener.onResult(null, null);
+ return;
+ }
+
+ if (!status.startsWith("2.0")) {
+ cal.LOG(`CalDAV: Got status ${status} in response to freebusy query for ${this.name}`);
+ }
+
+ let intervals = response.firstRecipient.intervals.map(data => {
+ let fbType = fbTypeMap[data.type] || Ci.calIFreeBusyInterval.UNKNOWN;
+ return new cal.provider.FreeBusyInterval(aCalId, fbType, data.begin, data.end);
+ });
+
+ aListener.onResult(null, intervals);
+ },
+ e => {
+ cal.LOG(`CalDAV: Failed freebusy request for ${this.name}: ${e}`);
+ aListener.onResult(null, null);
+ }
+ );
+ },
+
+ /**
+ * Extract the path from the full spec, if the regexp failed, log
+ * warning and return unaltered path.
+ */
+ extractPathFromSpec(aSpec) {
+ // The parsed array should look like this:
+ // a[0] = full string
+ // a[1] = scheme
+ // a[2] = everything between the scheme and the start of the path
+ // a[3] = extracted path
+ let a = aSpec.match("(https?)(://[^/]*)([^#?]*)");
+ if (a && a[3]) {
+ return a[3];
+ }
+ cal.WARN("CalDAV: Spec could not be parsed, returning as-is: " + aSpec);
+ return aSpec;
+ },
+ /**
+ * This is called to create an encoded path from a unencoded path OR
+ * encoded full url
+ *
+ * @param aString {string} un-encoded path OR encoded uri spec.
+ */
+ ensureEncodedPath(aString) {
+ if (aString.charAt(0) != "/") {
+ aString = this.ensureDecodedPath(aString);
+ }
+ let uriComponents = aString.split("/");
+ uriComponents = uriComponents.map(encodeURIComponent);
+ return uriComponents.join("/");
+ },
+
+ /**
+ * This is called to get a decoded path from an encoded path or uri spec.
+ *
+ * @param {string} aString - Represents either a path
+ * or a full uri that needs to be decoded.
+ * @returns {string} A decoded path.
+ */
+ ensureDecodedPath(aString) {
+ if (aString.charAt(0) != "/") {
+ aString = this.extractPathFromSpec(aString);
+ }
+
+ let uriComponents = aString.split("/");
+ for (let i = 0; i < uriComponents.length; i++) {
+ try {
+ uriComponents[i] = decodeURIComponent(uriComponents[i]);
+ } catch (e) {
+ cal.WARN("CalDAV: Exception decoding path " + aString + ", segment: " + uriComponents[i]);
+ }
+ }
+ return uriComponents.join("/");
+ },
+ isInbox(aString) {
+ // Note: If you change this, make sure it really returns a boolean
+ // value and not null!
+ return (
+ (this.hasScheduling || this.hasAutoScheduling) &&
+ this.mInboxUrl != null &&
+ aString.startsWith(this.mInboxUrl.spec)
+ );
+ },
+
+ /**
+ * Query contents of scheduling inbox
+ *
+ */
+ pollInbox() {
+ // If polling the inbox was switched off, no need to poll the inbox.
+ // Also, if we have more than one calendar in this CalDAV account, we
+ // want only one of them to be checking the inbox.
+ if (
+ (!this.hasScheduling && !this.hasAutoScheduling) ||
+ !this.mShouldPollInbox ||
+ !this.firstInRealm()
+ ) {
+ return;
+ }
+
+ this.getUpdatedItems(this.mInboxUrl, null);
+ },
+
+ //
+ // take calISchedulingSupport interface base implementation (cal.provider.BaseClass)
+ //
+
+ async processItipReply(aItem, aPath) {
+ // modify partstat for in-calendar item
+ // delete item from inbox
+ let self = this;
+ let modListener = {};
+ modListener.QueryInterface = ChromeUtils.generateQI(["calIOperationListener"]);
+ modListener.onOperationComplete = function (
+ aCalendar,
+ aStatus,
+ aOperationType,
+ aItemId,
+ aDetail
+ ) {
+ cal.LOG(`CalDAV: status ${aStatus} while processing iTIP REPLY for ${self.name}`);
+ // don't delete the REPLY item from inbox unless modifying the master
+ // item was successful
+ if (aStatus == 0) {
+ // aStatus undocumented; 0 seems to indicate no error
+ let delUri = self.calendarUri
+ .mutate()
+ .setPathQueryRef(self.ensureEncodedPath(aPath))
+ .finalize();
+ self.doDeleteItem(aItem, null, true, true, delUri);
+ }
+ };
+
+ let itemToUpdate = await this.mOfflineStorage.getItem(aItem.id);
+
+ if (aItem.recurrenceId && itemToUpdate.recurrenceInfo) {
+ itemToUpdate = itemToUpdate.recurrenceInfo.getOccurrenceFor(aItem.recurrenceId);
+ }
+ let newItem = itemToUpdate.clone();
+
+ for (let attendee of aItem.getAttendees()) {
+ let att = newItem.getAttendeeById(attendee.id);
+ if (att) {
+ newItem.removeAttendee(att);
+ att = att.clone();
+ att.participationStatus = attendee.participationStatus;
+ newItem.addAttendee(att);
+ }
+ }
+ self.doModifyItem(
+ newItem,
+ itemToUpdate.parentItem /* related to bug 396182 */,
+ modListener,
+ true
+ );
+ },
+
+ canNotify(aMethod, aItem) {
+ // canNotify should return false if the imip transport should takes care of notifying cal
+ // users
+ if (this.getProperty("forceEmailScheduling")) {
+ return false;
+ }
+ if (this.hasAutoScheduling || this.hasScheduling) {
+ // we go with server's scheduling capabilities here - we take care for exceptions if
+ // schedule agent is set to CLIENT in sendItems()
+ switch (aMethod) {
+ // supported methods as per RfC 6638
+ case "REPLY":
+ case "REQUEST":
+ case "CANCEL":
+ case "ADD":
+ return true;
+ default:
+ cal.LOG(
+ "Not supported method " +
+ aMethod +
+ " detected - falling back to email based scheduling."
+ );
+ }
+ }
+ return false; // use outbound iTIP for all
+ },
+
+ //
+ // calIItipTransport interface
+ //
+
+ get scheme() {
+ return "mailto";
+ },
+
+ mSenderAddress: null,
+ get senderAddress() {
+ return this.mSenderAddress || this.calendarUserAddress;
+ },
+ set senderAddress(aString) {
+ this.mSenderAddress = aString;
+ },
+
+ sendItems(aRecipients, aItipItem, aFromAttendee) {
+ function doImipScheduling(aCalendar, aRecipientList) {
+ let result = false;
+ let imipTransport = cal.provider.getImipTransport(aCalendar);
+ let recipients = [];
+ aRecipientList.forEach(rec => recipients.push(rec.toString()));
+ if (imipTransport) {
+ cal.LOG(
+ "Enforcing client-side email scheduling instead of server-side scheduling" +
+ " for " +
+ recipients.join()
+ );
+ result = imipTransport.sendItems(aRecipientList, aItipItem, aFromAttendee);
+ } else {
+ cal.ERROR(
+ "No imip transport available for " +
+ aCalendar.id +
+ ", failed to notify" +
+ recipients.join()
+ );
+ }
+ return result;
+ }
+
+ if (this.getProperty("forceEmailScheduling")) {
+ return doImipScheduling(this, aRecipients);
+ }
+
+ if (this.hasAutoScheduling || this.hasScheduling) {
+ // let's make sure we notify calendar users marked for client-side scheduling by email
+ let recipients = [];
+ for (let item of aItipItem.getItemList()) {
+ if (aItipItem.receivedMethod == "REPLY") {
+ if (item.organizer.getProperty("SCHEDULE-AGENT") == "CLIENT") {
+ recipients.push(item.organizer);
+ }
+ } else {
+ let atts = item.getAttendees().filter(att => {
+ return att.getProperty("SCHEDULE-AGENT") == "CLIENT";
+ });
+ for (let att of atts) {
+ recipients.push(att);
+ }
+ }
+ }
+ if (recipients.length) {
+ // We return the imip scheduling status here as any remaining calendar user will be
+ // notified by the server without receiving a status in the first place.
+ // We maybe could inspect the scheduling status of those attendees when
+ // re-retriving the modified event and try to do imip schedule on any status code
+ // other then 1.0, 1.1 or 1.2 - but I leave without that for now.
+ return doImipScheduling(this, recipients);
+ }
+ return true;
+ }
+
+ // from here on this code for explicit caldav scheduling
+ if (aItipItem.responseMethod == "REPLY") {
+ // Get my participation status
+ let attendee = aItipItem.getItemList()[0].getAttendeeById(this.calendarUserAddress);
+ if (!attendee) {
+ return false;
+ }
+ // work around BUG 351589, the below just removes RSVP:
+ aItipItem.setAttendeeStatus(attendee.id, attendee.participationStatus);
+ }
+
+ for (let item of aItipItem.getItemList()) {
+ let requestUri = this.makeUri(null, this.outboxUrl);
+ let request = new CalDavOutboxRequest(
+ this.session,
+ this,
+ requestUri,
+ this.calendarUserAddress,
+ aRecipients,
+ item
+ );
+
+ request.commit().then(
+ response => {
+ if (!response.ok) {
+ cal.LOG(`CalDAV: Sending iTIP failed with status ${response.status} for ${this.name}`);
+ }
+
+ let lowerRecipients = new Map(aRecipients.map(recip => [recip.id.toLowerCase(), recip]));
+ let remainingAttendees = [];
+ for (let [recipient, status] of Object.entries(response.data)) {
+ if (status.startsWith("2")) {
+ continue;
+ }
+
+ let att = lowerRecipients.get(recipient.toLowerCase());
+ if (att) {
+ remainingAttendees.push(att);
+ }
+ }
+
+ if (this.verboseLogging()) {
+ cal.LOG(
+ "CalDAV: Failed scheduling delivery to " +
+ remainingAttendees.map(att => att.id).join(", ")
+ );
+ }
+
+ if (remainingAttendees.length) {
+ // try to fall back to email delivery if CalDAV-sched didn't work
+ let imipTransport = cal.provider.getImipTransport(this);
+ if (imipTransport) {
+ if (this.verboseLogging()) {
+ cal.LOG(`CalDAV: sending email to ${remainingAttendees.length} recipients`);
+ }
+ imipTransport.sendItems(remainingAttendees, aItipItem, aFromAttendee);
+ } else {
+ cal.LOG("CalDAV: no fallback to iTIP/iMIP transport for " + this.name);
+ }
+ }
+ },
+ e => {
+ cal.LOG(`CalDAV: Failed itip request for ${this.name}: ${e}`);
+ }
+ );
+ }
+ return true;
+ },
+
+ mVerboseLogging: undefined,
+ verboseLogging() {
+ if (this.mVerboseLogging === undefined) {
+ this.mVerboseLogging = Services.prefs.getBoolPref("calendar.debug.log.verbose", false);
+ }
+ return this.mVerboseLogging;
+ },
+
+ getSerializedItem(aItem) {
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem]);
+ let serializedItem = serializer.serializeToString();
+ if (this.verboseLogging()) {
+ cal.LOG("CalDAV: send: " + serializedItem);
+ }
+ return serializedItem;
+ },
+};
+
+function calDavObserver(aCalendar) {
+ this.mCalendar = aCalendar;
+}
+
+calDavObserver.prototype = {
+ mCalendar: null,
+ mInBatch: false,
+
+ // calIObserver:
+ onStartBatch(calendar) {
+ this.mCalendar.observers.notify("onStartBatch", [calendar]);
+ this.mInBatch = true;
+ },
+ onEndBatch(calendar) {
+ this.mCalendar.observers.notify("onEndBatch", [calendar]);
+ this.mInBatch = false;
+ },
+ onLoad(calendar) {
+ this.mCalendar.observers.notify("onLoad", [calendar]);
+ },
+ onAddItem(aItem) {
+ this.mCalendar.observers.notify("onAddItem", [aItem]);
+ },
+ onModifyItem(aNewItem, aOldItem) {
+ this.mCalendar.observers.notify("onModifyItem", [aNewItem, aOldItem]);
+ },
+ onDeleteItem(aDeletedItem) {
+ this.mCalendar.observers.notify("onDeleteItem", [aDeletedItem]);
+ },
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.mCalendar.observers.notify("onPropertyChanged", [aCalendar, aName, aValue, aOldValue]);
+ },
+ onPropertyDeleting(aCalendar, aName) {
+ this.mCalendar.observers.notify("onPropertyDeleting", [aCalendar, aName]);
+ },
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.mCalendar.readOnly = true;
+ this.mCalendar.notifyError(aErrNo, aMessage);
+ },
+};
diff --git a/comm/calendar/providers/caldav/CalDavProvider.jsm b/comm/calendar/providers/caldav/CalDavProvider.jsm
new file mode 100644
index 0000000000..940e64337d
--- /dev/null
+++ b/comm/calendar/providers/caldav/CalDavProvider.jsm
@@ -0,0 +1,426 @@
+/* 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 EXPORTED_SYMBOLS = ["CalDavProvider"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { DNS } = ChromeUtils.import("resource:///modules/DNS.jsm");
+
+var { CalDavPropfindRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+var { CalDavDetectionSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider.caldav namespace.
+
+/**
+ * @implements {calICalendarProvider}
+ */
+var CalDavProvider = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]),
+
+ get type() {
+ return "caldav";
+ },
+
+ get displayName() {
+ return cal.l10n.getCalString("caldavName");
+ },
+
+ get shortName() {
+ return "CalDAV";
+ },
+
+ deleteCalendar(aCalendar, aListener) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ async detectCalendars(
+ username,
+ password,
+ location = null,
+ savePassword = false,
+ extraProperties = {}
+ ) {
+ let uri = cal.provider.detection.locationToUri(location);
+ if (!uri) {
+ throw new Error("Could not infer location from username");
+ }
+
+ let detector = new CalDavDetector(username, password, savePassword);
+
+ for (let method of [
+ "attemptGoogleOauth",
+ "attemptLocation",
+ "dnsSRV",
+ "wellKnown",
+ "attemptRoot",
+ ]) {
+ try {
+ cal.LOG(`[CalDavProvider] Trying to detect calendar using ${method} method`);
+ let calendars = await detector[method](uri);
+ if (calendars) {
+ return calendars;
+ }
+ } catch (e) {
+ // e may be an Error object or a response object like CalDavSimpleResponse.
+ // It can even be a string, as with the OAuth2 error below.
+ let message = `[CalDavProvider] Could not detect calendar using method ${method}`;
+
+ let errorDetails = err =>
+ ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`;
+
+ let responseDetails = response => ` - HTTP response status ${response.status}`;
+
+ // A special thing the OAuth2 code throws.
+ if (e == '{ "error": "cancelled"}') {
+ cal.WARN(message + ` - OAuth2 '${e}'`);
+ throw new cal.provider.detection.CanceledError("OAuth2 prompt canceled");
+ }
+
+ // We want to pass on any autodetect errors that will become results.
+ if (e instanceof cal.provider.detection.Error) {
+ cal.WARN(message + errorDetails(e));
+ throw e;
+ }
+
+ // Sometimes e is a CalDavResponseBase that is an auth error, so throw it.
+ if (e.authError) {
+ cal.WARN(message + responseDetails(e));
+ throw new cal.provider.detection.AuthFailedError();
+ }
+
+ if (e instanceof Error) {
+ cal.WARN(message + errorDetails(e));
+ } else if (typeof e.status == "number") {
+ cal.WARN(message + responseDetails(e));
+ } else {
+ cal.WARN(message);
+ }
+ }
+ }
+ return [];
+ },
+};
+
+/**
+ * Used by the CalDavProvider to detect CalDAV calendars for a given username,
+ * password, location, etc.
+ */
+class CalDavDetector {
+ /**
+ * Create a new caldav detector.
+ *
+ * @param {string} username - A username.
+ * @param {string} password - A password.
+ * @param {boolean} savePassword - Whether to save the password or not.
+ */
+ constructor(username, password, savePassword) {
+ this.username = username;
+ this.session = new CalDavDetectionSession(username, password, savePassword);
+ }
+
+ /**
+ * Attempt to detect calendars at the given location.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ attemptLocation(location) {
+ if (location.filePath == "/") {
+ // The location is the root, don't try to detect the collection, let the
+ // other handlers take care of it.
+ return Promise.resolve(null);
+ }
+ return this.detectCollection(location);
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using DNS lookups.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async dnsSRV(location) {
+ if (location.filePath != "/") {
+ // If there is already a path specified, then no need to use DNS lookups.
+ return null;
+ }
+
+ let dnshost = location.host;
+ let secure = location.schemeIs("http") ? "" : "s";
+ let dnsres = await DNS.srv(`_caldav${secure}._tcp.${dnshost}`);
+
+ if (!dnsres.length) {
+ let basedomain;
+ try {
+ basedomain = Services.eTLD.getBaseDomain(location);
+ } catch (e) {
+ // If we can't get a base domain just skip it.
+ }
+
+ if (basedomain && basedomain != location.host) {
+ cal.LOG(`[CalDavProvider] ${location.host} has no SRV entry, trying ${basedomain}`);
+ dnsres = await DNS.srv(`_caldav${secure}._tcp.${basedomain}`);
+ dnshost = basedomain;
+ }
+ }
+
+ if (!dnsres.length) {
+ return null;
+ }
+ dnsres.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
+
+ // Determine path from TXT, if available.
+ let pathres = await DNS.txt(`_caldav${secure}._tcp.${dnshost}`);
+ pathres.sort((a, b) => a.prio - b.prio || b.weight - a.weight);
+ pathres = pathres.filter(result => result.data.startsWith("path="));
+ // Get the string after `path=`.
+ let path = pathres.length ? pathres[0].data.substr(5) : "";
+
+ let calendars;
+ if (path) {
+ // If the server has SRV and TXT entries, we already have a full context path to test.
+ let uri = `http${secure}://${dnsres[0].host}:${dnsres[0].port}${path}`;
+ cal.LOG(`[CalDavProvider] Trying ${uri} from SRV and TXT response`);
+ calendars = await this.detectCollection(Services.io.newURI(uri));
+ }
+
+ if (!calendars) {
+ // Either the txt record doesn't point to a path (in which case we need to repeat with
+ // well-known), or no calendars could be detected at that location (in which case we
+ // need to repeat with well-known).
+
+ let baseloc = Services.io.newURI(
+ `http${secure}://${dnsres[0].host}:${dnsres[0].port}/.well-known/caldav`
+ );
+ cal.LOG(`[CalDavProvider] Trying ${baseloc.spec} from SRV response with .well-known`);
+
+ calendars = await this.detectCollection(baseloc);
+ }
+
+ return calendars;
+ }
+
+ /**
+ * Attempt to detect calendars using a `.well-known` URI.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async wellKnown(location) {
+ let wellKnownUri = Services.io.newURI("/.well-known/caldav", null, location);
+ cal.LOG(`[CalDavProvider] Trying .well-known URI without dns at ${wellKnownUri.spec}`);
+ return this.detectCollection(wellKnownUri);
+ }
+
+ /**
+ * Attempt to detect calendars using a root ("/") URI.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ attemptRoot(location) {
+ let rootUri = Services.io.newURI("/", null, location);
+ return this.detectCollection(rootUri);
+ }
+
+ /**
+ * Attempt to detect calendars using Google OAuth.
+ *
+ * @param {nsIURI} calURI - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async attemptGoogleOauth(calURI) {
+ let usesGoogleOAuth = cal.provider.detection.googleOAuthDomains.has(calURI.host);
+ if (!usesGoogleOAuth) {
+ // Not using Google OAuth that we know of, but we could check the mx entry.
+ // If mail is handled by Google then this is likely a Google Apps domain.
+ let mxRecords = await DNS.mx(calURI.host);
+ usesGoogleOAuth = mxRecords.some(r => /\bgoogle\.com$/.test(r.host));
+ }
+
+ if (usesGoogleOAuth) {
+ // If we were given a full URL to a calendar, try to use it.
+ let spec = this.username
+ ? `https://apidata.googleusercontent.com/caldav/v2/${encodeURIComponent(
+ this.username
+ )}/user`
+ : calURI.spec;
+ let uri = Services.io.newURI(spec);
+ return this.handlePrincipal(uri);
+ }
+ return null;
+ }
+
+ /**
+ * Utility function to detect whether a calendar collection exists at a given
+ * location and return it if it exists.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async detectCollection(location) {
+ let props = [
+ "D:resourcetype",
+ "D:owner",
+ "D:displayname",
+ "D:current-user-principal",
+ "D:current-user-privilege-set",
+ "A:calendar-color",
+ "C:calendar-home-set",
+ ];
+
+ cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`);
+ let request = new CalDavPropfindRequest(this.session, null, location, props);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ } else if (!response.ok) {
+ cal.LOG(`[CalDavProvider] ${target.spec} did not respond properly to PROPFIND`);
+ return null;
+ }
+
+ let resprops = response.firstProps;
+ let resourceType = resprops["D:resourcetype"];
+
+ if (resourceType.has("C:calendar")) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is a calendar`);
+ return [this.handleCalendar(target, resprops)];
+ } else if (resourceType.has("D:principal")) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is a principal, looking at home set`);
+ let homeSet = resprops["C:calendar-home-set"];
+ let homeSetUrl = Services.io.newURI(homeSet, null, target);
+ return this.handleHomeSet(homeSetUrl);
+ } else if (resprops["D:current-user-principal"]) {
+ cal.LOG(
+ `[CalDavProvider] ${target.spec} is something else, looking at current-user-principal`
+ );
+ let principalUrl = Services.io.newURI(resprops["D:current-user-principal"], null, target);
+ return this.handlePrincipal(principalUrl);
+ } else if (resprops["D:owner"]) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is something else, looking at collection owner`);
+ let principalUrl = Services.io.newURI(resprops["D:owner"], null, target);
+ return this.handlePrincipal(principalUrl);
+ }
+
+ return null;
+ }
+
+ /**
+ * Utility function to make a new attempt to detect calendars after the
+ * previous PROPFIND results contained either "D:current-user-principal"
+ * or "D:owner" props.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async handlePrincipal(location) {
+ let props = ["D:resourcetype", "C:calendar-home-set"];
+ let request = new CalDavPropfindRequest(this.session, null, location, props);
+ cal.LOG(`[CalDavProvider] Checking collection type at ${location.spec}`);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let homeSets = response.firstProps["C:calendar-home-set"];
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ } else if (!response.firstProps["D:resourcetype"].has("D:principal")) {
+ cal.LOG(`[CalDavProvider] ${target.spec} is not a principal collection`);
+ return null;
+ } else if (homeSets) {
+ let calendars = [];
+ for (let homeSet of homeSets) {
+ cal.LOG(`[CalDavProvider] ${target.spec} has a home set at ${homeSet}, checking that`);
+ let homeSetUrl = Services.io.newURI(homeSet, null, target);
+ let discoveredCalendars = await this.handleHomeSet(homeSetUrl);
+ if (discoveredCalendars) {
+ calendars.push(...discoveredCalendars);
+ }
+ }
+ return calendars.length ? calendars : null;
+ } else {
+ cal.LOG(`[CalDavProvider] ${target.spec} doesn't have a home set`);
+ return null;
+ }
+ }
+
+ /**
+ * Utility function to make a new attempt to detect calendars after the
+ * previous PROPFIND results contained a "C:calendar-home-set" prop.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async handleHomeSet(location) {
+ let props = [
+ "D:resourcetype",
+ "D:displayname",
+ "D:current-user-privilege-set",
+ "A:calendar-color",
+ ];
+ let request = new CalDavPropfindRequest(this.session, null, location, props, 1);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ }
+
+ let calendars = [];
+ for (let [href, resprops] of Object.entries(response.data)) {
+ if (resprops["D:resourcetype"].has("C:calendar")) {
+ let hrefUri = Services.io.newURI(href, null, target);
+ calendars.push(this.handleCalendar(hrefUri, resprops));
+ }
+ }
+ cal.LOG(`[CalDavProvider] ${target.spec} is a home set, found ${calendars.length} calendars`);
+
+ return calendars.length ? calendars : null;
+ }
+
+ /**
+ * Set up and return a new caldav calendar object.
+ *
+ * @param {nsIURI} uri - The location of the calendar.
+ * @param {Set} props - The calendar properties parsed from the
+ * response.
+ * @returns {calICalendar} A new calendar.
+ */
+ handleCalendar(uri, props) {
+ let displayName = props["D:displayname"];
+ let color = props["A:calendar-color"];
+ if (!displayName) {
+ let fileName = decodeURI(uri.spec).split("/").filter(Boolean).pop();
+ displayName = fileName || uri.spec;
+ }
+
+ // Some servers provide colors as an 8-character hex string. Strip the alpha component.
+ color = color?.replace(/^(#[0-9A-Fa-f]{6})[0-9A-Fa-f]{2}$/, "$1");
+
+ let calendar = cal.manager.createCalendar("caldav", uri);
+ calendar.setProperty("color", color || cal.view.hashColor(uri.spec));
+ calendar.name = displayName;
+ calendar.id = cal.getUUID();
+ calendar.setProperty("username", this.username);
+ calendar.wrappedJSObject.session = this.session.toBaseSession();
+
+ // Attempt to discover if the user is allowed to write to this calendar.
+ let privs = props["D:current-user-privilege-set"];
+ if (privs && privs instanceof Set) {
+ calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
+ priv => privs.has(priv)
+ );
+ }
+ return calendar;
+ }
+}
diff --git a/comm/calendar/providers/caldav/components.conf b/comm/calendar/providers/caldav/components.conf
new file mode 100644
index 0000000000..118aaa065c
--- /dev/null
+++ b/comm/calendar/providers/caldav/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/
+
+Classes = [
+ {
+ 'cid': '{a35fc6ea-3d92-11d9-89f9-00045ace3b8d}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=caldav'],
+ 'jsm': 'resource:///modules/CalDavCalendar.jsm',
+ 'constructor': 'CalDavCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/caldav/modules/CalDavRequest.jsm b/comm/calendar/providers/caldav/modules/CalDavRequest.jsm
new file mode 100644
index 0000000000..7778e42953
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavRequest.jsm
@@ -0,0 +1,1211 @@
+/* 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 { CalDavTagsToXmlns, CalDavNsUnresolver } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavUtils.jsm"
+);
+
+var { CalDavSession } = ChromeUtils.import("resource:///modules/caldav/CalDavSession.jsm");
+
+/* exported CalDavGenericRequest, CalDavLegacySAXRequest, CalDavItemRequest,
+ CalDavDeleteItemRequest, CalDavPropfindRequest, CalDavHeaderRequest,
+ CalDavPrincipalPropertySearchRequest, CalDavOutboxRequest, CalDavFreeBusyRequest */
+
+const EXPORTED_SYMBOLS = [
+ "CalDavGenericRequest",
+ "CalDavLegacySAXRequest",
+ "CalDavItemRequest",
+ "CalDavDeleteItemRequest",
+ "CalDavPropfindRequest",
+ "CalDavHeaderRequest",
+ "CalDavPrincipalPropertySearchRequest",
+ "CalDavOutboxRequest",
+ "CalDavFreeBusyRequest",
+];
+
+const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+const MIME_TEXT_CALENDAR = "text/calendar; charset=utf-8";
+const MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+/**
+ * Base class for a caldav request.
+ *
+ * @implements {nsIChannelEventSink}
+ * @implements {nsIInterfaceRequestor}
+ */
+class CalDavRequestBase {
+ QueryInterface = ChromeUtils.generateQI(["nsIChannelEventSink", "nsIInterfaceRequestor"]);
+
+ /**
+ * Creates a new base response, this should mainly be done using the subclass constructor
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {?calICalendar} aCalendar - The calendar this request belongs to (can be null)
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?string} aUploadData - The data to upload
+ * @param {?string} aContentType - The MIME content type for the upload data
+ * @param {?Function<nsIChannel>} aOnSetupChannel - The function to call to set up the channel
+ */
+ constructor(
+ aSession,
+ aCalendar,
+ aUri,
+ aUploadData = null,
+ aContentType = null,
+ aOnSetupChannel = null
+ ) {
+ if (typeof aUploadData == "function") {
+ aOnSetupChannel = aUploadData;
+ aUploadData = null;
+ aContentType = null;
+ }
+
+ this.session = aSession;
+ this.calendar = aCalendar;
+ this.uri = aUri;
+ this.uploadData = aUploadData;
+ this.contentType = aContentType;
+ this.onSetupChannel = aOnSetupChannel;
+ this.response = null;
+ this.reset();
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return CalDavSimpleResponse;
+ }
+
+ /**
+ * Resets the channel for this request
+ */
+ reset() {
+ this.channel = cal.provider.prepHttpChannel(
+ this.uri,
+ this.uploadData,
+ this.contentType,
+ this,
+ null,
+ this.session.isDetectionSession
+ );
+ }
+
+ /**
+ * Retrieves the given request header. Requires the request to be committed.
+ *
+ * @param {string} aHeader - The header to retrieve
+ * @returns {?string} The requested header, or null if unavailable
+ */
+ getHeader(aHeader) {
+ try {
+ return this.response.nsirequest.getRequestHeader(aHeader);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ /**
+ * Executes the request with the configuration set up in the constructor
+ *
+ * @returns {Promise} A promise that resolves with a subclass of CalDavResponseBase
+ * which is based on |responseClass|.
+ */
+ async commit() {
+ await this.session.prepareRequest(this.channel);
+
+ if (this.onSetupChannel) {
+ this.onSetupChannel(this.channel);
+ }
+
+ if (cal.verboseLogEnabled && this.uploadData) {
+ let method = this.channel.requestMethod;
+ cal.LOGverbose(`CalDAV: send (${method} ${this.uri.spec}): ${this.uploadData}`);
+ }
+
+ let ResponseClass = this.responseClass;
+ this.response = new ResponseClass(this);
+ this.response.lastRedirectStatus = null;
+ this.channel.asyncOpen(this.response.listener, this.channel);
+
+ await this.response.responded;
+
+ let action = await this.session.completeRequest(this.response);
+ if (action == CalDavSession.RESTART_REQUEST) {
+ this.reset();
+ return this.commit();
+ }
+
+ if (cal.verboseLogEnabled) {
+ let text = this.response.text;
+ if (text) {
+ cal.LOGverbose("CalDAV: recv: " + text);
+ }
+ }
+
+ return this.response;
+ }
+
+ /** Implement nsIInterfaceRequestor */
+ getInterface(aIID) {
+ /**
+ * Attempt to call nsIInterfaceRequestor::getInterface on the given object, and return null
+ * if it fails.
+ *
+ * @param {object} aObj - The object to call on.
+ * @returns {?*} The requested interface object, or null.
+ */
+ function tryGetInterface(aObj) {
+ try {
+ let requestor = aObj.QueryInterface(Ci.nsIInterfaceRequestor);
+ return requestor.getInterface(aIID);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ // Special case our nsIChannelEventSink, can't use tryGetInterface due to recursion errors
+ if (aIID.equals(Ci.nsIChannelEventSink)) {
+ return this.QueryInterface(Ci.nsIChannelEventSink);
+ }
+
+ // First check if the session has what we need. It may have an auth prompt implementation
+ // that should go first. Ideally we should move the auth prompt to the session anyway, but
+ // this is a task for another day (tm).
+ let iface = tryGetInterface(this.session) || tryGetInterface(this.calendar);
+ if (iface) {
+ return iface;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+
+ /** Implement nsIChannelEventSink */
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ /**
+ * Copy the given header from the old channel to the new one, ignoring missing headers
+ *
+ * @param {string} aHdr - The header to copy
+ */
+ function copyHeader(aHdr) {
+ try {
+ let hdrValue = aOldChannel.getRequestHeader(aHdr);
+ if (hdrValue) {
+ aNewChannel.setRequestHeader(aHdr, hdrValue, false);
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ // The header could possibly not be available, ignore that
+ // case but throw otherwise
+ throw e;
+ }
+ }
+ }
+
+ let uploadData, uploadContent;
+ let oldUploadChannel = cal.wrapInstance(aOldChannel, Ci.nsIUploadChannel);
+ let oldHttpChannel = cal.wrapInstance(aOldChannel, Ci.nsIHttpChannel);
+ if (oldUploadChannel && oldHttpChannel && oldUploadChannel.uploadStream) {
+ uploadData = oldUploadChannel.uploadStream;
+ uploadContent = oldHttpChannel.getRequestHeader("Content-Type");
+ }
+
+ cal.provider.prepHttpChannel(null, uploadData, uploadContent, this, aNewChannel);
+
+ // Make sure we can get/set headers on both channels.
+ aNewChannel.QueryInterface(Ci.nsIHttpChannel);
+ aOldChannel.QueryInterface(Ci.nsIHttpChannel);
+
+ try {
+ this.response.lastRedirectStatus = oldHttpChannel.responseStatus;
+ } catch (e) {
+ this.response.lastRedirectStatus = null;
+ }
+
+ // If any other header is used, it should be added here. We might want
+ // to just copy all headers over to the new channel.
+ copyHeader("Depth");
+ copyHeader("Originator");
+ copyHeader("Recipient");
+ copyHeader("If-None-Match");
+ copyHeader("If-Match");
+ copyHeader("Accept");
+
+ aNewChannel.requestMethod = oldHttpChannel.requestMethod;
+ this.session.prepareRedirect(aOldChannel, aNewChannel).then(() => {
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ });
+ }
+}
+
+/**
+ * The caldav response base class. Should be subclassed, and works with xpcom network code that uses
+ * nsIRequest.
+ */
+class CalDavResponseBase {
+ /**
+ * Constructs a new caldav response
+ *
+ * @param {CalDavRequestBase} aRequest - The request that initiated the response
+ */
+ constructor(aRequest) {
+ this.request = aRequest;
+
+ this.responded = new Promise((resolve, reject) => {
+ this._onresponded = resolve;
+ this._onrespondederror = reject;
+ });
+ this.completed = new Promise((resolve, reject) => {
+ this._oncompleted = resolve;
+ this._oncompletederror = reject;
+ });
+ }
+
+ /** The listener passed to the channel's asyncOpen */
+ get listener() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /** @returns {nsIURI} The request URI */
+ get uri() {
+ return this.nsirequest.URI;
+ }
+
+ /** @returns {boolean} True, if the request was redirected */
+ get redirected() {
+ return this.uri.spec != this.nsirequest.originalURI.spec;
+ }
+
+ /** @returns {number} The http response status of the request */
+ get status() {
+ try {
+ return this.nsirequest.responseStatus;
+ } catch (e) {
+ return -1;
+ }
+ }
+
+ /** The http status category, i.e. the first digit */
+ get statusCategory() {
+ return (this.status / 100) | 0;
+ }
+
+ /** If the response has a success code */
+ get ok() {
+ return this.statusCategory == 2;
+ }
+
+ /** If the response has a client error (4xx) */
+ get clientError() {
+ return this.statusCategory == 4;
+ }
+
+ /** If the response had an auth error */
+ get authError() {
+ // 403 is technically "Forbidden", but for our terms it is the same
+ return this.status == 401 || this.status == 403;
+ }
+
+ /** If the response has a conflict code */
+ get conflict() {
+ return this.status == 409 || this.status == 412;
+ }
+
+ /** If the response indicates the resource was not found */
+ get notFound() {
+ return this.status == 404;
+ }
+
+ /** If the response has a server error (5xx) */
+ get serverError() {
+ return this.statusCategory == 5;
+ }
+
+ /**
+ * Raise an exception if one of the handled 4xx and 5xx occurred.
+ */
+ raiseForStatus() {
+ if (this.authError) {
+ throw new HttpUnauthorizedError(this);
+ } else if (this.conflict) {
+ throw new HttpConflictError(this);
+ } else if (this.notFound) {
+ throw new HttpNotFoundError(this);
+ } else if (this.serverError) {
+ throw new HttpServerError(this);
+ }
+ }
+
+ /** The text response of the request */
+ get text() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /** @returns {DOMDocument} A DOM document with the response xml */
+ get xml() {
+ if (this.text && !this._responseXml) {
+ try {
+ this._responseXml = cal.xml.parseString(this.text);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ return this._responseXml;
+ }
+
+ /**
+ * Retrieve a request header
+ *
+ * @param {string} aHeader - The header to retrieve
+ * @returns {string} The header value
+ */
+ getHeader(aHeader) {
+ try {
+ return this.nsirequest.getResponseHeader(aHeader);
+ } catch (e) {
+ return null;
+ }
+ }
+}
+
+/**
+ * Thrown when the response had an authorization error (status 401 or 403).
+ */
+class HttpUnauthorizedError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpUnauthorizedError";
+ }
+}
+
+/**
+ * Thrown when the response has a conflict code (status 409 or 412).
+ */
+class HttpConflictError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpConflictError";
+ }
+}
+
+/**
+ * Thrown when the response indicates the resource was not found (status 404).
+ */
+class HttpNotFoundError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpNotFoundError";
+ }
+}
+
+/**
+ * Thrown when the response has a server error (status 5xx).
+ */
+class HttpServerError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "HttpServerError";
+ }
+}
+
+/**
+ * A simple caldav response using nsIStreamLoader
+ */
+class CalDavSimpleResponse extends CalDavResponseBase {
+ QueryInterface = ChromeUtils.generateQI(["nsIStreamLoaderObserver"]);
+
+ get listener() {
+ if (!this._listener) {
+ this._listener = cal.provider.createStreamLoader();
+ this._listener.init(this);
+ }
+ return this._listener;
+ }
+
+ get text() {
+ if (!this._responseText) {
+ this._responseText = new TextDecoder().decode(Uint8Array.from(this.result)) || "";
+ }
+ return this._responseText;
+ }
+
+ /** Implement nsIStreamLoaderObserver */
+ onStreamComplete(aLoader, aContext, aStatus, aResultLength, aResult) {
+ this.resultLength = aResultLength;
+ this.result = aResult;
+
+ this.nsirequest = aLoader.request.QueryInterface(Ci.nsIHttpChannel);
+
+ if (Components.isSuccessCode(aStatus)) {
+ this._onresponded(this);
+ } else {
+ // Check for bad server certificates on SSL/TLS connections.
+ // this.request is CalDavRequestBase instance and it contains calICalendar property
+ // which is needed for checkBadCertStatus. CalDavRequestBase.calendar can be null,
+ // this possibility is handled in BadCertHandler.
+ cal.provider.checkBadCertStatus(aLoader.request, aStatus, this.request.calendar);
+ this._onrespondederror(this);
+ }
+ }
+}
+
+/**
+ * A generic request method that uses the CalDavRequest/CalDavResponse infrastructure
+ */
+class CalDavGenericRequest extends CalDavRequestBase {
+ /**
+ * Constructs the generic caldav request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {string} aMethod - The HTTP method to use
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?object} aHeaders - An object with headers to set
+ * @param {?string} aUploadData - Optional data to upload
+ * @param {?string} aUploadType - Content type for upload data
+ */
+ constructor(
+ aSession,
+ aCalendar,
+ aMethod,
+ aUri,
+ aHeaders = {},
+ aUploadData = null,
+ aUploadType = null
+ ) {
+ super(aSession, aCalendar, aUri, aUploadData, aUploadType, channel => {
+ channel.requestMethod = aMethod;
+
+ for (let [name, value] of Object.entries(aHeaders)) {
+ channel.setRequestHeader(name, value, false);
+ }
+ });
+ }
+}
+
+/**
+ * Legacy request handlers request that uses an external request listener. Used for transitioning
+ * because once I started refactoring calDavRequestHandlers.js I was on the verge of refactoring the
+ * whole caldav provider. Too risky right now.
+ */
+class CalDavLegacySAXRequest extends CalDavRequestBase {
+ /**
+ * Constructs the legacy caldav request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?string} aUploadData - Optional data to upload
+ * @param {?string} aUploadType - Content type for upload data
+ * @param {?object} aHandler - The external request handler, e.g.
+ * CalDavEtagsHandler,
+ * CalDavMultigetSyncHandler,
+ * CalDavWebDavSyncHandler.
+ * @param {?Function<nsIChannel>} aOnSetupChannel - The function to call to set up the channel
+ */
+ constructor(
+ aSession,
+ aCalendar,
+ aUri,
+ aUploadData = null,
+ aUploadType = null,
+ aHandler = null,
+ aOnSetupChannel = null
+ ) {
+ super(aSession, aCalendar, aUri, aUploadData, aUploadType, aOnSetupChannel);
+ this._handler = aHandler;
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return LegacySAXResponse;
+ }
+}
+
+/**
+ * Response class for legacy requests. Contains a listener that proxies the
+ * external request handler object (e.g. CalDavMultigetSyncHandler,
+ * CalDavWebDavSyncHandler, CalDavEtagsHandler) in order to resolve or reject
+ * the promises for the response's "responded" and "completed" status.
+ */
+class LegacySAXResponse extends CalDavResponseBase {
+ /** @returns {nsIStreamListener} The listener passed to the channel's asyncOpen */
+ get listener() {
+ if (!this._listener) {
+ this._listener = {
+ QueryInterface: ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]),
+
+ onStartRequest: aRequest => {
+ try {
+ let result = this.request._handler.onStartRequest(aRequest);
+ this._onresponded();
+ return result;
+ } catch (e) {
+ this._onrespondederror(e);
+ return null;
+ }
+ },
+ onStopRequest: (aRequest, aStatusCode) => {
+ try {
+ let result = this.request._handler.onStopRequest(aRequest, aStatusCode);
+ this._onresponded();
+ return result;
+ } catch (e) {
+ this._onrespondederror(e);
+ return null;
+ }
+ },
+ onDataAvailable: this.request._handler.onDataAvailable.bind(this.request._handler),
+ };
+ }
+ return this._listener;
+ }
+
+ /** @returns {string} The text response of the request */
+ get text() {
+ return this.request._handler.logXML;
+ }
+}
+
+/**
+ * Upload an item to the caldav server
+ */
+class CalDavItemRequest extends CalDavRequestBase {
+ /**
+ * Constructs an item request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {calIItemBase} aItem - The item to send
+ * @param {?string} aEtag - The etag to check. The special value "*"
+ * sets the If-None-Match header, otherwise
+ * If-Match is set to the etag.
+ */
+ constructor(aSession, aCalendar, aUri, aItem, aEtag = null) {
+ aItem = fixGoogleDescription(aItem, aUri);
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem], 1);
+ let serializedItem = serializer.serializeToString();
+
+ super(aSession, aCalendar, aUri, serializedItem, MIME_TEXT_CALENDAR, channel => {
+ if (aEtag == "*") {
+ channel.setRequestHeader("If-None-Match", "*", false);
+ } else if (aEtag) {
+ channel.setRequestHeader("If-Match", aEtag, false);
+ }
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return ItemResponse;
+ }
+}
+
+/**
+ * The response for uploading an item to the server
+ */
+class ItemResponse extends CalDavSimpleResponse {
+ /** If the response has a success code */
+ get ok() {
+ // We should not accept a 201 status here indefinitely: it indicates a server error of some
+ // kind that we want to know about. It's convenient to accept it for now since a number of
+ // server impls don't get this right yet.
+ return this.status == 204 || this.status == 201 || this.status == 200;
+ }
+}
+
+/**
+ * A request for deleting an item from the server
+ */
+class CalDavDeleteItemRequest extends CalDavRequestBase {
+ /**
+ * Constructs an delete item request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {?string} aEtag - The etag to check, or null to
+ * unconditionally delete
+ */
+ constructor(aSession, aCalendar, aUri, aEtag = null) {
+ super(aSession, aCalendar, aUri, channel => {
+ if (aEtag) {
+ channel.setRequestHeader("If-Match", aEtag, false);
+ }
+ channel.requestMethod = "DELETE";
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return DeleteItemResponse;
+ }
+}
+
+/**
+ * The response class to deleting an item
+ */
+class DeleteItemResponse extends ItemResponse {
+ /** If the response has a success code */
+ get ok() {
+ // Accepting 404 as success because then the item is already deleted
+ return this.status == 204 || this.status == 200 || this.status == 404;
+ }
+}
+
+/**
+ * A dav PROPFIND request to retrieve specific properties of a dav resource.
+ */
+class CalDavPropfindRequest extends CalDavRequestBase {
+ /**
+ * Constructs a propfind request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string[]} aProps - The properties to request, including
+ * namespace prefix.
+ * @param {number} aDepth - The depth for the request, defaults to 0
+ */
+ constructor(aSession, aCalendar, aUri, aProps, aDepth = 0) {
+ let xml =
+ XML_HEADER +
+ `<D:propfind ${CalDavTagsToXmlns("D", ...aProps)}><D:prop>` +
+ aProps.map(prop => `<${prop}/>`).join("") +
+ "</D:prop></D:propfind>";
+
+ super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => {
+ channel.setRequestHeader("Depth", aDepth, false);
+ channel.requestMethod = "PROPFIND";
+ });
+
+ this.depth = aDepth;
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return PropfindResponse;
+ }
+}
+
+/**
+ * The response for a PROPFIND request
+ */
+class PropfindResponse extends CalDavSimpleResponse {
+ get decorators() {
+ /**
+ * Retrieves the trimmed text content of the node, or null if empty
+ *
+ * @param {Element} node - The node to get the text content of
+ * @returns {?string} The text content, or null if empty
+ */
+ function textContent(node) {
+ let text = node.textContent;
+ return text ? text.trim() : null;
+ }
+
+ /**
+ * Returns an array of string with each href value within the node scope
+ *
+ * @param {Element} parent - The node to get the href values in
+ * @returns {string[]} The array with trimmed text content values
+ */
+ function href(parent) {
+ return [...parent.querySelectorAll(":scope > href")].map(node => node.textContent.trim());
+ }
+
+ /**
+ * Returns the single href value within the node scope
+ *
+ * @param {Element} node - The node to get the href value in
+ * @returns {?string} The trimmed text content
+ */
+ function singleHref(node) {
+ let hrefval = node.querySelector(":scope > href");
+ return hrefval ? hrefval.textContent.trim() : null;
+ }
+
+ /**
+ * Returns a Set with the respective element local names in the path
+ *
+ * @param {string} path - The css path to search
+ * @param {Element} parent - The parent element to search in
+ * @returns {Set<string>} A set with the element names
+ */
+ function nodeNames(path, parent) {
+ return new Set(
+ [...parent.querySelectorAll(path)].map(node => {
+ let prefix = CalDavNsUnresolver(node.namespaceURI) || node.prefix;
+ return prefix + ":" + node.localName;
+ })
+ );
+ }
+
+ /**
+ * Returns a Set for the "current-user-privilege-set" properties. If a 404
+ * status is detected, null is returned indicating the server does not
+ * support this directive.
+ *
+ * @param {string} path - The css path to search
+ * @param {Element} parent - The parent element to search in
+ * @param {string} status - The status of the enclosing <propstat>
+ * @returns {Set<string>}
+ */
+ function privSet(path, parent, status = "") {
+ return status.includes("404") ? null : nodeNames(path, parent);
+ }
+
+ /**
+ * Returns a Set with the respective attribute values in the path
+ *
+ * @param {string} path - The css path to search
+ * @param {string} attribute - The attribute name to retrieve for each node
+ * @param {Element} parent - The parent element to search in
+ * @returns {Set<string>} A set with the attribute values
+ */
+ function attributeValue(path, attribute, parent) {
+ return new Set(
+ [...parent.querySelectorAll(path)].map(node => {
+ return node.getAttribute(attribute);
+ })
+ );
+ }
+
+ /**
+ * Return the result of either function a or function b, passing the node
+ *
+ * @param {Function} a - The first function to call
+ * @param {Function} b - The second function to call
+ * @param {Element} node - The node to call the functions with
+ * @returns {*} The return value of either a() or b()
+ */
+ function either(a, b, node) {
+ return a(node) || b(node);
+ }
+
+ return {
+ "D:principal-collection-set": href,
+ "C:calendar-home-set": href,
+ "C:calendar-user-address-set": href,
+ "D:current-user-principal": singleHref,
+ "D:current-user-privilege-set": privSet.bind(null, ":scope > privilege > *"),
+ "D:owner": singleHref,
+ "D:supported-report-set": nodeNames.bind(null, ":scope > supported-report > report > *"),
+ "D:resourcetype": nodeNames.bind(null, ":scope > *"),
+ "C:supported-calendar-component-set": attributeValue.bind(null, ":scope > comp", "name"),
+ "C:schedule-inbox-URL": either.bind(null, singleHref, textContent),
+ "C:schedule-outbox-URL": either.bind(null, singleHref, textContent),
+ };
+ }
+ /**
+ * Quick access to the properties of the PROPFIND request. Returns an object with the hrefs as
+ * keys, and an object with the normalized properties as the value.
+ *
+ * @returns {object} The object
+ */
+ get data() {
+ if (!this._data) {
+ this._data = {};
+ for (let response of this.xml.querySelectorAll(":scope > response")) {
+ let href = response.querySelector(":scope > href").textContent;
+ this._data[href] = {};
+
+ // This will throw 200's and 400's in one pot, but since 400's are empty that is ok
+ // for our needs.
+ for (let propStat of response.querySelectorAll(":scope > propstat")) {
+ let status = propStat.querySelector(":scope > status").textContent;
+ for (let prop of propStat.querySelectorAll(":scope > prop > *")) {
+ let prefix = CalDavNsUnresolver(prop.namespaceURI) || prop.prefix;
+ let qname = prefix + ":" + prop.localName;
+ if (qname in this.decorators) {
+ this._data[href][qname] = this.decorators[qname](prop, status) || null;
+ } else {
+ this._data[href][qname] = prop.textContent.trim() || null;
+ }
+ }
+ }
+ }
+ }
+ return this._data;
+ }
+
+ /**
+ * Shortcut for the properties of the first response, useful for depth=0
+ */
+ get firstProps() {
+ return Object.values(this.data)[0];
+ }
+
+ /** If the response has a success code */
+ get ok() {
+ return this.status == 207 && this.xml;
+ }
+}
+
+/**
+ * An OPTIONS request for retrieving the DAV header
+ */
+class CalDavHeaderRequest extends CalDavRequestBase {
+ /**
+ * Constructs the options request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ */
+ constructor(aSession, aCalendar, aUri) {
+ super(aSession, aCalendar, aUri, channel => {
+ channel.requestMethod = "OPTIONS";
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return DAVHeaderResponse;
+ }
+}
+
+/**
+ * The response class for the dav header request
+ */
+class DAVHeaderResponse extends CalDavSimpleResponse {
+ /**
+ * Returns a Set with the DAV features, not including the version
+ */
+ get features() {
+ if (!this._features) {
+ let dav = this.getHeader("dav") || "";
+ let features = dav.split(/,\s*/);
+ features.shift();
+ this._features = new Set(features);
+ }
+ return this._features;
+ }
+
+ /**
+ * The version from the DAV header
+ */
+ get version() {
+ let dav = this.getHeader("dav");
+ return parseInt(dav.substr(0, dav.indexOf(",")), 10);
+ }
+}
+
+/**
+ * Request class for principal-property-search queries
+ */
+class CalDavPrincipalPropertySearchRequest extends CalDavRequestBase {
+ /**
+ * Constructs a principal-property-search query.
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string} aMatch - The href to search in
+ * @param {string} aSearchProp - The property to search for
+ * @param {string[]} aProps - The properties to retrieve
+ * @param {number} aDepth - The depth of the query, defaults to 1
+ */
+ constructor(aSession, aCalendar, aUri, aMatch, aSearchProp, aProps, aDepth = 1) {
+ let xml =
+ XML_HEADER +
+ `<D:principal-property-search ${CalDavTagsToXmlns("D", aSearchProp, ...aProps)}>` +
+ "<D:property-search>" +
+ "<D:prop>" +
+ `<${aSearchProp}/>` +
+ "</D:prop>" +
+ `<D:match>${cal.xml.escapeString(aMatch)}</D:match>` +
+ "</D:property-search>" +
+ "<D:prop>" +
+ aProps.map(prop => `<${prop}/>`).join("") +
+ "</D:prop>" +
+ "</D:principal-property-search>";
+
+ super(aSession, aCalendar, aUri, xml, MIME_TEXT_XML, channel => {
+ channel.setRequestHeader("Depth", aDepth, false);
+ channel.requestMethod = "REPORT";
+ });
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return PropfindResponse;
+ }
+}
+
+/**
+ * Request class for calendar outbox queries, to send or respond to invitations
+ */
+class CalDavOutboxRequest extends CalDavRequestBase {
+ /**
+ * Constructs an outbox request
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string} aOrganizer - The organizer of the request
+ * @param {string} aRecipients - The recipients of the request
+ * @param {string} aResponseMethod - The itip response method, e.g. REQUEST,REPLY
+ * @param {calIItemBase} aItem - The item to send
+ */
+ constructor(aSession, aCalendar, aUri, aOrganizer, aRecipients, aResponseMethod, aItem) {
+ aItem = fixGoogleDescription(aItem, aUri);
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ serializer.addItems([aItem], 1);
+
+ let method = cal.icsService.createIcalProperty("METHOD");
+ method.value = aResponseMethod;
+ serializer.addProperty(method);
+
+ super(
+ aSession,
+ aCalendar,
+ aUri,
+ serializer.serializeToString(),
+ MIME_TEXT_CALENDAR,
+ channel => {
+ channel.requestMethod = "POST";
+ channel.setRequestHeader("Originator", aOrganizer, false);
+ for (let recipient of aRecipients) {
+ channel.setRequestHeader("Recipient", recipient, true);
+ }
+ }
+ );
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return OutboxResponse;
+ }
+}
+
+/**
+ * Response class for the caldav outbox request
+ */
+class OutboxResponse extends CalDavSimpleResponse {
+ /**
+ * An object with the recipients as keys, and the request status as values
+ */
+ get data() {
+ if (!this._data) {
+ this._data = {};
+ // TODO The following queries are currently untested code, as I don't have
+ // a caldav-sched server available. If you find someone who does, please test!
+ for (let response of this.xml.querySelectorAll(":scope > response")) {
+ let recipient = response.querySelector(":scope > recipient > href").textContent;
+ let status = response.querySelector(":scope > request-status").textContent;
+ this.data[recipient] = status;
+ }
+ }
+ return this._data;
+ }
+
+ /** If the response has a success code */
+ get ok() {
+ return this.status == 200 && this.xml;
+ }
+}
+
+/**
+ * Request class for freebusy queries
+ */
+class CalDavFreeBusyRequest extends CalDavRequestBase {
+ /**
+ * Creates a freebusy request, for the specified range
+ *
+ * @param {CalDavSession} aSession - The session to use for this request
+ * @param {calICalendar} aCalendar - The calendar this request belongs to
+ * @param {nsIURI} aUri - The uri to request
+ * @param {string} aOrganizer - The organizer of the request
+ * @param {string} aRecipient - The attendee to look up
+ * @param {calIDateTime} aRangeStart - The start of the range
+ * @param {calIDateTime} aRangeEnd - The end of the range
+ */
+ constructor(aSession, aCalendar, aUri, aOrganizer, aRecipient, aRangeStart, aRangeEnd) {
+ let vcalendar = cal.icsService.createIcalComponent("VCALENDAR");
+ cal.item.setStaticProps(vcalendar);
+
+ let method = cal.icsService.createIcalProperty("METHOD");
+ method.value = "REQUEST";
+ vcalendar.addProperty(method);
+
+ let freebusy = cal.icsService.createIcalComponent("VFREEBUSY");
+ freebusy.uid = cal.getUUID();
+ freebusy.stampTime = cal.dtz.now().getInTimezone(cal.dtz.UTC);
+ freebusy.startTime = aRangeStart.getInTimezone(cal.dtz.UTC);
+ freebusy.endTime = aRangeEnd.getInTimezone(cal.dtz.UTC);
+ vcalendar.addSubcomponent(freebusy);
+
+ let organizer = cal.icsService.createIcalProperty("ORGANIZER");
+ organizer.value = aOrganizer;
+ freebusy.addProperty(organizer);
+
+ let attendee = cal.icsService.createIcalProperty("ATTENDEE");
+ attendee.setParameter("PARTSTAT", "NEEDS-ACTION");
+ attendee.setParameter("ROLE", "REQ-PARTICIPANT");
+ attendee.setParameter("CUTYPE", "INDIVIDUAL");
+ attendee.value = aRecipient;
+ freebusy.addProperty(attendee);
+
+ super(aSession, aCalendar, aUri, vcalendar.serializeToICS(), MIME_TEXT_CALENDAR, channel => {
+ channel.requestMethod = "POST";
+ channel.setRequestHeader("Originator", aOrganizer, false);
+ channel.setRequestHeader("Recipient", aRecipient, false);
+ });
+
+ this._rangeStart = aRangeStart;
+ this._rangeEnd = aRangeEnd;
+ }
+
+ /**
+ * @returns {object} The class of the response for this request
+ */
+ get responseClass() {
+ return FreeBusyResponse;
+ }
+}
+
+/**
+ * Response class for the freebusy request
+ */
+class FreeBusyResponse extends CalDavSimpleResponse {
+ /**
+ * Quick access to the freebusy response data. An object is returned with the keys being
+ * recipients:
+ *
+ * {
+ * "mailto:user@example.com": {
+ * status: "HTTP/1.1 200 OK",
+ * intervals: [
+ * { type: "BUSY", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) },
+ * { type: "FREE", begin: ({calIDateTime}), end: ({calIDateTime or calIDuration}) }
+ * ]
+ * }
+ * }
+ */
+ get data() {
+ /**
+ * Helper to get the trimmed text content
+ *
+ * @param {Element} aParent - The parent node to search in
+ * @param {string} aPath - The css query path to serch
+ * @returns {string} The trimmed text content
+ */
+ function querySelectorText(aParent, aPath) {
+ let node = aParent.querySelector(aPath);
+ return node ? node.textContent.trim() : "";
+ }
+
+ if (!this._data) {
+ this._data = {};
+ for (let response of this.xml.querySelectorAll(":scope > response")) {
+ let recipient = querySelectorText(response, ":scope > recipient > href");
+ let status = querySelectorText(response, ":scope > request-status");
+ let caldata = querySelectorText(response, ":scope > calendar-data");
+ let intervals = [];
+ if (caldata) {
+ let component;
+ try {
+ component = cal.icsService.parseICS(caldata);
+ } catch (e) {
+ cal.LOG("CalDAV: Could not parse freebusy data: " + e);
+ continue;
+ }
+
+ for (let fbcomp of cal.iterate.icalComponent(component, "VFREEBUSY")) {
+ let fbstart = fbcomp.startTime;
+ if (fbstart && this.request._rangeStart.compare(fbstart) < 0) {
+ intervals.push({
+ type: "UNKNOWN",
+ begin: this.request._rangeStart,
+ end: fbstart,
+ });
+ }
+
+ for (let fbprop of cal.iterate.icalProperty(fbcomp, "FREEBUSY")) {
+ let type = fbprop.getParameter("FBTYPE");
+
+ let parts = fbprop.value.split("/");
+ let begin = cal.createDateTime(parts[0]);
+ let end;
+ if (parts[1].startsWith("P")) {
+ // this is a duration
+ end = begin.clone();
+ end.addDuration(cal.createDuration(parts[1]));
+ } else {
+ // This is a date string
+ end = cal.createDateTime(parts[1]);
+ }
+
+ intervals.push({ type, begin, end });
+ }
+
+ let fbend = fbcomp.endTime;
+ if (fbend && this.request._rangeEnd.compare(fbend) > 0) {
+ intervals.push({
+ type: "UNKNOWN",
+ begin: fbend,
+ end: this.request._rangeEnd,
+ });
+ }
+ }
+ }
+ this._data[recipient] = { status, intervals };
+ }
+ }
+ return this._data;
+ }
+
+ /**
+ * The data for the first recipient, useful if just one recipient was requested
+ */
+ get firstRecipient() {
+ return Object.values(this.data)[0];
+ }
+}
+
+/**
+ * Set item description to a format Google Calendar understands if the item
+ * will be uploaded to Google Calendar.
+ *
+ * @param {calIItemBase} aItem - The item we may want to modify.
+ * @param {nsIURI} aUri - The URI the item will be uploaded to.
+ * @returns {calItemBase} - A calendar item with appropriately-set description.
+ */
+function fixGoogleDescription(aItem, aUri) {
+ if (aUri.spec.startsWith("https://apidata.googleusercontent.com/caldav/")) {
+ // Google expects item descriptions to be bare HTML in violation of spec,
+ // rather than using the standard Alternate Text Representation.
+ aItem = aItem.clone();
+ aItem.descriptionText = aItem.descriptionHTML;
+
+ // Mark items we've modified for Google compatibility for informational
+ // purposes.
+ aItem.setProperty("X-MOZ-GOOGLE-HTML-DESCRIPTION", true);
+ }
+
+ return aItem;
+}
diff --git a/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm b/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm
new file mode 100644
index 0000000000..c5055d1a1f
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavRequestHandlers.jsm
@@ -0,0 +1,1091 @@
+/* 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 { CalDavLegacySAXRequest } = ChromeUtils.import("resource:///modules/caldav/CalDavRequest.jsm");
+
+/* exported CalDavEtagsHandler, CalDavWebDavSyncHandler, CalDavMultigetSyncHandler */
+
+const EXPORTED_SYMBOLS = [
+ "CalDavEtagsHandler",
+ "CalDavWebDavSyncHandler",
+ "CalDavMultigetSyncHandler",
+];
+
+const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n';
+const MIME_TEXT_XML = "text/xml; charset=utf-8";
+
+/**
+ * Accumulate all XML response, then parse with DOMParser. This class imitates
+ * nsISAXXMLReader by calling startDocument/endDocument and startElement/endElement.
+ */
+class XMLResponseHandler {
+ constructor() {
+ this._inStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ this._xmlString = "";
+ }
+
+ /**
+ * @see nsIStreamListener
+ */
+ onDataAvailable(request, inputStream, offset, count) {
+ this._inStream.init(inputStream);
+ // What we get from inputStream is BinaryString, decode it to UTF-8.
+ this._xmlString += new TextDecoder("UTF-8").decode(
+ this._binaryStringToTypedArray(this._inStream.read(count))
+ );
+ }
+
+ /**
+ * Log the response code and body.
+ *
+ * @param {number} responseStatus
+ */
+ logResponse(responseStatus) {
+ if (this.calendar.verboseLogging()) {
+ cal.LOG(`CalDAV: recv (${responseStatus}): ${this._xmlString}`);
+ }
+ }
+
+ /**
+ * Parse this._xmlString with DOMParser, then create a TreeWalker and start
+ * walking the node tree.
+ */
+ async handleResponse() {
+ let parser = new DOMParser();
+ let doc;
+ try {
+ doc = parser.parseFromString(this._xmlString, "application/xml");
+ } catch (e) {
+ cal.ERROR("CALDAV: DOMParser parse error: ", e);
+ this.fatalError();
+ }
+
+ let treeWalker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT);
+ this.startDocument();
+ await this._walk(treeWalker);
+ await this.endDocument();
+ }
+
+ /**
+ * Reset this._xmlString.
+ */
+ resetXMLResponseHandler() {
+ this._xmlString = "";
+ }
+
+ /**
+ * Converts a binary string into a Uint8Array.
+ *
+ * @param {BinaryString} str - The string to convert.
+ * @returns {Uint8Array}.
+ */
+ _binaryStringToTypedArray(str) {
+ let arr = new Uint8Array(str.length);
+ for (let i = 0; i < str.length; i++) {
+ arr[i] = str.charCodeAt(i);
+ }
+ return arr;
+ }
+
+ /**
+ * Walk the tree node by node, call startElement and endElement when appropriate.
+ */
+ async _walk(treeWalker) {
+ let currentNode = treeWalker.currentNode;
+ if (currentNode) {
+ this.startElement("", currentNode.localName, currentNode.nodeName, "");
+
+ // Traverse children first.
+ let firstChild = treeWalker.firstChild();
+ if (firstChild) {
+ await this._walk(treeWalker);
+ // TreeWalker has reached a leaf node, reset the cursor to continue the traversal.
+ treeWalker.currentNode = firstChild;
+ } else {
+ this.characters(currentNode.textContent);
+ await this.endElement("", currentNode.localName, currentNode.nodeName);
+ return;
+ }
+
+ // Traverse siblings next.
+ let nextSibling = treeWalker.nextSibling();
+ while (nextSibling) {
+ await this._walk(treeWalker);
+ // TreeWalker has reached a leaf node, reset the cursor to continue the traversal.
+ treeWalker.currentNode = nextSibling;
+ nextSibling = treeWalker.nextSibling();
+ }
+
+ await this.endElement("", currentNode.localName, currentNode.nodeName);
+ }
+ }
+}
+
+/**
+ * This is a handler for the etag request in calDavCalendar.js' getUpdatedItem.
+ * It uses XMLResponseHandler to parse the items and compose the resulting
+ * multiget.
+ */
+class CalDavEtagsHandler extends XMLResponseHandler {
+ /**
+ * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to.
+ * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection).
+ * @param {*=} aChangeLogListener - (optional) for cached calendars, the listener to notify.
+ */
+ constructor(aCalendar, aBaseUri, aChangeLogListener) {
+ super();
+ this.calendar = aCalendar;
+ this.baseUri = aBaseUri;
+ this.changeLogListener = aChangeLogListener;
+
+ this.itemsReported = {};
+ this.itemsNeedFetching = [];
+ }
+
+ skipIndex = -1;
+ currentResponse = null;
+ tag = null;
+ calendar = null;
+ baseUri = null;
+ changeLogListener = null;
+ logXML = "";
+
+ itemsReported = null;
+ itemsNeedFetching = null;
+
+ QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]);
+
+ /**
+ * @see nsIRequestObserver
+ */
+ onStartRequest(request) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status getting etags for calendar " + this.calendar.name);
+ }
+
+ if (responseStatus == 207) {
+ // We only need to parse 207's, anything else is probably a
+ // server error (i.e 50x).
+ httpchannel.contentType = "application/xml";
+ } else {
+ cal.LOG("CalDAV: Error fetching item etags");
+ this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR);
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ }
+ }
+
+ async onStopRequest(request, statusCode) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status getting etags for calendar " + this.calendar.name);
+ }
+
+ this.logResponse(responseStatus);
+
+ if (responseStatus != 207) {
+ // Not a successful response, do nothing.
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ await this.handleResponse();
+
+ // Now that we are done, check which items need fetching.
+ this.calendar.superCalendar.startBatch();
+
+ let needsRefresh = false;
+ try {
+ for (let path in this.calendar.mHrefIndex) {
+ if (path in this.itemsReported || path.substr(0, this.baseUri.length) == this.baseUri) {
+ // If the item is also on the server, check the next.
+ continue;
+ }
+ // If an item has been deleted from the server, delete it here too.
+ // Since the target calendar's operations are synchronous, we can
+ // safely set variables from this function.
+ let foundItem = await this.calendar.mOfflineStorage.getItem(this.calendar.mHrefIndex[path]);
+
+ if (foundItem) {
+ let wasInboxItem = this.calendar.mItemInfoCache[foundItem.id].isInboxItem;
+ if (
+ (wasInboxItem && this.calendar.isInbox(this.baseUri.spec)) ||
+ (wasInboxItem === false && !this.calendar.isInbox(this.baseUri.spec))
+ ) {
+ cal.LOG("Deleting local href: " + path);
+ delete this.calendar.mHrefIndex[path];
+ await this.calendar.mOfflineStorage.deleteItem(foundItem);
+ needsRefresh = true;
+ }
+ }
+ }
+ } finally {
+ this.calendar.superCalendar.endBatch();
+ }
+
+ // Avoid sending empty multiget requests update views if something has
+ // been deleted server-side.
+ if (this.itemsNeedFetching.length) {
+ let multiget = new CalDavMultigetSyncHandler(
+ this.itemsNeedFetching,
+ this.calendar,
+ this.baseUri,
+ null,
+ false,
+ null,
+ this.changeLogListener
+ );
+ multiget.doMultiGet();
+ } else {
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_OK }, Cr.NS_OK);
+ }
+
+ if (needsRefresh) {
+ this.calendar.mObservers.notify("onLoad", [this.calendar]);
+ }
+
+ // but do poll the inbox
+ if (this.calendar.mShouldPollInbox && !this.calendar.isInbox(this.baseUri.spec)) {
+ this.calendar.pollInbox();
+ }
+ }
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ fatalError() {
+ cal.WARN("CalDAV: Fatal Error parsing etags for " + this.calendar.name);
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ characters(aValue) {
+ if (this.calendar.verboseLogging()) {
+ this.logXML += aValue;
+ }
+ if (this.tag) {
+ this.currentResponse[this.tag] += aValue;
+ }
+ }
+
+ startDocument() {
+ this.hrefMap = {};
+ this.currentResponse = {};
+ this.tag = null;
+ }
+
+ endDocument() {}
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ switch (aLocalName) {
+ case "response":
+ this.currentResponse = {};
+ this.currentResponse.isCollection = false;
+ this.tag = null;
+ break;
+ case "collection":
+ this.currentResponse.isCollection = true;
+ // falls through
+ case "href":
+ case "getetag":
+ case "getcontenttype":
+ this.tag = aLocalName;
+ this.currentResponse[aLocalName] = "";
+ break;
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "<" + aQName + ">";
+ }
+ }
+
+ endElement(aUri, aLocalName, aQName) {
+ switch (aLocalName) {
+ case "response": {
+ this.tag = null;
+ let resp = this.currentResponse;
+ if (
+ resp.getetag &&
+ resp.getetag.length &&
+ resp.href &&
+ resp.href.length &&
+ resp.getcontenttype &&
+ resp.getcontenttype.length &&
+ !resp.isCollection
+ ) {
+ resp.href = this.calendar.ensureDecodedPath(resp.href);
+
+ if (resp.getcontenttype.substr(0, 14) == "message/rfc822") {
+ // workaround for a Scalix bug which causes incorrect
+ // contenttype to be returned.
+ resp.getcontenttype = "text/calendar";
+ }
+ if (resp.getcontenttype == "text/vtodo") {
+ // workaround Kerio weirdness
+ resp.getcontenttype = "text/calendar";
+ }
+
+ // Only handle calendar items
+ if (resp.getcontenttype.substr(0, 13) == "text/calendar") {
+ if (resp.href && resp.href.length) {
+ this.itemsReported[resp.href] = resp.getetag;
+
+ let itemUid = this.calendar.mHrefIndex[resp.href];
+ if (!itemUid || resp.getetag != this.calendar.mItemInfoCache[itemUid].etag) {
+ this.itemsNeedFetching.push(resp.href);
+ }
+ }
+ }
+ }
+ break;
+ }
+ case "href":
+ case "getetag":
+ case "getcontenttype": {
+ this.tag = null;
+ break;
+ }
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "</" + aQName + ">";
+ }
+ }
+
+ processingInstruction(aTarget, aData) {}
+}
+
+/**
+ * This is a handler for the webdav sync request in calDavCalendar.js'
+ * getUpdatedItem. It uses XMLResponseHandler to parse the items and compose the
+ * resulting multiget.
+ */
+class CalDavWebDavSyncHandler extends XMLResponseHandler {
+ /**
+ * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to.
+ * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection).
+ * @param {*=} aChangeLogListener - (optional) for cached calendars, the listener to notify.
+ */
+ constructor(aCalendar, aBaseUri, aChangeLogListener) {
+ super();
+ this.calendar = aCalendar;
+ this.baseUri = aBaseUri;
+ this.changeLogListener = aChangeLogListener;
+
+ this.itemsReported = {};
+ this.itemsNeedFetching = [];
+ }
+
+ currentResponse = null;
+ tag = null;
+ calendar = null;
+ baseUri = null;
+ newSyncToken = null;
+ changeLogListener = null;
+ logXML = "";
+ isInPropStat = false;
+ changeCount = 0;
+ unhandledErrors = 0;
+ itemsReported = null;
+ itemsNeedFetching = null;
+ additionalSyncNeeded = false;
+
+ QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]);
+
+ async doWebDAVSync() {
+ if (this.calendar.mDisabledByDavError) {
+ // check if maybe our calendar has become available
+ this.calendar.checkDavResourceType(this.changeLogListener);
+ return;
+ }
+
+ let syncTokenString = "<sync-token/>";
+ if (this.calendar.mWebdavSyncToken && this.calendar.mWebdavSyncToken.length > 0) {
+ let syncToken = cal.xml.escapeString(this.calendar.mWebdavSyncToken);
+ syncTokenString = "<sync-token>" + syncToken + "</sync-token>";
+ }
+
+ let queryXml =
+ XML_HEADER +
+ '<sync-collection xmlns="DAV:">' +
+ syncTokenString +
+ "<sync-level>1</sync-level>" +
+ "<prop>" +
+ "<getcontenttype/>" +
+ "<getetag/>" +
+ "</prop>" +
+ "</sync-collection>";
+
+ let requestUri = this.calendar.makeUri(null, this.baseUri);
+
+ if (this.calendar.verboseLogging()) {
+ cal.LOG(`CalDAV: send (REPORT ${requestUri.spec}): ${queryXml}`);
+ }
+ cal.LOG("CalDAV: webdav-sync Token: " + this.calendar.mWebdavSyncToken);
+
+ let onSetupChannel = channel => {
+ // The depth header adheres to an older version of the webdav-sync
+ // spec and has been replaced by the <sync-level> tag above.
+ // Unfortunately some servers still depend on the depth header,
+ // therefore we send both (yuck).
+ channel.setRequestHeader("Depth", "1", false);
+ channel.requestMethod = "REPORT";
+ };
+ let request = new CalDavLegacySAXRequest(
+ this.calendar.session,
+ this.calendar,
+ requestUri,
+ queryXml,
+ MIME_TEXT_XML,
+ this,
+ onSetupChannel
+ );
+
+ await request.commit().catch(() => {
+ // Something went wrong with the OAuth token, notify failure
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult(
+ { status: Cr.NS_ERROR_NOT_AVAILABLE },
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ });
+ }
+
+ /**
+ * @see nsIRequestObserver
+ */
+ onStartRequest(request) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing webdav sync for calendar " + this.calendar.name);
+ }
+
+ if (responseStatus == 207) {
+ // We only need to parse 207's, anything else is probably a
+ // server error (i.e 50x).
+ httpchannel.contentType = "application/xml";
+ }
+ }
+
+ async onStopRequest(request, statusCode) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing webdav sync for calendar " + this.calendar.name);
+ }
+
+ this.logResponse(responseStatus);
+
+ if (responseStatus == 207) {
+ await this.handleResponse();
+ } else if (
+ (responseStatus == 403 && this._xmlString.includes(`<D:error xmlns:D="DAV:"/>`)) ||
+ responseStatus == 429
+ ) {
+ // We're hitting the rate limit. Don't attempt to refresh now.
+ cal.WARN("CalDAV: rate limit reached, server returned status code: " + responseStatus);
+ if (this.calendar.isCached && this.changeLogListener) {
+ // Not really okay, but we have to return something and an error code puts us in a bad state.
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ } else if (
+ this.calendar.mWebdavSyncToken != null &&
+ responseStatus >= 400 &&
+ responseStatus <= 499
+ ) {
+ // Invalidate sync token with 4xx errors that could indicate the
+ // sync token has become invalid and do a refresh.
+ cal.LOG(
+ "CalDAV: Resetting sync token because server returned status code: " + responseStatus
+ );
+ this.calendar.mWebdavSyncToken = null;
+ this.calendar.saveCalendarProperties();
+ this.calendar.safeRefresh(this.changeLogListener);
+ } else {
+ cal.WARN("CalDAV: Error doing webdav sync: " + responseStatus);
+ this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR);
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ }
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ fatalError() {
+ cal.WARN("CalDAV: Fatal Error doing webdav sync for " + this.calendar.name);
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ characters(aValue) {
+ if (this.calendar.verboseLogging()) {
+ this.logXML += aValue;
+ }
+ this.currentResponse[this.tag] += aValue;
+ }
+
+ startDocument() {
+ this.hrefMap = {};
+ this.currentResponse = {};
+ this.tag = null;
+ this.calendar.superCalendar.startBatch();
+ }
+
+ async endDocument() {
+ if (this.unhandledErrors) {
+ this.calendar.superCalendar.endBatch();
+ this.calendar.reportDavError(Ci.calIErrors.DAV_REPORT_ERROR);
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ if (this.calendar.mWebdavSyncToken == null && !this.additionalSyncNeeded) {
+ // null token means reset or first refresh indicating we did
+ // a full sync; remove local items that were not returned in this full
+ // sync
+ for (let path in this.calendar.mHrefIndex) {
+ if (!this.itemsReported[path]) {
+ await this.calendar.deleteTargetCalendarItem(path);
+ }
+ }
+ }
+ this.calendar.superCalendar.endBatch();
+
+ if (this.itemsNeedFetching.length) {
+ let multiget = new CalDavMultigetSyncHandler(
+ this.itemsNeedFetching,
+ this.calendar,
+ this.baseUri,
+ this.newSyncToken,
+ this.additionalSyncNeeded,
+ null,
+ this.changeLogListener
+ );
+ multiget.doMultiGet();
+ } else {
+ if (this.newSyncToken) {
+ this.calendar.mWebdavSyncToken = this.newSyncToken;
+ this.calendar.saveCalendarProperties();
+ cal.LOG("CalDAV: New webdav-sync Token: " + this.calendar.mWebdavSyncToken);
+
+ if (this.additionalSyncNeeded) {
+ let wds = new CalDavWebDavSyncHandler(
+ this.calendar,
+ this.baseUri,
+ this.changeLogListener
+ );
+ wds.doWebDAVSync();
+ return;
+ }
+ }
+ this.calendar.finalizeUpdatedItems(this.changeLogListener, this.baseUri);
+ }
+ }
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ switch (aLocalName) {
+ case "response": // WebDAV Sync draft 3
+ this.currentResponse = {};
+ this.tag = null;
+ this.isInPropStat = false;
+ break;
+ case "propstat":
+ this.isInPropStat = true;
+ break;
+ case "status":
+ if (this.isInPropStat) {
+ this.tag = "propstat_" + aLocalName;
+ } else {
+ this.tag = aLocalName;
+ }
+ this.currentResponse[this.tag] = "";
+ break;
+ case "href":
+ case "getetag":
+ case "getcontenttype":
+ case "sync-token":
+ this.tag = aLocalName.replace(/-/g, "");
+ this.currentResponse[this.tag] = "";
+ break;
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "<" + aQName + ">";
+ }
+ }
+
+ async endElement(aUri, aLocalName, aQName) {
+ switch (aLocalName) {
+ case "response": // WebDAV Sync draft 3
+ case "sync-response": {
+ // WebDAV Sync draft 0,1,2
+ let resp = this.currentResponse;
+ if (resp.href && resp.href.length) {
+ resp.href = this.calendar.ensureDecodedPath(resp.href);
+ }
+
+ if (
+ (!resp.getcontenttype || resp.getcontenttype == "text/plain") &&
+ resp.href &&
+ resp.href.endsWith(".ics")
+ ) {
+ // If there is no content-type (iCloud) or text/plain was passed
+ // (iCal Server) for the resource but its name ends with ".ics"
+ // assume the content type to be text/calendar. Apple
+ // iCloud/iCal Server interoperability fix.
+ resp.getcontenttype = "text/calendar";
+ }
+
+ // Deleted item
+ if (
+ resp.href &&
+ resp.href.length &&
+ resp.status &&
+ resp.status.length &&
+ resp.status.indexOf(" 404") > 0
+ ) {
+ if (this.calendar.mHrefIndex[resp.href]) {
+ this.changeCount++;
+ await this.calendar.deleteTargetCalendarItem(resp.href);
+ } else {
+ cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href);
+ }
+ // Only handle Created or Updated calendar items
+ } else if (
+ resp.getcontenttype &&
+ resp.getcontenttype.substr(0, 13) == "text/calendar" &&
+ resp.getetag &&
+ resp.getetag.length &&
+ resp.href &&
+ resp.href.length &&
+ (!resp.status || // Draft 3 does not require
+ resp.status.length == 0 || // a status for created or updated items but
+ resp.status.indexOf(" 204") || // draft 0, 1 and 2 needed it so treat no status
+ resp.status.indexOf(" 200") || // Apple iCloud returns 200 status for each item
+ resp.status.indexOf(" 201"))
+ ) {
+ // and status 201 and 204 the same
+ this.itemsReported[resp.href] = resp.getetag;
+ let itemId = this.calendar.mHrefIndex[resp.href];
+ let oldEtag = itemId && this.calendar.mItemInfoCache[itemId].etag;
+
+ if (!oldEtag || oldEtag != resp.getetag) {
+ // Etag mismatch, getting new/updated item.
+ this.itemsNeedFetching.push(resp.href);
+ }
+ } else if (resp.status && resp.status.includes(" 507")) {
+ // webdav-sync says that if a 507 is encountered and the
+ // url matches the request, the current token should be
+ // saved and another request should be made. We don't
+ // actually compare the URL, its too easy to get this
+ // wrong.
+
+ // The 507 doesn't mean the data received is invalid, so
+ // continue processing.
+ this.additionalSyncNeeded = true;
+ } else if (
+ resp.status &&
+ resp.status.indexOf(" 200") &&
+ resp.href &&
+ resp.href.endsWith("/")
+ ) {
+ // iCloud returns status responses for directories too
+ // so we just ignore them if they have status code 200. We
+ // want to make sure these are not counted as unhandled
+ // errors in the next block
+ } else if (
+ (resp.getcontenttype && resp.getcontenttype.startsWith("text/calendar")) ||
+ (resp.status && !resp.status.includes(" 404"))
+ ) {
+ // If the response element is still not handled, log an
+ // error only if the content-type is text/calendar or the
+ // response status is different than 404 not found. We
+ // don't care about response elements on non-calendar
+ // resources or whose status is not indicating a deleted
+ // resource.
+ cal.WARN("CalDAV: Unexpected response, status: " + resp.status + ", href: " + resp.href);
+ this.unhandledErrors++;
+ } else {
+ cal.LOG(
+ "CalDAV: Unhandled response element, status: " +
+ resp.status +
+ ", href: " +
+ resp.href +
+ " contenttype:" +
+ resp.getcontenttype
+ );
+ }
+ break;
+ }
+ case "sync-token": {
+ this.newSyncToken = this.currentResponse[this.tag];
+ break;
+ }
+ case "propstat": {
+ this.isInPropStat = false;
+ break;
+ }
+ }
+ this.tag = null;
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "</" + aQName + ">";
+ }
+ }
+
+ processingInstruction(aTarget, aData) {}
+}
+
+/**
+ * This is a handler for the multiget request. It uses XMLResponseHandler to
+ * parse the items and compose the resulting multiget.
+ */
+class CalDavMultigetSyncHandler extends XMLResponseHandler {
+ /**
+ * @param {string[]} aItemsNeedFetching - Array of items to fetch, an array of
+ * un-encoded paths.
+ * @param {calDavCalendar} aCalendar - The (unwrapped) calendar this request belongs to.
+ * @param {nsIURI} aBaseUri - The URI requested (i.e inbox or collection).
+ * @param {*=} aNewSyncToken - (optional) New Sync token to set if operation successful.
+ * @param {boolean=} aAdditionalSyncNeeded - (optional) If true, the passed sync token is not the
+ * latest, another webdav sync run should be
+ * done after completion.
+ * @param {*=} aListener - (optional) The listener to notify.
+ * @param {*=} aChangeLogListener - (optional) For cached calendars, the listener to
+ * notify.
+ */
+ constructor(
+ aItemsNeedFetching,
+ aCalendar,
+ aBaseUri,
+ aNewSyncToken,
+ aAdditionalSyncNeeded,
+ aListener,
+ aChangeLogListener
+ ) {
+ super();
+ this.calendar = aCalendar;
+ this.baseUri = aBaseUri;
+ this.listener = aListener;
+ this.newSyncToken = aNewSyncToken;
+ this.changeLogListener = aChangeLogListener;
+ this.itemsNeedFetching = aItemsNeedFetching;
+ this.additionalSyncNeeded = aAdditionalSyncNeeded;
+ }
+
+ currentResponse = null;
+ tag = null;
+ calendar = null;
+ baseUri = null;
+ newSyncToken = null;
+ listener = null;
+ changeLogListener = null;
+ logXML = null;
+ unhandledErrors = 0;
+ itemsNeedFetching = null;
+ additionalSyncNeeded = false;
+ timer = null;
+
+ QueryInterface = ChromeUtils.generateQI(["nsIRequestObserver", "nsIStreamListener"]);
+
+ doMultiGet() {
+ if (this.calendar.mDisabledByDavError) {
+ // check if maybe our calendar has become available
+ this.calendar.checkDavResourceType(this.changeLogListener);
+ return;
+ }
+
+ let batchSize = Services.prefs.getIntPref("calendar.caldav.multigetBatchSize", 100);
+ let hrefString = "";
+ while (this.itemsNeedFetching.length && batchSize > 0) {
+ batchSize--;
+ // ensureEncodedPath extracts only the path component of the item and
+ // encodes it before it is sent to the server
+ let locpath = this.calendar.ensureEncodedPath(this.itemsNeedFetching.pop());
+ hrefString += "<D:href>" + cal.xml.escapeString(locpath) + "</D:href>";
+ }
+
+ let queryXml =
+ XML_HEADER +
+ '<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' +
+ "<D:prop>" +
+ "<D:getetag/>" +
+ "<C:calendar-data/>" +
+ "</D:prop>" +
+ hrefString +
+ "</C:calendar-multiget>";
+
+ let requestUri = this.calendar.makeUri(null, this.baseUri);
+ if (this.calendar.verboseLogging()) {
+ cal.LOG(`CalDAV: send (REPORT ${requestUri.spec}): ${queryXml}`);
+ }
+
+ let onSetupChannel = channel => {
+ channel.requestMethod = "REPORT";
+ channel.setRequestHeader("Depth", "1", false);
+ };
+ let request = new CalDavLegacySAXRequest(
+ this.calendar.session,
+ this.calendar,
+ requestUri,
+ queryXml,
+ MIME_TEXT_XML,
+ this,
+ onSetupChannel
+ );
+
+ request.commit().catch(() => {
+ // Something went wrong with the OAuth token, notify failure
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult(
+ { status: Cr.NS_ERROR_NOT_AVAILABLE },
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+ });
+ }
+
+ /**
+ * @see nsIRequestObserver
+ */
+ onStartRequest(request) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing multiget for calendar " + this.calendar.name);
+ }
+
+ if (responseStatus == 207) {
+ // We only need to parse 207's, anything else is probably a
+ // server error (i.e 50x).
+ httpchannel.contentType = "application/xml";
+ } else {
+ let errorMsg =
+ "CalDAV: Error: got status " +
+ responseStatus +
+ " fetching calendar data for " +
+ this.calendar.name +
+ ", " +
+ this.listener;
+ this.calendar.notifyGetFailed(errorMsg, this.listener, this.changeLogListener);
+ }
+ }
+
+ async onStopRequest(request, statusCode) {
+ let httpchannel = request.QueryInterface(Ci.nsIHttpChannel);
+
+ let responseStatus;
+ try {
+ responseStatus = httpchannel.responseStatus;
+ } catch (ex) {
+ cal.WARN("CalDAV: No response status doing multiget for calendar " + this.calendar.name);
+ }
+
+ this.logResponse(responseStatus);
+
+ if (responseStatus != 207) {
+ // Not a successful response, do nothing.
+ if (this.calendar.isCached && this.changeLogListener) {
+ this.changeLogListener.onResult({ status: Cr.NS_ERROR_FAILURE }, Cr.NS_ERROR_FAILURE);
+ }
+ return;
+ }
+
+ if (this.unhandledErrors) {
+ this.calendar.superCalendar.endBatch();
+ this.calendar.notifyGetFailed("multiget error", this.listener, this.changeLogListener);
+ return;
+ }
+ if (this.itemsNeedFetching.length == 0) {
+ if (this.newSyncToken) {
+ this.calendar.mWebdavSyncToken = this.newSyncToken;
+ this.calendar.saveCalendarProperties();
+ cal.LOG("CalDAV: New webdav-sync Token: " + this.calendar.mWebdavSyncToken);
+ }
+ }
+ await this.handleResponse();
+ if (this.itemsNeedFetching.length > 0) {
+ cal.LOG("CalDAV: Still need to fetch " + this.itemsNeedFetching.length + " elements.");
+ this.resetXMLResponseHandler();
+ let timerCallback = {
+ requestHandler: this,
+ notify(timer) {
+ // Call multiget again to get another batch
+ this.requestHandler.doMultiGet();
+ },
+ };
+ this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this.timer.initWithCallback(timerCallback, 0, Ci.nsITimer.TYPE_ONE_SHOT);
+ } else if (this.additionalSyncNeeded) {
+ let wds = new CalDavWebDavSyncHandler(this.calendar, this.baseUri, this.changeLogListener);
+ wds.doWebDAVSync();
+ } else {
+ this.calendar.finalizeUpdatedItems(this.changeLogListener, this.baseUri);
+ }
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ fatalError(error) {
+ cal.WARN("CalDAV: Fatal Error doing multiget for " + this.calendar.name + ": " + error);
+ }
+
+ /**
+ * @see XMLResponseHandler
+ */
+ characters(aValue) {
+ if (this.calendar.verboseLogging()) {
+ this.logXML += aValue;
+ }
+ if (this.tag) {
+ this.currentResponse[this.tag] += aValue;
+ }
+ }
+
+ startDocument() {
+ this.hrefMap = {};
+ this.currentResponse = {};
+ this.tag = null;
+ this.logXML = "";
+ this.calendar.superCalendar.startBatch();
+ }
+
+ endDocument() {
+ this.calendar.superCalendar.endBatch();
+ }
+
+ startElement(aUri, aLocalName, aQName, aAttributes) {
+ switch (aLocalName) {
+ case "response":
+ this.currentResponse = {};
+ this.tag = null;
+ this.isInPropStat = false;
+ break;
+ case "propstat":
+ this.isInPropStat = true;
+ break;
+ case "status":
+ if (this.isInPropStat) {
+ this.tag = "propstat_" + aLocalName;
+ } else {
+ this.tag = aLocalName;
+ }
+ this.currentResponse[this.tag] = "";
+ break;
+ case "calendar-data":
+ case "href":
+ case "getetag":
+ this.tag = aLocalName.replace(/-/g, "");
+ this.currentResponse[this.tag] = "";
+ break;
+ }
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "<" + aQName + ">";
+ }
+ }
+
+ async endElement(aUri, aLocalName, aQName) {
+ switch (aLocalName) {
+ case "response": {
+ let resp = this.currentResponse;
+ if (resp.href && resp.href.length) {
+ resp.href = this.calendar.ensureDecodedPath(resp.href);
+ }
+ if (
+ resp.href &&
+ resp.href.length &&
+ resp.status &&
+ resp.status.length &&
+ resp.status.indexOf(" 404") > 0
+ ) {
+ if (this.calendar.mHrefIndex[resp.href]) {
+ await this.calendar.deleteTargetCalendarItem(resp.href);
+ } else {
+ cal.LOG("CalDAV: skipping unfound deleted item : " + resp.href);
+ }
+ // Created or Updated item
+ } else if (
+ resp.getetag &&
+ resp.getetag.length &&
+ resp.href &&
+ resp.href.length &&
+ resp.calendardata &&
+ resp.calendardata.length
+ ) {
+ let oldEtag;
+ let itemId = this.calendar.mHrefIndex[resp.href];
+ if (itemId) {
+ oldEtag = this.calendar.mItemInfoCache[itemId].etag;
+ } else {
+ oldEtag = null;
+ }
+ if (!oldEtag || oldEtag != resp.getetag || this.listener) {
+ await this.calendar.addTargetCalendarItem(
+ resp.href,
+ resp.calendardata,
+ this.baseUri,
+ resp.getetag,
+ this.listener
+ );
+ } else {
+ cal.LOG("CalDAV: skipping item with unmodified etag : " + oldEtag);
+ }
+ } else {
+ cal.WARN(
+ "CalDAV: Unexpected response, status: " +
+ resp.status +
+ ", href: " +
+ resp.href +
+ " calendar-data:\n" +
+ resp.calendardata
+ );
+ this.unhandledErrors++;
+ }
+ break;
+ }
+ case "propstat": {
+ this.isInPropStat = false;
+ break;
+ }
+ }
+ this.tag = null;
+ if (this.calendar.verboseLogging()) {
+ this.logXML += "</" + aQName + ">";
+ }
+ }
+
+ processingInstruction(aTarget, aData) {}
+}
diff --git a/comm/calendar/providers/caldav/modules/CalDavSession.jsm b/comm/calendar/providers/caldav/modules/CalDavSession.jsm
new file mode 100644
index 0000000000..c94bfdaff7
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavSession.jsm
@@ -0,0 +1,573 @@
+/* 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 { OAuth2 } = ChromeUtils.import("resource:///modules/OAuth2.jsm");
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(lazy, "OAuth2Providers", "resource:///modules/OAuth2Providers.jsm");
+
+/**
+ * Session and authentication tools for the caldav provider
+ */
+
+const EXPORTED_SYMBOLS = ["CalDavDetectionSession", "CalDavSession"];
+/* exported CalDavDetectionSession, CalDavSession */
+
+const OAUTH_GRACE_TIME = 30 * 1000;
+
+class CalDavOAuth extends OAuth2 {
+ /**
+ * Returns true if the token has expired, or will expire within the grace time.
+ */
+ get tokenExpired() {
+ let now = new Date().getTime();
+ return this.tokenExpires - OAUTH_GRACE_TIME < now;
+ }
+
+ /**
+ * Retrieves the refresh token from the password manager. The token is cached.
+ */
+ get refreshToken() {
+ cal.ASSERT(this.id, `This ${this.constructor.name} object has no id.`);
+ if (!this._refreshToken) {
+ let pass = { value: null };
+ try {
+ cal.auth.passwordManagerGet(this.id, pass, this.origin, this.pwMgrId);
+ } catch (e) {
+ // User might have cancelled the primary password prompt, that's ok
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ }
+ this._refreshToken = pass.value;
+ }
+ return this._refreshToken;
+ }
+
+ /**
+ * Saves the refresh token in the password manager
+ *
+ * @param {string} aVal - The value to set
+ */
+ set refreshToken(aVal) {
+ try {
+ if (aVal) {
+ cal.auth.passwordManagerSave(this.id, aVal, this.origin, this.pwMgrId);
+ } else {
+ cal.auth.passwordManagerRemove(this.id, this.origin, this.pwMgrId);
+ }
+ } catch (e) {
+ // User might have cancelled the primary password prompt, that's ok
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ throw e;
+ }
+ }
+ this._refreshToken = aVal;
+ }
+
+ /**
+ * Wait for the calendar window to appear.
+ *
+ * This is a workaround for bug 901329: If the calendar window isn't loaded yet the master
+ * password prompt will show just the buttons and possibly hang. If we postpone until the window
+ * is loaded, all is well.
+ *
+ * @returns {Promise} A promise resolved without value when the window is loaded
+ */
+ waitForCalendarWindow() {
+ return new Promise(resolve => {
+ // eslint-disable-next-line func-names, require-jsdoc
+ function postpone() {
+ let win = cal.window.getCalendarWindow();
+ if (!win || win.document.readyState != "complete") {
+ setTimeout(postpone, 0);
+ } else {
+ resolve();
+ }
+ }
+ setTimeout(postpone, 0);
+ });
+ }
+
+ /**
+ * Promisified version of |connect|, using all means necessary to gracefully display the
+ * authentication prompt.
+ *
+ * @param {boolean} aWithUI - If UI should be shown for authentication
+ * @param {boolean} aRefresh - Force refresh the token TODO default false
+ * @returns {Promise} A promise resolved when the OAuth process is completed
+ */
+ promiseConnect(aWithUI = true, aRefresh = true) {
+ return this.waitForCalendarWindow().then(() => {
+ return new Promise((resolve, reject) => {
+ let self = this;
+ let asyncprompter = Cc["@mozilla.org/messenger/msgAsyncPrompter;1"].getService(
+ Ci.nsIMsgAsyncPrompter
+ );
+ asyncprompter.queueAsyncAuthPrompt(this.id, false, {
+ onPromptStartAsync(callback) {
+ this.onPromptAuthAvailable(callback);
+ },
+
+ onPromptAuthAvailable(callback) {
+ self.connect(
+ () => {
+ if (callback) {
+ callback.onAuthResult(true);
+ }
+ resolve();
+ },
+ () => {
+ if (callback) {
+ callback.onAuthResult(false);
+ }
+ reject();
+ },
+ aWithUI,
+ aRefresh
+ );
+ },
+ onPromptCanceled: reject,
+ onPromptStart() {},
+ });
+ });
+ });
+ }
+
+ /**
+ * Prepare the given channel for an OAuth request
+ *
+ * @param {nsIChannel} aChannel - The channel to prepare
+ */
+ async prepareRequest(aChannel) {
+ if (!this.accessToken || this.tokenExpired) {
+ // The token has expired, we need to reauthenticate first
+ cal.LOG("CalDAV: OAuth token expired or empty, refreshing");
+ await this.promiseConnect();
+ }
+
+ let hdr = "Bearer " + this.accessToken;
+ aChannel.setRequestHeader("Authorization", hdr, false);
+ }
+
+ /**
+ * Prepare the redirect, copying the auth header to the new channel
+ *
+ * @param {nsIChannel} aOldChannel - The old channel that is being redirected
+ * @param {nsIChannel} aNewChannel - The new channel to prepare
+ */
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ try {
+ let hdrValue = aOldChannel.getRequestHeader("Authorization");
+ if (hdrValue) {
+ aNewChannel.setRequestHeader("Authorization", hdrValue, false);
+ }
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_NOT_AVAILABLE) {
+ // The header could possibly not be available, ignore that
+ // case but throw otherwise
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Check for OAuth auth errors and restart the request without a token if necessary
+ *
+ * @param {CalDavResponseBase} aResponse - The response to inspect for completion
+ * @returns {Promise} A promise resolved when complete, with
+ * CalDavSession.RESTART_REQUEST or null
+ */
+ async completeRequest(aResponse) {
+ // Check for OAuth errors
+ let wwwauth = aResponse.getHeader("WWW-Authenticate");
+ if (this.oauth && wwwauth && wwwauth.startsWith("Bearer") && wwwauth.includes("error=")) {
+ this.oauth.accessToken = null;
+
+ return CalDavSession.RESTART_REQUEST;
+ }
+ return null;
+ }
+}
+
+/**
+ * Authentication provider for Google's OAuth.
+ */
+class CalDavGoogleOAuth extends CalDavOAuth {
+ /**
+ * Constructs a new Google OAuth authentication provider
+ *
+ * @param {string} sessionId - The session id, used in the password manager
+ * @param {string} name - The user-readable description of this session
+ */
+ constructor(sessionId, name) {
+ /* eslint-disable no-undef */
+ super("https://www.googleapis.com/auth/calendar", {
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/auth",
+ tokenEndpoint: "https://www.googleapis.com/oauth2/v3/token",
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_HASH,
+ });
+ /* eslint-enable no-undef */
+
+ this.id = sessionId;
+ this.origin = "oauth:" + sessionId;
+ this.pwMgrId = "Google CalDAV v2";
+
+ this._maybeUpgrade(name);
+
+ this.requestWindowTitle = cal.l10n.getAnyString(
+ "global",
+ "commonDialogs",
+ "EnterUserPasswordFor2",
+ [name]
+ );
+ this.extraAuthParams = [["login_hint", name]];
+ }
+
+ /**
+ * If no token is found for "Google CalDAV v2", this is either a new session (in which case
+ * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+ * Detect those situations and switch credentials if necessary.
+ */
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("accounts.google.com");
+ this.clientId = issuerDetails.clientId;
+ this.consumerSecret = issuerDetails.clientSecret;
+
+ this.origin = "oauth://accounts.google.com";
+ this.pwMgrId = "https://www.googleapis.com/auth/calendar";
+ }
+ }
+}
+
+/**
+ * Authentication provider for Fastmail's OAuth.
+ */
+class CalDavFastmailOAuth extends CalDavOAuth {
+ /**
+ * Constructs a new Fastmail OAuth authentication provider
+ *
+ * @param {string} sessionId - The session id, used in the password manager
+ * @param {string} name - The user-readable description of this session
+ */
+ constructor(sessionId, name) {
+ /* eslint-disable no-undef */
+ super("https://www.fastmail.com/dev/protocol-caldav", {
+ authorizationEndpoint: "https://api.fastmail.com/oauth/authorize",
+ tokenEndpoint: "https://api.fastmail.com/oauth/refresh",
+ clientId: OAUTH_CLIENT_ID,
+ clientSecret: OAUTH_HASH,
+ usePKCE: true,
+ });
+ /* eslint-enable no-undef */
+
+ this.id = sessionId;
+ this.origin = "oauth:" + sessionId;
+ this.pwMgrId = "Fastmail CalDAV";
+
+ this._maybeUpgrade(name);
+
+ this.requestWindowTitle = cal.l10n.getAnyString(
+ "global",
+ "commonDialogs",
+ "EnterUserPasswordFor2",
+ [name]
+ );
+ this.extraAuthParams = [["login_hint", name]];
+ }
+
+ /**
+ * If no token is found for "Fastmail CalDAV", this is either a new session (in which case
+ * it should use Thunderbird's credentials) or it's already using Thunderbird's credentials.
+ * Detect those situations and switch credentials if necessary.
+ */
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("www.fastmail.com");
+ this.clientId = issuerDetails.clientId;
+
+ this.origin = "oauth://www.fastmail.com";
+ this.pwMgrId = "https://www.fastmail.com/dev/protocol-caldav";
+ }
+ }
+}
+
+/**
+ * A modified version of CalDavGoogleOAuth for testing. This class mimics the
+ * real class as closely as possible.
+ */
+class CalDavTestOAuth extends CalDavGoogleOAuth {
+ constructor(sessionId, name) {
+ super(sessionId, name);
+
+ // Override these values with test values.
+ this.authorizationEndpoint =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/redirect_auto.sjs";
+ this.tokenEndpoint =
+ "http://mochi.test:8888/browser/comm/mail/components/addrbook/test/browser/data/token.sjs";
+ this.scope = "test_scope";
+ this.clientId = "test_client_id";
+ this.consumerSecret = "test_scope";
+
+ // I don't know why, but tests refuse to work with a plain HTTP endpoint
+ // (the request is redirected to HTTPS, which we're not listening to).
+ // Just use an HTTPS endpoint.
+ this.redirectionEndpoint = "https://localhost";
+ }
+
+ _maybeUpgrade() {
+ if (!this.refreshToken) {
+ const issuerDetails = lazy.OAuth2Providers.getIssuerDetails("mochi.test");
+ this.clientId = issuerDetails.clientId;
+ this.consumerSecret = issuerDetails.clientSecret;
+
+ this.origin = "oauth://mochi.test";
+ this.pwMgrId = "test_scope";
+ }
+ }
+}
+
+/**
+ * A session for the caldav provider. Two or more calendars can share a session if they have the
+ * same auth credentials.
+ */
+class CalDavSession {
+ QueryInterface = ChromeUtils.generateQI(["nsIInterfaceRequestor"]);
+
+ /**
+ * Dictionary of hostname => auth adapter. Before a request is made to a hostname
+ * in the dictionary, the auth adapter will be called to modify the request.
+ */
+ authAdapters = {};
+
+ /**
+ * Constant returned by |completeRequest| when the request should be restarted
+ *
+ * @returns {number} The constant
+ */
+ static get RESTART_REQUEST() {
+ return 1;
+ }
+
+ /**
+ * Creates a new caldav session
+ *
+ * @param {string} aSessionId - The session id, used in the password manager
+ * @param {string} aName - The user-readable description of this session
+ */
+ constructor(aSessionId, aName) {
+ this.id = aSessionId;
+ this.name = aName;
+
+ // Only create an auth adapter if we're going to use it.
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "apidata.googleusercontent.com",
+ () => new CalDavGoogleOAuth(aSessionId, aName)
+ );
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "caldav.fastmail.com",
+ () => new CalDavFastmailOAuth(aSessionId, aName)
+ );
+ XPCOMUtils.defineLazyGetter(
+ this.authAdapters,
+ "mochi.test",
+ () => new CalDavTestOAuth(aSessionId, aName)
+ );
+ }
+
+ /**
+ * Implement nsIInterfaceRequestor. The base class has no extra interfaces, but a subclass of
+ * the session may.
+ *
+ * @param {nsIIDRef} aIID - The IID of the interface being requested
+ * @returns {?*} Either this object QI'd to the IID, or null.
+ * Components.returnCode is set accordingly.
+ */
+ getInterface(aIID) {
+ try {
+ // Try to query the this object for the requested interface but don't
+ // throw if it fails since that borks the network code.
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ Components.returnCode = e;
+ }
+
+ return null;
+ }
+
+ /**
+ * Calls the auth adapter for the given host in case it exists. This allows delegating auth
+ * preparation based on the host, e.g. for OAuth.
+ *
+ * @param {string} aHost - The host to check the auth adapter for
+ * @param {string} aMethod - The method to call
+ * @param {...*} aArgs - Remaining args specific to the adapted method
+ * @returns {*} Return value specific to the adapter method
+ */
+ async _callAdapter(aHost, aMethod, ...aArgs) {
+ let adapter = this.authAdapters[aHost] || null;
+ if (adapter) {
+ return adapter[aMethod](...aArgs);
+ }
+ return null;
+ }
+
+ /**
+ * Prepare the channel for a request, e.g. setting custom authentication headers
+ *
+ * @param {nsIChannel} aChannel - The channel to prepare
+ * @returns {Promise} A promise resolved when the preparations are complete
+ */
+ async prepareRequest(aChannel) {
+ return this._callAdapter(aChannel.URI.host, "prepareRequest", aChannel);
+ }
+
+ /**
+ * Prepare the given new channel for a redirect, e.g. copying headers.
+ *
+ * @param {nsIChannel} aOldChannel - The old channel that is being redirected
+ * @param {nsIChannel} aNewChannel - The new channel to prepare
+ * @returns {Promise} A promise resolved when the preparations are complete
+ */
+ async prepareRedirect(aOldChannel, aNewChannel) {
+ return this._callAdapter(aNewChannel.URI.host, "prepareRedirect", aOldChannel, aNewChannel);
+ }
+
+ /**
+ * Complete the request based on the results from the response. Allows restarting the session if
+ * |CalDavSession.RESTART_REQUEST| is returned.
+ *
+ * @param {CalDavResponseBase} aResponse - The response to inspect for completion
+ * @returns {Promise} A promise resolved when complete, with
+ * CalDavSession.RESTART_REQUEST or null
+ */
+ async completeRequest(aResponse) {
+ return this._callAdapter(aResponse.request.uri.host, "completeRequest", aResponse);
+ }
+}
+
+/**
+ * A session used to detect a caldav provider when subscribing to a network calendar.
+ *
+ * @implements {nsIAuthPrompt2}
+ * @implements {nsIAuthPromptProvider}
+ * @implements {nsIInterfaceRequestor}
+ */
+class CalDavDetectionSession extends CalDavSession {
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIAuthPrompt2,
+ Ci.nsIAuthPromptProvider,
+ Ci.nsIInterfaceRequestor,
+ ]);
+
+ isDetectionSession = true;
+
+ /**
+ * Create a new caldav detection session.
+ *
+ * @param {string} aUserName - The username for the session.
+ * @param {string} aPassword - The password for the session.
+ * @param {boolean} aSavePassword - Whether to save the password.
+ */
+ constructor(aUserName, aPassword, aSavePassword) {
+ super(aUserName, aUserName);
+ this.password = aPassword;
+ this.savePassword = aSavePassword;
+ }
+
+ /**
+ * Returns a plain (non-autodect) caldav session based on this session.
+ *
+ * @returns {CalDavSession} A caldav session.
+ */
+ toBaseSession() {
+ return new CalDavSession(this.id, this.name);
+ }
+
+ /**
+ * @see {nsIAuthPromptProvider}
+ */
+ getAuthPrompt(aReason, aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ setTimeout(() => {
+ if (this.promptAuth(aChannel, aLevel, aAuthInfo)) {
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ } else {
+ aCallback.onAuthCancelled(aContext, true);
+ }
+ });
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ if (!this.password) {
+ return false;
+ }
+
+ if ((aAuthInfo.flags & aAuthInfo.PREVIOUS_FAILED) == 0) {
+ aAuthInfo.username = this.name;
+ aAuthInfo.password = this.password;
+
+ if (this.savePassword) {
+ cal.auth.passwordManagerSave(
+ this.name,
+ this.password,
+ aChannel.URI.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return true;
+ }
+
+ aAuthInfo.username = null;
+ aAuthInfo.password = null;
+ if (this.savePassword) {
+ cal.auth.passwordManagerRemove(this.name, aChannel.URI.prePath, aAuthInfo.realm);
+ }
+ return false;
+ }
+}
+
+// Before you spend time trying to find out what this means, please note that
+// doing so and using the information WILL cause Google to revoke Lightning's
+// privileges, which means not one Lightning user will be able to connect to
+// Google Calendar via CalDAV. This will cause unhappy users all around which
+// means that the Lightning developers will have to spend more time with user
+// support, which means less time for features, releases and bugfixes. For a
+// paid developer this would actually mean financial harm.
+//
+// Do you really want all of this to be your fault? Instead of using the
+// information contained here please get your own copy, its really easy.
+/* eslint-disable */
+// prettier-ignore
+(zqdx=>{zqdx["\x65\x76\x61\x6C"](zqdx["\x41\x72\x72\x61\x79"]["\x70\x72\x6F\x74"+
+"\x6F\x74\x79\x70\x65"]["\x6D\x61\x70"]["\x63\x61\x6C\x6C"]("uijt/PBVUI`CBTF`VS"+
+"J>#iuuqt;00bddpvout/hpphmf/dpn0p0#<uijt/PBVUI`TDPQF>#iuuqt;00xxx/hpphmfbqjt/dp"+
+"n0bvui0dbmfoebs#<uijt/PBVUI`DMJFOU`JE>#831674:95649/bqqt/hpphmfvtfsdpoufou/dpn"+
+"#<uijt/PBVUI`IBTI>#zVs7YVgyvsbguj7s8{1TTfJR#<",_=>zqdx["\x53\x74\x72\x69\x6E"+
+"\x67"]["\x66\x72\x6F\x6D\x43\x68\x61\x72\x43\x6F\x64\x65"](_["\x63\x68\x61\x72"+
+"\x43\x6F\x64\x65\x41\x74"](0)-1),this)[""+"\x6A\x6F\x69\x6E"](""))})["\x63\x61"+
+"\x6C\x6C"]((this),Components["\x75\x74\x69\x6c\x73"]["\x67\x65\x74\x47\x6c\x6f"+
+"\x62\x61\x6c\x46\x6f\x72\x4f\x62\x6a\x65\x63\x74"](this))
+/* eslint-enable */
diff --git a/comm/calendar/providers/caldav/modules/CalDavUtils.jsm b/comm/calendar/providers/caldav/modules/CalDavUtils.jsm
new file mode 100644
index 0000000000..63b50b7fb3
--- /dev/null
+++ b/comm/calendar/providers/caldav/modules/CalDavUtils.jsm
@@ -0,0 +1,110 @@
+/* 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");
+
+/**
+ * Various utility functions for the caldav provider
+ */
+
+/* exported CalDavXmlns, CalDavTagsToXmlns, CalDavNsUnresolver, CalDavNsResolver, CalDavXPath,
+ * CalDavXPathFirst */
+const EXPORTED_SYMBOLS = [
+ "CalDavXmlns",
+ "CalDavTagsToXmlns",
+ "CalDavNsUnresolver",
+ "CalDavNsResolver",
+ "CalDavXPath",
+ "CalDavXPathFirst",
+];
+
+/**
+ * Creates an xmlns string with the requested namespace prefixes
+ *
+ * @param {...string} aRequested - The requested namespace prefixes
+ * @returns {string} An xmlns string that can be inserted into xml documents
+ */
+function CalDavXmlns(...aRequested) {
+ let namespaces = [];
+ for (let namespace of aRequested) {
+ let nsUri = CalDavNsResolver(namespace);
+ if (namespace) {
+ namespaces.push(`xmlns:${namespace}='${nsUri}'`);
+ }
+ }
+
+ return namespaces.join(" ");
+}
+
+/**
+ * Helper function to gather namespaces from QNames or namespace prefixes, plus a few extra for the
+ * remaining request.
+ *
+ * @param {...string} aTags - Either QNames, or just namespace prefixes to be resolved.
+ * @returns {string} The complete namespace string
+ */
+function CalDavTagsToXmlns(...aTags) {
+ let namespaces = new Set(aTags.map(tag => tag.split(":")[0]));
+ return CalDavXmlns(...namespaces.values());
+}
+
+/**
+ * Resolve the namespace URI to one of the prefixes used in our codebase
+ *
+ * @param {string} aNamespace - The namespace URI to resolve
+ * @returns {?string} The namespace prefix we use
+ */
+function CalDavNsUnresolver(aNamespace) {
+ const prefixes = {
+ "http://apple.com/ns/ical/": "A",
+ "DAV:": "D",
+ "urn:ietf:params:xml:ns:caldav": "C",
+ "http://calendarserver.org/ns/": "CS",
+ };
+ return prefixes[aNamespace] || null;
+}
+
+/**
+ * Resolve the namespace URI from one of the prefixes used in our codebase
+ *
+ * @param {string} aPrefix - The namespace prefix we use
+ * @returns {?string} The namespace URI for the prefix
+ */
+function CalDavNsResolver(aPrefix) {
+ /* eslint-disable id-length */
+ const namespaces = {
+ A: "http://apple.com/ns/ical/",
+ D: "DAV:",
+ C: "urn:ietf:params:xml:ns:caldav",
+ CS: "http://calendarserver.org/ns/",
+ };
+ /* eslint-enable id-length */
+
+ return namespaces[aPrefix] || null;
+}
+
+/**
+ * Run an xpath expression on the given node, using the caldav namespace resolver
+ *
+ * @param {Element} aNode - The context node to search from
+ * @param {string} aExpr - The XPath expression to search for
+ * @param {?XPathResult} aType - (optional) Force a result type, must be an XPathResult constant
+ * @returns {Element[]} Array of found elements
+ */
+function CalDavXPath(aNode, aExpr, aType) {
+ return cal.xml.evalXPath(aNode, aExpr, CalDavNsResolver, aType);
+}
+
+/**
+ * Run an xpath expression on the given node, using the caldav namespace resolver. Returns the first
+ * result.
+ *
+ * @param {Element} aNode - The context node to search from
+ * @param {string} aExpr - The XPath expression to search for
+ * @param {?XPathResult} aType - (optional) Force a result type, must be an XPathResult constant
+ * @returns {?Element} The found element, or null.
+ */
+function CalDavXPathFirst(aNode, aExpr, aType) {
+ return cal.xml.evalXPathFirst(aNode, aExpr, CalDavNsResolver, aType);
+}
diff --git a/comm/calendar/providers/caldav/moz.build b/comm/calendar/providers/caldav/moz.build
new file mode 100644
index 0000000000..eecaa153ab
--- /dev/null
+++ b/comm/calendar/providers/caldav/moz.build
@@ -0,0 +1,25 @@
+# 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/.
+
+DIRS += ["public"]
+
+EXTRA_JS_MODULES += [
+ "CalDavCalendar.jsm",
+ "CalDavProvider.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+EXTRA_JS_MODULES.caldav += [
+ "modules/CalDavRequest.jsm",
+ "modules/CalDavRequestHandlers.jsm",
+ "modules/CalDavSession.jsm",
+ "modules/CalDavUtils.jsm",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: CalDAV")
diff --git a/comm/calendar/providers/caldav/public/calICalDavCalendar.idl b/comm/calendar/providers/caldav/public/calICalDavCalendar.idl
new file mode 100644
index 0000000000..c9533470df
--- /dev/null
+++ b/comm/calendar/providers/caldav/public/calICalDavCalendar.idl
@@ -0,0 +1,20 @@
+/* -*- Mode: C++; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */
+/* 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/. */
+
+#include "calICalendar.idl"
+#include "calIOperation.idl"
+
+
+/** Adds CalDAV specific capabilities to calICalendar.
+ */
+[scriptable, uuid(88F6FB22-C172-11DC-A8D1-00197EA74E11)]
+interface calICalDavCalendar : calICalendar
+{
+ /**
+ * The calendar's RFC 2617 authentication realm
+ */
+ readonly attribute AUTF8String authRealm;
+
+};
diff --git a/comm/calendar/providers/caldav/public/moz.build b/comm/calendar/providers/caldav/public/moz.build
new file mode 100644
index 0000000000..e8c0600501
--- /dev/null
+++ b/comm/calendar/providers/caldav/public/moz.build
@@ -0,0 +1,10 @@
+# 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/.
+
+XPIDL_SOURCES += [
+ "calICalDavCalendar.idl",
+]
+
+XPIDL_MODULE = "caldav"
diff --git a/comm/calendar/providers/composite/CalCompositeCalendar.jsm b/comm/calendar/providers/composite/CalCompositeCalendar.jsm
new file mode 100644
index 0000000000..65c1e3e2f2
--- /dev/null
+++ b/comm/calendar/providers/composite/CalCompositeCalendar.jsm
@@ -0,0 +1,426 @@
+/* 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 EXPORTED_SYMBOLS = ["CalCompositeCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+/**
+ * Calendar specific utility functions
+ */
+
+function calCompositeCalendarObserverHelper(compCalendar) {
+ this.compCalendar = compCalendar;
+}
+
+calCompositeCalendarObserverHelper.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["calIObserver"]),
+
+ onStartBatch(calendar) {
+ this.compCalendar.mObservers.notify("onStartBatch", [calendar]);
+ },
+
+ onEndBatch(calendar) {
+ this.compCalendar.mObservers.notify("onEndBatch", [calendar]);
+ },
+
+ onLoad(calendar) {
+ this.compCalendar.mObservers.notify("onLoad", [calendar]);
+ },
+
+ onAddItem(aItem) {
+ this.compCalendar.mObservers.notify("onAddItem", arguments);
+ },
+
+ onModifyItem(aNewItem, aOldItem) {
+ this.compCalendar.mObservers.notify("onModifyItem", arguments);
+ },
+
+ onDeleteItem(aDeletedItem) {
+ this.compCalendar.mObservers.notify("onDeleteItem", arguments);
+ },
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.compCalendar.mObservers.notify("onError", arguments);
+ },
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.compCalendar.mObservers.notify("onPropertyChanged", arguments);
+ },
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.compCalendar.mObservers.notify("onPropertyDeleting", arguments);
+ },
+};
+
+function CalCompositeCalendar() {
+ this.mObserverHelper = new calCompositeCalendarObserverHelper(this);
+ this.wrappedJSObject = this;
+
+ this.mCalendars = [];
+ this.mCompositeObservers = new cal.data.ObserverSet(Ci.calICompositeObserver);
+ this.mObservers = new cal.data.ObserverSet(Ci.calIObserver);
+ this.mDefaultCalendar = null;
+ this.mStatusObserver = null;
+}
+
+var calCompositeCalendarClassID = Components.ID("{aeff788d-63b0-4996-91fb-40a7654c6224}");
+var calCompositeCalendarInterfaces = ["calICalendar", "calICompositeCalendar"];
+CalCompositeCalendar.prototype = {
+ classID: calCompositeCalendarClassID,
+ QueryInterface: ChromeUtils.generateQI(calCompositeCalendarInterfaces),
+
+ //
+ // calICompositeCalendar interface
+ //
+
+ mCalendars: null,
+ mDefaultCalendar: null,
+ mPrefPrefix: null,
+ mDefaultPref: null,
+ mActivePref: null,
+
+ get enabledCalendars() {
+ return this.mCalendars.filter(e => !e.getProperty("disabled"));
+ },
+
+ set prefPrefix(aPrefPrefix) {
+ if (this.mPrefPrefix) {
+ for (let calendar of this.mCalendars) {
+ this.removeCalendar(calendar);
+ }
+ }
+
+ this.mPrefPrefix = aPrefPrefix;
+ this.mActivePref = aPrefPrefix + "-in-composite";
+ this.mDefaultPref = aPrefPrefix + "-default";
+ let cals = cal.manager.getCalendars();
+
+ cals.forEach(function (calendar) {
+ if (calendar.getProperty(this.mActivePref)) {
+ this.addCalendar(calendar);
+ }
+ if (calendar.getProperty(this.mDefaultPref)) {
+ this.setDefaultCalendar(calendar, false);
+ }
+ }, this);
+ },
+
+ get prefPrefix() {
+ return this.mPrefPrefix;
+ },
+
+ addCalendar(aCalendar) {
+ cal.ASSERT(aCalendar.id, "calendar does not have an id!", true);
+
+ // check if the calendar already exists
+ if (this.getCalendarById(aCalendar.id)) {
+ return;
+ }
+
+ // add our observer helper
+ aCalendar.addObserver(this.mObserverHelper);
+
+ this.mCalendars.push(aCalendar);
+ if (this.mPrefPrefix) {
+ aCalendar.setProperty(this.mActivePref, true);
+ }
+ this.mCompositeObservers.notify("onCalendarAdded", [aCalendar]);
+
+ // if we have no default calendar, we need one here
+ if (this.mDefaultCalendar == null && !aCalendar.getProperty("disabled")) {
+ this.setDefaultCalendar(aCalendar, false);
+ }
+ },
+
+ removeCalendar(aCalendar) {
+ let id = aCalendar.id;
+ let newCalendars = this.mCalendars.filter(calendar => calendar.id != id);
+ if (newCalendars.length != this.mCalendars) {
+ this.mCalendars = newCalendars;
+ if (this.mPrefPrefix) {
+ aCalendar.deleteProperty(this.mActivePref);
+ aCalendar.deleteProperty(this.mDefaultPref);
+ }
+ aCalendar.removeObserver(this.mObserverHelper);
+ this.mCompositeObservers.notify("onCalendarRemoved", [aCalendar]);
+ }
+ },
+
+ getCalendarById(aId) {
+ for (let calendar of this.mCalendars) {
+ if (calendar.id == aId) {
+ return calendar;
+ }
+ }
+ return null;
+ },
+
+ getCalendars() {
+ return this.mCalendars;
+ },
+
+ get defaultCalendar() {
+ return this.mDefaultCalendar;
+ },
+
+ setDefaultCalendar(calendar, usePref) {
+ // Don't do anything if the passed calendar is the default calendar
+ if (calendar && this.mDefaultCalendar && this.mDefaultCalendar.id == calendar.id) {
+ return;
+ }
+ if (usePref && this.mPrefPrefix) {
+ if (this.mDefaultCalendar) {
+ this.mDefaultCalendar.deleteProperty(this.mDefaultPref);
+ }
+ // if not null set the new calendar as default in the preferences
+ if (calendar) {
+ calendar.setProperty(this.mDefaultPref, true);
+ }
+ }
+ this.mDefaultCalendar = calendar;
+ this.mCompositeObservers.notify("onDefaultCalendarChanged", [calendar]);
+ },
+
+ set defaultCalendar(calendar) {
+ this.setDefaultCalendar(calendar, true);
+ },
+
+ //
+ // calICalendar interface
+ //
+ // Write operations here are forwarded to either the item's
+ // parent calendar, or to the default calendar if one is set.
+ // Get operations are sent to each calendar.
+ //
+
+ get id() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set id(id) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get superCalendar() {
+ // There shouldn't be a superCalendar for the composite
+ return this;
+ },
+ set superCalendar(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ // this could, at some point, return some kind of URI identifying
+ // all the child calendars, thus letting us create nifty calendar
+ // trees.
+ get uri() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set uri(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get readOnly() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set readOnly(bool) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get canRefresh() {
+ return true;
+ },
+
+ get name() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+ set name(val) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ get type() {
+ return "composite";
+ },
+
+ getProperty(aName) {
+ return this.mDefaultCalendar.getProperty(aName);
+ },
+
+ get supportsScheduling() {
+ return false;
+ },
+
+ getSchedulingSupport() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ setProperty(aName, aValue) {
+ return this.mDefaultCalendar.setProperty(aName, aValue);
+ },
+
+ deleteProperty(aName) {
+ return this.mDefaultCalendar.deleteProperty(aName);
+ },
+
+ // void addObserver( in calIObserver observer );
+ mCompositeObservers: null,
+ mObservers: null,
+ addObserver(aObserver) {
+ let wrappedCObserver = cal.wrapInstance(aObserver, Ci.calICompositeObserver);
+ if (wrappedCObserver) {
+ this.mCompositeObservers.add(wrappedCObserver);
+ }
+ this.mObservers.add(aObserver);
+ },
+
+ // void removeObserver( in calIObserver observer );
+ removeObserver(aObserver) {
+ let wrappedCObserver = cal.wrapInstance(aObserver, Ci.calICompositeObserver);
+ if (wrappedCObserver) {
+ this.mCompositeObservers.delete(wrappedCObserver);
+ }
+ this.mObservers.delete(aObserver);
+ },
+
+ refresh() {
+ if (this.mStatusObserver) {
+ this.mStatusObserver.startMeteors(
+ Ci.calIStatusObserver.DETERMINED_PROGRESS,
+ this.mCalendars.length
+ );
+ }
+ for (let calendar of this.enabledCalendars) {
+ try {
+ if (calendar.canRefresh) {
+ calendar.refresh();
+ }
+ } catch (e) {
+ cal.ASSERT(false, e);
+ }
+ }
+ // send out a single onLoad for this composite calendar,
+ // although e.g. the ics provider will trigger another
+ // onLoad asynchronously; we cannot rely on every calendar
+ // sending an onLoad:
+ this.mObservers.notify("onLoad", [this]);
+ },
+
+ // Promise<calIItemBase> modifyItem( in calIItemBase aNewItem, in calIItemBase aOldItem)
+ async modifyItem(aNewItem, aOldItem) {
+ cal.ASSERT(aNewItem.calendar, "Composite can't modify item with null calendar", true);
+ cal.ASSERT(aNewItem.calendar != this, "Composite can't modify item with this calendar", true);
+
+ return aNewItem.calendar.modifyItem(aNewItem, aOldItem);
+ },
+
+ // Promise<void> deleteItem(in calIItemBase aItem);
+ async deleteItem(aItem) {
+ cal.ASSERT(aItem.calendar, "Composite can't delete item with null calendar", true);
+ cal.ASSERT(aItem.calendar != this, "Composite can't delete item with this calendar", true);
+
+ return aItem.calendar.deleteItem(aItem);
+ },
+
+ // Promise<calIItemBase> addItem(in calIItemBase aItem);
+ addItem(aItem) {
+ return this.mDefaultCalendar.addItem(aItem);
+ },
+
+ // Promise<calIItemBase|null> getItem(in string aId);
+ async getItem(aId) {
+ for (let calendar of this.enabledCalendars) {
+ let item = await calendar.getItem(aId);
+ if (item) {
+ return item;
+ }
+ }
+ return null;
+ },
+
+ // ReadableStream<calItemBase> getItems(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ // If there are no calendars return early.
+ let enabledCalendars = this.enabledCalendars;
+ if (enabledCalendars.length == 0) {
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+ if (this.mStatusObserver) {
+ if (this.mStatusObserver.spinning == Ci.calIStatusObserver.NO_PROGRESS) {
+ this.mStatusObserver.startMeteors(Ci.calIStatusObserver.UNDETERMINED_PROGRESS, -1);
+ }
+ }
+
+ let compositeCal = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ iterators: [],
+ async start(controller) {
+ for (let calendar of enabledCalendars) {
+ let iterator = cal.iterate.streamValues(
+ calendar.getItems(itemFilter, count, rangeStart, rangeEnd)
+ );
+ this.iterators.push(iterator);
+ for await (let items of iterator) {
+ controller.enqueue(items);
+ }
+
+ if (compositeCal.statusDisplayed) {
+ compositeCal.mStatusObserver.calendarCompleted(calendar);
+ }
+ }
+ if (compositeCal.statusDisplayed) {
+ compositeCal.mStatusObserver.stopMeteors();
+ }
+ controller.close();
+ },
+
+ async cancel(reason) {
+ for (let iterator of this.iterators) {
+ await iterator.cancel(reason);
+ }
+ if (compositeCal.statusDisplayed) {
+ compositeCal.mStatusObserver.stopMeteors();
+ }
+ },
+ }
+ );
+ },
+
+ // Promise<calItemBase[]> getItemsAsArray(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ async getItemsAsArray(itemFilter, count, rangeStart, rangeEnd) {
+ return cal.iterate.streamToArray(this.getItems(itemFilter, count, rangeStart, rangeEnd));
+ },
+
+ startBatch() {
+ this.mCompositeObservers.notify("onStartBatch", [this]);
+ },
+ endBatch() {
+ this.mCompositeObservers.notify("onEndBatch", [this]);
+ },
+
+ get statusDisplayed() {
+ if (this.mStatusObserver) {
+ return this.mStatusObserver.spinning != Ci.calIStatusObserver.NO_PROGRESS;
+ }
+ return false;
+ },
+
+ setStatusObserver(aStatusObserver, aWindow) {
+ this.mStatusObserver = aStatusObserver;
+ if (this.mStatusObserver) {
+ this.mStatusObserver.initialize(aWindow);
+ }
+ },
+};
diff --git a/comm/calendar/providers/composite/components.conf b/comm/calendar/providers/composite/components.conf
new file mode 100644
index 0000000000..3f75bf3500
--- /dev/null
+++ b/comm/calendar/providers/composite/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/
+
+Classes = [
+ {
+ 'cid': '{aeff788d-63b0-4996-91fb-40a7654c6224}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=composite'],
+ 'jsm': 'resource:///modules/CalCompositeCalendar.jsm',
+ 'constructor': 'CalCompositeCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/composite/moz.build b/comm/calendar/providers/composite/moz.build
new file mode 100644
index 0000000000..9009560429
--- /dev/null
+++ b/comm/calendar/providers/composite/moz.build
@@ -0,0 +1,12 @@
+# 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 += [
+ "CalCompositeCalendar.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/calendar/providers/ics/CalICSCalendar.sys.mjs b/comm/calendar/providers/ics/CalICSCalendar.sys.mjs
new file mode 100644
index 0000000000..df5eab830b
--- /dev/null
+++ b/comm/calendar/providers/ics/CalICSCalendar.sys.mjs
@@ -0,0 +1,1235 @@
+/* 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 { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+// This is a non-sync ics file. It reads the file pointer to by uri when set,
+// then writes it on updates. External changes to the file will be
+// ignored and overwritten.
+//
+// XXX Should do locks, so that external changes are not overwritten.
+
+function icsNSResolver(prefix) {
+ const ns = { D: "DAV:" };
+ return ns[prefix] || null;
+}
+
+function icsXPathFirst(aNode, aExpr, aType) {
+ return cal.xml.evalXPathFirst(aNode, aExpr, icsNSResolver, aType);
+}
+
+var calICSCalendarClassID = Components.ID("{f8438bff-a3c9-4ed5-b23f-2663b5469abf}");
+var calICSCalendarInterfaces = [
+ "calICalendar",
+ "calISchedulingSupport",
+ "nsIChannelEventSink",
+ "nsIInterfaceRequestor",
+ "nsIStreamListener",
+ "nsIStreamLoaderObserver",
+];
+
+/**
+ * @implements {calICalendar}
+ * @implements {calISchedulingSupport}
+ * @implements {nsIChannelEventSink}
+ * @implements {nsIInterfaceRequestor}
+ * @implements {nsIStreamListener}
+ * @implements {nsIStreamLoaderObserver}
+ */
+export class CalICSCalendar extends cal.provider.BaseClass {
+ classID = calICSCalendarClassID;
+ QueryInterface = cal.generateQI(calICSCalendarInterfaces);
+ classInfo = cal.generateCI({
+ classID: calICSCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=ics",
+ classDescription: "Calendar ICS provider",
+ interfaces: calICSCalendarInterfaces,
+ });
+
+ #hooks = null;
+ #memoryCalendar = null;
+ #modificationActions = [];
+ #observer = null;
+ #uri = null;
+ #locked = false;
+ #unmappedComponents = [];
+ #unmappedProperties = [];
+
+ // Public to allow access by calCachedCalendar
+ _queue = [];
+
+ constructor() {
+ super();
+
+ this.initProviderBase();
+ this.initICSCalendar();
+ }
+
+ initICSCalendar() {
+ this.#memoryCalendar = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calICalendar
+ );
+
+ this.#memoryCalendar.superCalendar = this;
+ this.#observer = new calICSObserver(this);
+ this.#memoryCalendar.addObserver(this.#observer); // XXX Not removed
+ }
+
+ //
+ // calICalendar interface
+ //
+ get type() {
+ return "ics";
+ }
+
+ get canRefresh() {
+ return true;
+ }
+
+ get uri() {
+ return this.#uri;
+ }
+
+ set uri(uri) {
+ if (this.#uri?.spec == uri.spec) {
+ return;
+ }
+
+ this.#uri = uri;
+ this.#memoryCalendar.uri = this.#uri;
+
+ if (this.#uri.schemeIs("http") || this.#uri.schemeIs("https")) {
+ this.#hooks = new httpHooks(this);
+ } else if (this.#uri.schemeIs("file")) {
+ this.#hooks = new fileHooks();
+ } else {
+ this.#hooks = new dummyHooks();
+ }
+ }
+
+ getProperty(aName) {
+ switch (aName) {
+ case "requiresNetwork":
+ return !this.uri.schemeIs("file");
+ }
+
+ return super.getProperty(aName);
+ }
+
+ get supportsScheduling() {
+ return true;
+ }
+
+ getSchedulingSupport() {
+ return this;
+ }
+
+ // Always use the queue, just to reduce the amount of places where
+ // this.mMemoryCalendar.addItem() and friends are called. less
+ // copied code.
+ addItem(aItem) {
+ return this.adoptItem(aItem.clone());
+ }
+
+ // Used to allow the cachedCalendar provider to hook into adoptItem() before
+ // it returns.
+ _cachedAdoptItemCallback = null;
+
+ async adoptItem(aItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ let adoptCallback = this._cachedAdoptItemCallback;
+
+ let item = await new Promise(resolve => {
+ this.startBatch();
+ this._queue.push({
+ action: "add",
+ item: aItem,
+ listener: item => {
+ this.endBatch();
+ resolve(item);
+ },
+ });
+ this.#processQueue();
+ });
+
+ if (adoptCallback) {
+ await adoptCallback(item.calendar, Cr.NS_OK, Ci.calIOperationListener.ADD, item.id, item);
+ }
+ return item;
+ }
+
+ // Used to allow the cachedCalendar provider to hook into modifyItem() before
+ // it returns.
+ _cachedModifyItemCallback = null;
+
+ async modifyItem(aNewItem, aOldItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ let modifyCallback = this._cachedModifyItemCallback;
+ let item = await new Promise(resolve => {
+ this.startBatch();
+ this._queue.push({
+ action: "modify",
+ newItem: aNewItem,
+ oldItem: aOldItem,
+ listener: item => {
+ this.endBatch();
+ resolve(item);
+ },
+ });
+ this.#processQueue();
+ });
+
+ if (modifyCallback) {
+ await modifyCallback(item.calendar, Cr.NS_OK, Ci.calIOperationListener.MODIFY, item.id, item);
+ }
+ return item;
+ }
+
+ /**
+ * Delete the provided item.
+ *
+ * @param {calIItemBase} aItem
+ * @returns {Promise<void>}
+ */
+ deleteItem(aItem) {
+ if (this.readOnly) {
+ throw new Components.Exception("Calendar is not writable", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ return new Promise(resolve => {
+ this._queue.push({
+ action: "delete",
+ item: aItem,
+ listener: resolve,
+ });
+ this.#processQueue();
+ });
+ }
+
+ /**
+ * @param {string} aId
+ * @returns {Promise<calIItemBase?>}
+ */
+ getItem(aId) {
+ return new Promise(resolve => {
+ this._queue.push({
+ action: "get_item",
+ id: aId,
+ listener: resolve,
+ });
+ this.#processQueue();
+ });
+ }
+
+ /**
+ * @param {number} aItemFilter
+ * @param {number} aCount
+ * @param {calIDateTime} aRangeStart
+ * @param {calIDateTime} aRangeEndEx
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(aItemFilter, aCount, aRangeStart, aRangeEndEx) {
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ aCount,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ start(controller) {
+ self._queue.push({
+ action: "get_items",
+ exec: async () => {
+ for await (let value of cal.iterate.streamValues(
+ self.#memoryCalendar.getItems(aItemFilter, aCount, aRangeStart, aRangeEndEx)
+ )) {
+ controller.enqueue(value);
+ }
+ controller.close();
+ },
+ });
+ self.#processQueue();
+ },
+ }
+ );
+ }
+
+ refresh() {
+ this._queue.push({ action: "refresh", forceRefresh: false });
+ this.#processQueue();
+ }
+
+ startBatch() {
+ this.#observer.onStartBatch(this);
+ }
+
+ endBatch() {
+ this.#observer.onEndBatch(this);
+ }
+
+ #forceRefresh() {
+ this._queue.push({ action: "refresh", forceRefresh: true });
+ this.#processQueue();
+ }
+
+ #prepareChannel(channel, forceRefresh) {
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.notificationCallbacks = this;
+
+ // Allow the hook to do its work, like a performing a quick check to
+ // see if the remote file really changed. Might save a lot of time
+ this.#hooks.onBeforeGet(channel, forceRefresh);
+ }
+
+ #createMemoryCalendar() {
+ // Create a new calendar, to get rid of all the old events
+ // Don't forget to remove the observer
+ if (this.#memoryCalendar) {
+ this.#memoryCalendar.removeObserver(this.#observer);
+ }
+ this.#memoryCalendar = Cc["@mozilla.org/calendar/calendar;1?type=memory"].createInstance(
+ Ci.calICalendar
+ );
+ this.#memoryCalendar.uri = this.#uri;
+ this.#memoryCalendar.superCalendar = this;
+ }
+
+ #doRefresh(force) {
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ this.#prepareChannel(channel, force);
+
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+
+ // Lock other changes to the item list.
+ this.#lock();
+
+ try {
+ streamLoader.init(this);
+ channel.asyncOpen(streamLoader);
+ } catch (e) {
+ // File not found: a new calendar. No problem.
+ cal.LOG("[calICSCalendar] Error occurred opening channel: " + e);
+ this.#unlock();
+ }
+ }
+
+ // nsIChannelEventSink implementation
+ asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) {
+ this.#prepareChannel(aNewChannel, true);
+ aCallback.onRedirectVerifyCallback(Cr.NS_OK);
+ }
+
+ // nsIStreamLoaderObserver impl
+ // Listener for download. Parse the downloaded file
+
+ onStreamComplete(loader, ctxt, status, resultLength, result) {
+ let cont = false;
+
+ if (Components.isSuccessCode(status)) {
+ // Allow the hook to get needed data (like an etag) of the channel
+ cont = this.#hooks.onAfterGet(loader.request);
+ cal.LOG("[calICSCalendar] Loading ICS succeeded, needs further processing: " + cont);
+ } else {
+ // Failure may be due to temporary connection issue, keep old data to
+ // prevent potential data loss if it becomes available again.
+ cal.LOG("[calICSCalendar] Unable to load stream - status: " + status);
+
+ // Check for bad server certificates on SSL/TLS connections.
+ cal.provider.checkBadCertStatus(loader.request, status, this);
+ }
+
+ if (!cont) {
+ // no need to process further, we can use the previous data
+ // HACK Sorry, but offline support requires the items to be signaled
+ // even if nothing has changed (especially at startup)
+ this.#observer.onLoad(this);
+ this.#unlock();
+ return;
+ }
+
+ // Clear any existing events if there was no result
+ if (!resultLength) {
+ this.#createMemoryCalendar();
+ this.#memoryCalendar.addObserver(this.#observer);
+ this.#observer.onLoad(this);
+ this.#unlock();
+ return;
+ }
+
+ // This conversion is needed, because the stream only knows about
+ // byte arrays, not about strings or encodings. The array of bytes
+ // need to be interpreted as utf8 and put into a javascript string.
+ let str;
+ try {
+ str = new TextDecoder().decode(Uint8Array.from(result));
+ } catch (e) {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.CAL_UTF8_DECODING_FAILED,
+ e.toString()
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.READ_FAILED, "");
+ this.#unlock();
+ return;
+ }
+
+ this.#createMemoryCalendar();
+
+ this.#observer.onStartBatch(this);
+ this.#memoryCalendar.addObserver(this.#observer);
+
+ // Wrap parsing in a try block. Will ignore errors. That's a good thing
+ // for non-existing or empty files, but not good for invalid files.
+ // That's why we put them in readOnly mode
+ let parser = Cc["@mozilla.org/calendar/ics-parser;1"].createInstance(Ci.calIIcsParser);
+ let self = this;
+ let listener = {
+ // calIIcsParsingListener
+ onParsingComplete(rc, parser_) {
+ try {
+ for (let item of parser_.getItems()) {
+ self.#memoryCalendar.adoptItem(item);
+ }
+ self.#unmappedComponents = parser_.getComponents();
+ self.#unmappedProperties = parser_.getProperties();
+ cal.LOG("[calICSCalendar] Parsing ICS succeeded for " + self.uri.spec);
+ } catch (exc) {
+ cal.LOG("[calICSCalendar] Parsing ICS failed for \nException: " + exc);
+ self.#observer.onError(self.superCalendar, exc.result, exc.toString());
+ self.#observer.onError(self.superCalendar, Ci.calIErrors.READ_FAILED, "");
+ }
+ self.#observer.onEndBatch(self);
+ self.#observer.onLoad(self);
+
+ // Now that all items have been stuffed into the memory calendar
+ // we should add ourselves as observer. It is important that this
+ // happens *after* the calls to adoptItem in the above loop to prevent
+ // the views from being notified.
+ self.#unlock();
+ },
+ };
+ parser.parseString(str, listener);
+ }
+
+ async #writeICS() {
+ cal.LOG("[calICSCalendar] Commencing write of ICS Calendar " + this.name);
+ if (!this.#uri) {
+ throw Components.Exception("mUri must be set", Cr.NS_ERROR_FAILURE);
+ }
+ this.#lock();
+ try {
+ await this.#makeBackup();
+ await this.#doWriteICS();
+ } catch (e) {
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ }
+ }
+
+ /**
+ * Write the contents of an ICS serializer to an open channel as an ICS file.
+ *
+ * @param {calIIcsSerializer} serializer - The serializer to write
+ * @param {nsIChannel} channel - The destination upload or file channel
+ */
+ async #writeSerializerToChannel(serializer, channel) {
+ if (channel.URI.schemeIs("file")) {
+ // We handle local files separately, as writing to an nsIChannel has the
+ // potential to fail partway and can leave a file truncated, resulting in
+ // data loss. For local files, we have the option to do atomic writes.
+ try {
+ const file = channel.QueryInterface(Ci.nsIFileChannel).file;
+
+ // The temporary file permissions will become the file permissions since
+ // we move the temp file over top of the file itself. Copy the file
+ // permissions or use a restrictive default.
+ const tmpFilePermissions = file.exists() ? file.permissions : 0o600;
+
+ // We're going to be writing to an arbitrary point in the user's file
+ // system, so we want to be very careful that we're not going to
+ // overwrite any of their files.
+ const tmpFilePath = await IOUtils.createUniqueFile(
+ file.parent.path,
+ `${file.leafName}.tmp`,
+ tmpFilePermissions
+ );
+
+ const outString = serializer.serializeToString();
+ await IOUtils.writeUTF8(file.path, outString, {
+ tmpPath: tmpFilePath,
+ });
+ } catch (e) {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.MODIFICATION_FAILED,
+ `Failed to write to calendar file ${channel.URI.spec}: ${e.message}`
+ );
+
+ // Writing the file has failed; refresh and signal error to all
+ // modifying operations.
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ this.#forceRefresh();
+
+ return;
+ }
+
+ // Write succeeded and we can clean up. We can reuse the channel, as the
+ // last-modified time on the file will still be accurate.
+ this.#hooks.onAfterPut(channel, () => {
+ this.#unlock();
+ this.#observer.onLoad(this);
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ });
+
+ return;
+ }
+
+ channel.notificationCallbacks = this;
+ let uploadChannel = channel.QueryInterface(Ci.nsIUploadChannel);
+
+ // Set the content of the upload channel to our ICS file.
+ let icsStream = serializer.serializeToInputStream();
+ uploadChannel.setUploadStream(icsStream, "text/calendar", -1);
+
+ channel.asyncOpen(this);
+ }
+
+ async #doWriteICS() {
+ cal.LOG("[calICSCalendar] Writing ICS File " + this.uri.spec);
+
+ let serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance(
+ Ci.calIIcsSerializer
+ );
+ for (let comp of this.#unmappedComponents) {
+ serializer.addComponent(comp);
+ }
+
+ for (let prop of this.#unmappedProperties) {
+ switch (prop.propertyName) {
+ // we always set the current name and timezone:
+ case "X-WR-CALNAME":
+ case "X-WR-TIMEZONE":
+ break;
+ default:
+ serializer.addProperty(prop);
+ break;
+ }
+ }
+
+ let prop = cal.icsService.createIcalProperty("X-WR-CALNAME");
+ prop.value = this.name;
+ serializer.addProperty(prop);
+ prop = cal.icsService.createIcalProperty("X-WR-TIMEZONE");
+ prop.value = cal.timezoneService.defaultTimezone.tzid;
+ serializer.addProperty(prop);
+
+ // Get items directly from the memory calendar, as we're locked now and
+ // calling this.getItems{,AsArray}() will return immediately
+ serializer.addItems(
+ await this.#memoryCalendar.getItemsAsArray(
+ Ci.calICalendar.ITEM_FILTER_TYPE_ALL | Ci.calICalendar.ITEM_FILTER_COMPLETED_ALL,
+ 0,
+ null,
+ null
+ )
+ );
+
+ let inLastWindowClosingSurvivalArea = false;
+ try {
+ // All events are returned. Now set up a channel and a
+ // streamloader to upload. onStopRequest will be called
+ // once the write has finished
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+
+ // Allow the hook to add things to the channel, like a
+ // header that checks etags
+ let notChanged = this.#hooks.onBeforePut(channel);
+ if (notChanged) {
+ // Prevent Thunderbird from exiting entirely until we've finished
+ // uploading one way or another
+ Services.startup.enterLastWindowClosingSurvivalArea();
+ inLastWindowClosingSurvivalArea = true;
+
+ this.#writeSerializerToChannel(serializer, channel);
+ } else {
+ this.#observer.onError(
+ this.superCalendar,
+ Ci.calIErrors.MODIFICATION_FAILED,
+ "The calendar has been changed remotely. Please reload and apply your changes again!"
+ );
+
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+ }
+ } catch (ex) {
+ if (inLastWindowClosingSurvivalArea) {
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ }
+
+ this.#observer.onError(
+ this.superCalendar,
+ ex.result,
+ "The calendar could not be saved; there was a failure: 0x" + ex.result.toString(16)
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.MODIFICATION_FAILED, "");
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+
+ this.#forceRefresh();
+ }
+ }
+
+ // nsIStreamListener impl
+ // For after publishing. Do error checks here
+ onStartRequest(aRequest) {}
+
+ onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
+ // All data must be consumed. For an upload channel, there is
+ // no meaningful data. So it gets read and then ignored
+ let scriptableInputStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ scriptableInputStream.init(aInputStream);
+ scriptableInputStream.read(-1);
+ }
+
+ onStopRequest(aRequest, aStatusCode) {
+ let httpChannel;
+ let requestSucceeded = false;
+ try {
+ httpChannel = aRequest.QueryInterface(Ci.nsIHttpChannel);
+ requestSucceeded = httpChannel.requestSucceeded;
+ } catch (e) {
+ // This may fail if it was not a http channel, handled later on.
+ }
+
+ if (httpChannel) {
+ cal.LOG("[calICSCalendar] channel.requestSucceeded: " + requestSucceeded);
+ }
+
+ if (
+ (httpChannel && !requestSucceeded) ||
+ (!httpChannel && !Components.isSuccessCode(aRequest.status))
+ ) {
+ this.#observer.onError(
+ this.superCalendar,
+ Components.isSuccessCode(aRequest.status) ? Ci.calIErrors.DAV_PUT_ERROR : aRequest.status,
+ "Publishing the calendar file failed\n" +
+ "Status code: " +
+ aRequest.status.toString(16) +
+ "\n"
+ );
+ this.#observer.onError(this.superCalendar, Ci.calIErrors.MODIFICATION_FAILED, "");
+
+ // The PUT has failed; refresh and signal error to all modifying operations
+ this.#forceRefresh();
+ this.#unlock(Ci.calIErrors.MODIFICATION_FAILED);
+
+ Services.startup.exitLastWindowClosingSurvivalArea();
+
+ return;
+ }
+
+ // Allow the hook to grab data of the channel, like the new etag
+ this.#hooks.onAfterPut(aRequest, () => {
+ this.#unlock();
+ this.#observer.onLoad(this);
+ Services.startup.exitLastWindowClosingSurvivalArea();
+ });
+ }
+
+ async #processQueue() {
+ if (this._isLocked) {
+ return;
+ }
+
+ let task;
+ let refreshAction = null;
+ while ((task = this._queue.shift())) {
+ switch (task.action) {
+ case "add":
+ this.#lock();
+ this.#memoryCalendar.addItem(task.item).then(async item => {
+ task.item = item;
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "modify":
+ this.#lock();
+ this.#memoryCalendar.modifyItem(task.newItem, task.oldItem).then(async item => {
+ task.item = item;
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "delete":
+ this.#lock();
+ this.#memoryCalendar.deleteItem(task.item).then(async () => {
+ this.#modificationActions.push(task);
+ await this.#writeICS();
+ });
+ return;
+ case "get_item":
+ this.#memoryCalendar.getItem(task.id).then(task.listener);
+ break;
+ case "get_items":
+ task.exec();
+ break;
+ case "refresh":
+ refreshAction = task;
+ break;
+ }
+
+ if (refreshAction) {
+ cal.LOG(
+ "[calICSCalendar] Refreshing " +
+ this.name +
+ (refreshAction.forceRefresh ? " (forced)" : "")
+ );
+ this.#doRefresh(refreshAction.forceRefresh);
+
+ // break queue processing here and wait for refresh to finish
+ // before processing further operations
+ break;
+ }
+ }
+ }
+
+ #lock() {
+ this.#locked = true;
+ }
+
+ #unlock(errCode) {
+ cal.ASSERT(this.#locked, "unexpected!");
+
+ this.#modificationActions.forEach(action => {
+ let listener = action.listener;
+ if (typeof listener == "function") {
+ listener(action.item);
+ } else if (listener) {
+ let args = action.opCompleteArgs;
+ cal.ASSERT(args, "missing onOperationComplete call!");
+ if (Components.isSuccessCode(args[1]) && errCode && !Components.isSuccessCode(errCode)) {
+ listener.onOperationComplete(args[0], errCode, args[2], args[3], null);
+ } else {
+ listener.onOperationComplete(...args);
+ }
+ }
+ });
+ this.#modificationActions = [];
+
+ this.#locked = false;
+ this.#processQueue();
+ }
+
+ // Visible for testing.
+ get _isLocked() {
+ return this.#locked;
+ }
+
+ /**
+ * @see nsIInterfaceRequestor
+ * @see calProviderUtils.jsm
+ */
+ getInterface = cal.provider.InterfaceRequestor_getInterface;
+
+ /**
+ * Make a backup of the (remote) calendar
+ *
+ * This will download the remote file into the profile dir.
+ * It should be called before every upload, so every change can be
+ * restored. By default, it will keep 3 backups. It also keeps one
+ * file each day, for 3 days. That way, even if the user doesn't notice
+ * the remote calendar has become corrupted, he will still lose max 1
+ * day of work.
+ *
+ * @returns {Promise} A promise that is settled once backup completed.
+ */
+ #makeBackup() {
+ return new Promise((resolve, reject) => {
+ // Uses |pseudoID|, an id of the calendar, defined below
+ function makeName(type) {
+ return "calBackupData_" + pseudoID + "_" + type + ".ics";
+ }
+
+ // This is a bit messy. createUnique creates an empty file,
+ // but we don't use that file. All we want is a filename, to be used
+ // in the call to copyTo later. So we create a file, get the filename,
+ // and never use the file again, but write over it.
+ // Using createUnique anyway, because I don't feel like
+ // re-implementing it
+ function makeDailyFileName() {
+ let dailyBackupFile = backupDir.clone();
+ dailyBackupFile.append(makeName("day"));
+ dailyBackupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+ dailyBackupFileName = dailyBackupFile.leafName;
+
+ // Remove the reference to the nsIFile, because we need to
+ // write over the file later, and you never know what happens
+ // if something still has a reference.
+ // Also makes it explicit that we don't need the file itself,
+ // just the name.
+ dailyBackupFile = null;
+
+ return dailyBackupFileName;
+ }
+
+ function purgeBackupsByType(files, type) {
+ // filter out backups of the type we care about.
+ let filteredFiles = files.filter(file =>
+ file.name.includes("calBackupData_" + pseudoID + "_" + type)
+ );
+ // Sort by lastmodifed
+ filteredFiles.sort((a, b) => a.lastmodified - b.lastmodified);
+ // And delete the oldest files, and keep the desired number of
+ // old backups
+ for (let i = 0; i < filteredFiles.length - numBackupFiles; ++i) {
+ let file = backupDir.clone();
+ file.append(filteredFiles[i].name);
+
+ try {
+ file.remove(false);
+ } catch (ex) {
+ // This can fail because of some crappy code in
+ // nsIFile. That's not the end of the world. We can
+ // try to remove the file the next time around.
+ }
+ }
+ }
+
+ function purgeOldBackups() {
+ // Enumerate files in the backupdir for expiry of old backups
+ let files = [];
+ for (let file of backupDir.directoryEntries) {
+ if (file.isFile()) {
+ files.push({ name: file.leafName, lastmodified: file.lastModifiedTime });
+ }
+ }
+
+ if (doDailyBackup) {
+ purgeBackupsByType(files, "day");
+ } else {
+ purgeBackupsByType(files, "edit");
+ }
+ }
+
+ function copyToOverwriting(oldFile, newParentDir, newName) {
+ try {
+ let newFile = newParentDir.clone();
+ newFile.append(newName);
+
+ if (newFile.exists()) {
+ newFile.remove(false);
+ }
+ oldFile.copyTo(newParentDir, newName);
+ } catch (e) {
+ cal.ERROR("[calICSCalendar] Backup failed, no copy: " + e);
+ // Error in making a daily/initial backup.
+ // not fatal, so just continue
+ }
+ }
+
+ let backupDays = Services.prefs.getIntPref("calendar.backup.days", 1);
+ let numBackupFiles = Services.prefs.getIntPref("calendar.backup.filenum", 3);
+
+ let backupDir;
+ try {
+ backupDir = cal.provider.getCalendarDirectory();
+ backupDir.append("backup");
+ if (!backupDir.exists()) {
+ backupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ }
+ } catch (e) {
+ // Backup dir wasn't found. Likely because we are running in
+ // xpcshell. Don't die, but continue the upload.
+ cal.ERROR("[calICSCalendar] Backup failed, no backupdir:" + e);
+ resolve();
+ return;
+ }
+
+ let pseudoID;
+ try {
+ pseudoID = this.getProperty("uniquenum2");
+ if (!pseudoID) {
+ pseudoID = new Date().getTime();
+ this.setProperty("uniquenum2", pseudoID);
+ }
+ } catch (e) {
+ // calendarmgr not found. Likely because we are running in
+ // xpcshell. Don't die, but continue the upload.
+ cal.ERROR("[calICSCalendar] Backup failed, no calendarmanager:" + e);
+ resolve();
+ return;
+ }
+
+ let doInitialBackup = false;
+ let initialBackupFile = backupDir.clone();
+ initialBackupFile.append(makeName("initial"));
+ if (!initialBackupFile.exists()) {
+ doInitialBackup = true;
+ }
+
+ let doDailyBackup = false;
+ let backupTime = this.getProperty("backup-time2");
+ if (!backupTime || new Date().getTime() > backupTime + backupDays * 24 * 60 * 60 * 1000) {
+ // It's time do to a daily backup
+ doDailyBackup = true;
+ this.setProperty("backup-time2", new Date().getTime());
+ }
+
+ let dailyBackupFileName;
+ if (doDailyBackup) {
+ dailyBackupFileName = makeDailyFileName(backupDir);
+ }
+
+ let backupFile = backupDir.clone();
+ backupFile.append(makeName("edit"));
+ backupFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("0600", 8));
+
+ purgeOldBackups();
+
+ // Now go download the remote file, and store it somewhere local.
+ let channel = Services.io.newChannelFromURI(
+ this.#uri,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null,
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_OTHER
+ );
+ channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ channel.notificationCallbacks = this;
+
+ let downloader = Cc["@mozilla.org/network/downloader;1"].createInstance(Ci.nsIDownloader);
+ let listener = {
+ onDownloadComplete(opdownloader, request, ctxt, status, result) {
+ if (!Components.isSuccessCode(status)) {
+ reject();
+ return;
+ }
+ if (doInitialBackup) {
+ copyToOverwriting(result, backupDir, makeName("initial"));
+ }
+ if (doDailyBackup) {
+ copyToOverwriting(result, backupDir, dailyBackupFileName);
+ }
+ resolve();
+ },
+ };
+
+ downloader.init(listener, backupFile);
+ try {
+ channel.asyncOpen(downloader);
+ } catch (e) {
+ // For local files, asyncOpen throws on new (calendar) files
+ // No problem, go and upload something
+ cal.ERROR("[calICSCalendar] Backup failed in asyncOpen:" + e);
+ resolve();
+ }
+ });
+ }
+}
+
+/**
+ * @implements {calIObserver}
+ */
+class calICSObserver {
+ #calendar = null;
+
+ constructor(calendar) {
+ this.#calendar = calendar;
+ }
+
+ onStartBatch(aCalendar) {
+ this.#calendar.observers.notify("onStartBatch", [aCalendar]);
+ }
+
+ onEndBatch(aCalendar) {
+ this.#calendar.observers.notify("onEndBatch", [aCalendar]);
+ }
+
+ onLoad(aCalendar) {
+ this.#calendar.observers.notify("onLoad", [aCalendar]);
+ }
+
+ onAddItem(aItem) {
+ this.#calendar.observers.notify("onAddItem", [aItem]);
+ }
+
+ onModifyItem(aNewItem, aOldItem) {
+ this.#calendar.observers.notify("onModifyItem", [aNewItem, aOldItem]);
+ }
+
+ onDeleteItem(aDeletedItem) {
+ this.#calendar.observers.notify("onDeleteItem", [aDeletedItem]);
+ }
+
+ onError(aCalendar, aErrNo, aMessage) {
+ this.#calendar.readOnly = true;
+ this.#calendar.notifyError(aErrNo, aMessage);
+ }
+
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ this.#calendar.observers.notify("onPropertyChanged", [aCalendar, aName, aValue, aOldValue]);
+ }
+
+ onPropertyDeleting(aCalendar, aName) {
+ this.#calendar.observers.notify("onPropertyDeleting", [aCalendar, aName]);
+ }
+}
+
+/*
+ * Transport Abstraction Hooks
+ *
+ * These hooks provide a way to do checks before or after publishing an
+ * ICS file. The main use will be to check etags (or some other way to check
+ * for remote changes) to protect remote changes from being overwritten.
+ *
+ * Different protocols need different checks (webdav can do etag, but
+ * local files need last-modified stamps), hence different hooks for each
+ * types
+ */
+
+// dummyHooks are for transport types that don't have hooks of their own.
+// Also serves as poor-mans interface definition.
+class dummyHooks {
+ onBeforeGet(aChannel, aForceRefresh) {
+ return true;
+ }
+
+ /**
+ * @returns {boolean} false if the previous data should be used (the datastore
+ * didn't change, there might be no data in this GET), true
+ * in all other cases
+ */
+ onAfterGet(aChannel) {
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ aRespFunc();
+ return true;
+ }
+}
+
+class httpHooks {
+ #calendar = null;
+ #etag = null;
+ #lastModified = null;
+
+ constructor(calendar) {
+ this.#calendar = calendar;
+ }
+
+ onBeforeGet(aChannel, aForceRefresh) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ httpchannel.setRequestHeader("Accept", "text/calendar,text/plain;q=0.8,*/*;q=0.5", false);
+
+ if (this.#etag && !aForceRefresh) {
+ // Somehow the webdav header 'If' doesn't work on apache when
+ // passing in a Not, so use the http version here.
+ httpchannel.setRequestHeader("If-None-Match", this.#etag, false);
+ } else if (!aForceRefresh && this.#lastModified) {
+ // Only send 'If-Modified-Since' if no ETag is available
+ httpchannel.setRequestHeader("If-Modified-Since", this.#lastModified, false);
+ }
+
+ return true;
+ }
+
+ onAfterGet(aChannel) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ let responseStatus = 0;
+ let responseStatusCategory = 0;
+
+ try {
+ responseStatus = httpchannel.responseStatus;
+ responseStatusCategory = Math.floor(responseStatus / 100);
+ } catch (e) {
+ // Error might have been a temporary connection issue, keep old data to
+ // prevent potential data loss if it becomes available again.
+ cal.LOG("[calICSCalendar] Unable to get response status.");
+ return false;
+ }
+
+ if (responseStatus == 304) {
+ // 304: Not Modified
+ // Can use the old data, so tell the caller that it can skip parsing.
+ cal.LOG("[calICSCalendar] Response status 304: Not Modified. Using the existing data.");
+ return false;
+ } else if (responseStatus == 404) {
+ // 404: Not Found
+ // This is a new calendar. Shouldn't try to parse it. But it also
+ // isn't a failure, so don't throw.
+ cal.LOG("[calICSCalendar] Response status 404: Not Found. This is a new calendar.");
+ return false;
+ } else if (responseStatus == 410) {
+ cal.LOG("[calICSCalendar] Response status 410, calendar is gone. Disabling the calendar.");
+ this.#calendar.setProperty("disabled", "true");
+ return false;
+ } else if (responseStatusCategory == 4 || responseStatusCategory == 5) {
+ cal.LOG(
+ "[calICSCalendar] Response status " +
+ responseStatus +
+ ", temporarily disabling calendar for safety."
+ );
+ this.#calendar.setProperty("disabled", "true");
+ this.#calendar.setProperty("auto-enabled", "true");
+ return false;
+ }
+
+ try {
+ this.#etag = httpchannel.getResponseHeader("ETag");
+ } catch (e) {
+ // No etag header. Now what?
+ this.#etag = null;
+ }
+
+ try {
+ this.#lastModified = httpchannel.getResponseHeader("Last-Modified");
+ } catch (e) {
+ this.#lastModified = null;
+ }
+
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ if (this.#etag) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+
+ // Apache doesn't work correctly with if-match on a PUT method,
+ // so use the webdav header
+ httpchannel.setRequestHeader("If", "([" + this.#etag + "])", false);
+ }
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ let httpchannel = aChannel.QueryInterface(Ci.nsIHttpChannel);
+ try {
+ this.#etag = httpchannel.getResponseHeader("ETag");
+ aRespFunc();
+ } catch (e) {
+ // There was no ETag header on the response. This means that
+ // putting is not atomic. This is bad. Race conditions can happen,
+ // because there is a time in which we don't know the right
+ // etag.
+ // Try to do the best we can, by immediately getting the etag.
+ let etagListener = {};
+ let self = this; // need to reference in callback
+
+ etagListener.onStreamComplete = function (
+ aLoader,
+ aContext,
+ aStatus,
+ aResultLength,
+ aResult
+ ) {
+ let multistatus;
+ try {
+ let str = new TextDecoder().decode(Uint8Array.from(aResult));
+ multistatus = cal.xml.parseString(str);
+ } catch (ex) {
+ cal.LOG("[calICSCalendar] Failed to fetch channel etag");
+ }
+
+ self.#etag = icsXPathFirst(
+ multistatus,
+ "/D:propfind/D:response/D:propstat/D:prop/D:getetag"
+ );
+ aRespFunc();
+ };
+ let queryXml = '<D:propfind xmlns:D="DAV:"><D:prop><D:getetag/></D:prop></D:propfind>';
+
+ let etagChannel = cal.provider.prepHttpChannel(
+ aChannel.URI,
+ queryXml,
+ "text/xml; charset=utf-8",
+ this
+ );
+ etagChannel.setRequestHeader("Depth", "0", false);
+ etagChannel.requestMethod = "PROPFIND";
+ let streamLoader = Cc["@mozilla.org/network/stream-loader;1"].createInstance(
+ Ci.nsIStreamLoader
+ );
+
+ cal.provider.sendHttpRequest(streamLoader, etagChannel, etagListener);
+ }
+ return true;
+ }
+
+ // nsIProgressEventSink
+ onProgress(aRequest, aProgress, aProgressMax) {}
+ onStatus(aRequest, aStatus, aStatusArg) {}
+
+ getInterface(aIid) {
+ if (aIid.equals(Ci.nsIProgressEventSink)) {
+ return this;
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ }
+}
+
+class fileHooks {
+ #lastModified = null;
+
+ onBeforeGet(aChannel, aForceRefresh) {
+ return true;
+ }
+
+ /**
+ * @returns {boolean} false if the previous data should be used (the datastore
+ * didn't change, there might be no data in this GET), true
+ * in all other cases
+ */
+ onAfterGet(aChannel) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ if (this.#lastModified && this.#lastModified == filechannel.file.lastModifiedTime) {
+ return false;
+ }
+ this.#lastModified = filechannel.file.lastModifiedTime;
+ return true;
+ }
+
+ onBeforePut(aChannel) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ if (this.#lastModified && this.#lastModified != filechannel.file.lastModifiedTime) {
+ return false;
+ }
+ return true;
+ }
+
+ onAfterPut(aChannel, aRespFunc) {
+ let filechannel = aChannel.QueryInterface(Ci.nsIFileChannel);
+ this.#lastModified = filechannel.file.lastModifiedTime;
+ aRespFunc();
+ return true;
+ }
+}
diff --git a/comm/calendar/providers/ics/CalICSProvider.jsm b/comm/calendar/providers/ics/CalICSProvider.jsm
new file mode 100644
index 0000000000..1c5df4efa0
--- /dev/null
+++ b/comm/calendar/providers/ics/CalICSProvider.jsm
@@ -0,0 +1,447 @@
+/* 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 EXPORTED_SYMBOLS = ["CalICSProvider"];
+
+var { setTimeout } = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalDavGenericRequest, CalDavPropfindRequest } = ChromeUtils.import(
+ "resource:///modules/caldav/CalDavRequest.jsm"
+);
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.provider.ics namespace.
+
+/**
+ * @implements {calICalendarProvider}
+ */
+var CalICSProvider = {
+ QueryInterface: ChromeUtils.generateQI(["calICalendarProvider"]),
+
+ get type() {
+ return "ics";
+ },
+
+ get displayName() {
+ return cal.l10n.getCalString("icsName");
+ },
+
+ get shortName() {
+ return "ICS";
+ },
+
+ deleteCalendar(aCalendar, aListener) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ async detectCalendars(
+ username,
+ password,
+ location = null,
+ savePassword = false,
+ extraProperties = {}
+ ) {
+ let uri = cal.provider.detection.locationToUri(location);
+ if (!uri) {
+ throw new Error("Could not infer location from username");
+ }
+
+ let detector = new ICSDetector(username, password, savePassword);
+
+ // To support ics files hosted by simple HTTP server, attempt HEAD/GET
+ // before PROPFIND.
+ for (let method of [
+ "attemptHead",
+ "attemptGet",
+ "attemptDAVLocation",
+ "attemptPut",
+ "attemptLocalFile",
+ ]) {
+ try {
+ cal.LOG(`[CalICSProvider] Trying to detect calendar using ${method} method`);
+ let calendars = await detector[method](uri);
+ if (calendars) {
+ return calendars;
+ }
+ } catch (e) {
+ // e may be an Error object or a response object like CalDavSimpleResponse.
+ let message = `[CalICSProvider] Could not detect calendar using method ${method}`;
+
+ let errorDetails = err =>
+ ` - ${err.fileName || err.filename}:${err.lineNumber}: ${err} - ${err.stack}`;
+
+ let responseDetails = response => ` - HTTP response status ${response.status}`;
+
+ // We want to pass on any autodetect errors that will become results.
+ if (e instanceof cal.provider.detection.Error) {
+ cal.WARN(message + errorDetails(e));
+ throw e;
+ }
+
+ // Sometimes e is a CalDavResponseBase that is an auth error, so throw it.
+ if (e.authError) {
+ cal.WARN(message + responseDetails(e));
+ throw new cal.provider.detection.AuthFailedError();
+ }
+
+ if (e instanceof Error) {
+ cal.WARN(message + errorDetails(e));
+ } else if (typeof e.status == "number") {
+ cal.WARN(message + responseDetails(e));
+ } else {
+ cal.WARN(message);
+ }
+ }
+ }
+ return [];
+ },
+};
+
+/**
+ * Used by the CalICSProvider to detect ICS calendars for a given username,
+ * password, location, etc.
+ *
+ * @implements {nsIAuthPrompt2}
+ * @implements {nsIAuthPromptProvider}
+ * @implements {nsIInterfaceRequestor}
+ */
+class ICSDetectionSession {
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIAuthPrompt2,
+ Ci.nsIAuthPromptProvider,
+ Ci.nsIInterfaceRequestor,
+ ]);
+
+ isDetectionSession = true;
+
+ /**
+ * Create a new ICS detection session.
+ *
+ * @param {string} aSessionId - The session id, used in the password manager.
+ * @param {string} aName - The user-readable description of this session.
+ * @param {string} aPassword - The password for the session.
+ * @param {boolean} aSavePassword - Whether to save the password.
+ */
+ constructor(aSessionId, aUserName, aPassword, aSavePassword) {
+ this.id = aSessionId;
+ this.name = aUserName;
+ this.password = aPassword;
+ this.savePassword = aSavePassword;
+ }
+
+ /**
+ * Implement nsIInterfaceRequestor.
+ *
+ * @param {nsIIDRef} aIID - The IID of the interface being requested.
+ * @returns {ICSAutodetectSession | null} Either this object QI'd to the IID, or null.
+ * Components.returnCode is set accordingly.
+ * @see {nsIInterfaceRequestor}
+ */
+ getInterface(aIID) {
+ try {
+ // Try to query the this object for the requested interface but don't
+ // throw if it fails since that borks the network code.
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ Components.returnCode = e;
+ }
+ return null;
+ }
+
+ /**
+ * @see {nsIAuthPromptProvider}
+ */
+ getAuthPrompt(aReason, aIID) {
+ try {
+ return this.QueryInterface(aIID);
+ } catch (e) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+ }
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) {
+ setTimeout(() => {
+ if (this.promptAuth(aChannel, aLevel, aAuthInfo)) {
+ aCallback.onAuthAvailable(aContext, aAuthInfo);
+ } else {
+ aCallback.onAuthCancelled(aContext, true);
+ }
+ }, 0);
+ }
+
+ /**
+ * @see {nsIAuthPrompt2}
+ */
+ promptAuth(aChannel, aLevel, aAuthInfo) {
+ if (!this.password) {
+ return false;
+ }
+
+ if ((aAuthInfo.flags & aAuthInfo.PREVIOUS_FAILED) == 0) {
+ aAuthInfo.username = this.name;
+ aAuthInfo.password = this.password;
+
+ if (this.savePassword) {
+ cal.auth.passwordManagerSave(
+ this.name,
+ this.password,
+ aChannel.URI.prePath,
+ aAuthInfo.realm
+ );
+ }
+ return true;
+ }
+
+ aAuthInfo.username = null;
+ aAuthInfo.password = null;
+ if (this.savePassword) {
+ cal.auth.passwordManagerRemove(this.name, aChannel.URI.prePath, aAuthInfo.realm);
+ }
+ return false;
+ }
+
+ /** @see {CalDavSession} */
+ async prepareRequest(aChannel) {}
+ async prepareRedirect(aOldChannel, aNewChannel) {}
+ async completeRequest(aResponse) {}
+}
+
+/**
+ * Used by the CalICSProvider to detect ICS calendars for a given location,
+ * username, password, etc. The protocol for detecting ICS calendars is DAV
+ * (pure DAV, not CalDAV), but we use some of the CalDAV code here because the
+ * code is not currently organized to handle pure DAV and CalDAV separately
+ * (e.g. CalDavGenericRequest, CalDavPropfindRequest).
+ */
+class ICSDetector {
+ /**
+ * Create a new ICS detector.
+ *
+ * @param {string} username - A username.
+ * @param {string} password - A password.
+ * @param {boolean} savePassword - Whether to save the password or not.
+ */
+ constructor(username, password, savePassword) {
+ this.session = new ICSDetectionSession(cal.getUUID(), username, password, savePassword);
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using CalDAV PROPFIND.
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async attemptDAVLocation(location) {
+ let props = ["D:getcontenttype", "D:resourcetype", "D:displayname", "A:calendar-color"];
+ let request = new CalDavPropfindRequest(this.session, null, location, props);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.authError) {
+ throw new cal.provider.detection.AuthFailedError();
+ } else if (!response.ok) {
+ cal.LOG(`[calICSProvider] ${target.spec} did not respond properly to PROPFIND`);
+ return null;
+ }
+
+ let resprops = response.firstProps;
+ let resourceType = resprops["D:resourcetype"] || new Set();
+
+ if (resourceType.has("C:calendar") || resprops["D:getcontenttype"] == "text/calendar") {
+ cal.LOG(`[calICSProvider] ${target.spec} is a calendar`);
+ return [this.handleCalendar(target, resprops)];
+ } else if (resourceType.has("D:collection")) {
+ return this.handleDirectory(target);
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using a CalDAV generic
+ * request and a method like "HEAD" or "GET".
+ *
+ * @param {string} method - The request method to use, e.g. "GET" or "HEAD".
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async _attemptMethod(method, location) {
+ let request = new CalDavGenericRequest(this.session, null, method, location, {
+ Accept: "text/calendar, application/ics, text/plain;q=0.9",
+ });
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+
+ // The content type header may include a charset, so use 'string.includes'.
+ if (response.ok) {
+ let header = response.getHeader("Content-Type");
+
+ if (
+ header.includes("text/calendar") ||
+ header.includes("application/ics") ||
+ (response.text && response.text.includes("BEGIN:VCALENDAR"))
+ ) {
+ let target = response.uri;
+ cal.LOG(`[calICSProvider] ${target.spec} has valid content type (via ${method} request)`);
+ return [this.handleCalendar(target)];
+ }
+ }
+ return null;
+ }
+
+ get attemptHead() {
+ return this._attemptMethod.bind(this, "HEAD");
+ }
+
+ get attemptGet() {
+ return this._attemptMethod.bind(this, "GET");
+ }
+
+ /**
+ * Attempt to detect calendars at the given location using a CalDAV generic
+ * request and "PUT".
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async attemptPut(location) {
+ let request = new CalDavGenericRequest(
+ this.session,
+ null,
+ "PUT",
+ location,
+ { "If-Match": "nothing" },
+ "",
+ "text/plain"
+ );
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ if (response.conflict) {
+ // The etag didn't match, which means we can generally write here but our crafted etag
+ // is stopping us. This means we can assume there is a calendar at the location.
+ cal.LOG(
+ `[calICSProvider] ${target.spec} responded to a dummy ETag request, we can` +
+ " assume it is a valid calendar location"
+ );
+ return [this.handleCalendar(target)];
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempt to detect a calendar for a file URI (`file:///path/to/file.ics`).
+ * If a directory in the path does not exist return null. Whether the file
+ * exists or not, return a calendar for the location (the file will be
+ * created if it does not exist).
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {calICalendar[] | null} An array containing a calendar or null.
+ */
+ async attemptLocalFile(location) {
+ if (location.schemeIs("file")) {
+ let fullPath = location.QueryInterface(Ci.nsIFileURL).file.path;
+ let pathToDir = PathUtils.parent(fullPath);
+ let dirExists = await IOUtils.exists(pathToDir);
+
+ if (dirExists || pathToDir == "") {
+ let calendar = this.handleCalendar(location);
+ if (calendar) {
+ // Check whether we have write permission on the calendar file.
+ // Calling stat on a non-existent file is an error so we check for
+ // it's existence first.
+ let { permissions } = (await IOUtils.exists(fullPath))
+ ? await IOUtils.stat(fullPath)
+ : await IOUtils.stat(pathToDir);
+
+ calendar.readOnly = (permissions ^ 0o200) == 0;
+ return [calendar];
+ }
+ } else {
+ cal.LOG(`[calICSProvider] ${location.spec} includes a directory that does not exist`);
+ }
+ } else {
+ cal.LOG(`[calICSProvider] ${location.spec} is not a "file" URI`);
+ }
+ return null;
+ }
+
+ /**
+ * Utility function to make a new attempt to detect calendars after the
+ * previous PROPFIND results contained "D:resourcetype" with "D:collection".
+ *
+ * @param {nsIURI} location - The location to attempt.
+ * @returns {Promise<calICalendar[] | null>} An array of calendars or null.
+ */
+ async handleDirectory(location) {
+ let props = [
+ "D:getcontenttype",
+ "D:current-user-privilege-set",
+ "D:displayname",
+ "A:calendar-color",
+ ];
+ let request = new CalDavPropfindRequest(this.session, null, location, props, 1);
+
+ // `request.commit()` can throw; errors should be caught by calling functions.
+ let response = await request.commit();
+ let target = response.uri;
+
+ let calendars = [];
+ for (let [href, resprops] of Object.entries(response.data)) {
+ if (resprops["D:getcontenttype"] != "text/calendar") {
+ continue;
+ }
+
+ let uri = Services.io.newURI(href, null, target);
+ calendars.push(this.handleCalendar(uri, resprops));
+ }
+
+ cal.LOG(`[calICSProvider] ${target.spec} is a directory, found ${calendars.length} calendars`);
+
+ return calendars.length ? calendars : null;
+ }
+
+ /**
+ * Set up and return a new ICS calendar object.
+ *
+ * @param {nsIURI} uri - The location of the calendar.
+ * @param {Set} [props] - For CalDav calendars, these are the props
+ * parsed from the response.
+ * @returns {calICalendar} A new calendar.
+ */
+ handleCalendar(uri, props = new Set()) {
+ let displayName = props["D:displayname"];
+ let color = props["A:calendar-color"];
+ if (!displayName) {
+ let lastPath = uri.filePath.split("/").filter(Boolean).pop() || "";
+ let fileName = lastPath.split(".").slice(0, -1).join(".");
+ displayName = fileName || lastPath || uri.spec;
+ }
+
+ let calendar = cal.manager.createCalendar("ics", uri);
+ calendar.setProperty("color", color || cal.view.hashColor(uri.spec));
+ calendar.name = displayName;
+ calendar.id = cal.getUUID();
+
+ // Attempt to discover if the user is allowed to write to this calendar.
+ let privs = props["D:current-user-privilege-set"];
+ if (privs && privs instanceof Set) {
+ calendar.readOnly = !["D:write", "D:write-content", "D:write-properties", "D:all"].some(
+ priv => privs.has(priv)
+ );
+ }
+
+ return calendar;
+ }
+}
diff --git a/comm/calendar/providers/ics/components.conf b/comm/calendar/providers/ics/components.conf
new file mode 100644
index 0000000000..fd05b7f7f6
--- /dev/null
+++ b/comm/calendar/providers/ics/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/
+
+Classes = [
+ {
+ 'cid': '{f8438bff-a3c9-4ed5-b23f-2663b5469abf}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=ics'],
+ 'esModule': 'resource:///modules/CalICSCalendar.sys.mjs',
+ 'constructor': 'CalICSCalendar',
+ },
+]
diff --git a/comm/calendar/providers/ics/moz.build b/comm/calendar/providers/ics/moz.build
new file mode 100644
index 0000000000..6ec4226df7
--- /dev/null
+++ b/comm/calendar/providers/ics/moz.build
@@ -0,0 +1,16 @@
+# 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 += [
+ "CalICSCalendar.sys.mjs",
+ "CalICSProvider.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: ICS/WebDAV")
diff --git a/comm/calendar/providers/memory/CalMemoryCalendar.jsm b/comm/calendar/providers/memory/CalMemoryCalendar.jsm
new file mode 100644
index 0000000000..cd810285d8
--- /dev/null
+++ b/comm/calendar/providers/memory/CalMemoryCalendar.jsm
@@ -0,0 +1,538 @@
+/* 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 EXPORTED_SYMBOLS = ["CalMemoryCalendar"];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+var { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+
+var cICL = Ci.calIChangeLog;
+
+function CalMemoryCalendar() {
+ this.initProviderBase();
+ this.initMemoryCalendar();
+}
+var calMemoryCalendarClassID = Components.ID("{bda0dd7f-0a2f-4fcf-ba08-5517e6fbf133}");
+var calMemoryCalendarInterfaces = [
+ "calICalendar",
+ "calISchedulingSupport",
+ "calIOfflineStorage",
+ "calISyncWriteCalendar",
+ "calICalendarProvider",
+];
+CalMemoryCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+ classID: calMemoryCalendarClassID,
+ QueryInterface: cal.generateQI(calMemoryCalendarInterfaces),
+ classInfo: cal.generateCI({
+ classID: calMemoryCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=memory",
+ classDescription: "Calendar Memory Provider",
+ interfaces: calMemoryCalendarInterfaces,
+ }),
+
+ mItems: null,
+ mOfflineFlags: null,
+ mObservers: null,
+ mMetaData: null,
+
+ initMemoryCalendar() {
+ this.mObservers = new cal.data.ObserverSet(Ci.calIObserver);
+ this.mItems = {};
+ this.mOfflineFlags = {};
+ this.mMetaData = new Map();
+ },
+
+ //
+ // calICalendarProvider interface
+ //
+
+ get displayName() {
+ return cal.l10n.getCalString("memoryName");
+ },
+
+ get shortName() {
+ return this.displayName;
+ },
+
+ deleteCalendar(calendar, listener) {
+ calendar = calendar.wrappedJSObject;
+ calendar.mItems = {};
+ calendar.mMetaData = new Map();
+
+ try {
+ listener.onDeleteCalendar(calendar, Cr.NS_OK, null);
+ } catch (ex) {
+ // Don't bail out if the listener fails
+ }
+ },
+
+ detectCalendars() {
+ throw Components.Exception(
+ "CalMemoryCalendar does not implement detectCalendars",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+
+ mRelaxedMode: undefined,
+ get relaxedMode() {
+ if (this.mRelaxedMode === undefined) {
+ this.mRelaxedMode = this.getProperty("relaxedMode");
+ }
+ return this.mRelaxedMode;
+ },
+
+ //
+ // calICalendar interface
+ //
+
+ getProperty(aName) {
+ switch (aName) {
+ case "cache.supported":
+ case "requiresNetwork":
+ return false;
+ case "capabilities.priority.supported":
+ return true;
+ case "removemodes":
+ return ["delete"];
+ }
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ },
+
+ get supportsScheduling() {
+ return true;
+ },
+
+ getSchedulingSupport() {
+ return this;
+ },
+
+ // readonly attribute AUTF8String type;
+ get type() {
+ return "memory";
+ },
+
+ // Promise<calIItemBase> addItem(in calIItemBase aItem);
+ async addItem(aItem) {
+ let newItem = aItem.clone();
+ return this.adoptItem(newItem);
+ },
+
+ // Promise<calIItemBase> adoptItem(in calIItemBase aItem);
+ async adoptItem(aItem) {
+ if (this.readOnly) {
+ throw Ci.calIErrors.CAL_IS_READONLY;
+ }
+ if (aItem.id == null && aItem.isMutable) {
+ aItem.id = cal.getUUID();
+ }
+
+ if (aItem.id == null) {
+ this.notifyOperationComplete(
+ null,
+ Cr.NS_ERROR_FAILURE,
+ Ci.calIOperationListener.ADD,
+ aItem.id,
+ "Can't set ID on non-mutable item to addItem"
+ );
+ return Promise.reject(
+ new Components.Exception("Can't set ID on non-mutable item to addItem", Cr.NS_ERROR_FAILURE)
+ );
+ }
+
+ // Lines below are commented because of the offline bug 380060, the
+ // memory calendar cannot assume that a new item should not have an ID.
+ // calCachedCalendar could send over an item with an id.
+
+ /*
+ if (this.mItems[aItem.id] != null) {
+ if (this.relaxedMode) {
+ // we possibly want to interact with the user before deleting
+ delete this.mItems[aItem.id];
+ } else {
+ this.notifyOperationComplete(aListener,
+ Ci.calIErrors.DUPLICATE_ID,
+ Ci.calIOperationListener.ADD,
+ aItem.id,
+ "ID already exists for addItem");
+ return;
+ }
+ }
+ */
+
+ let parentItem = aItem.parentItem;
+ if (parentItem != aItem) {
+ parentItem = parentItem.clone();
+ parentItem.recurrenceInfo.modifyException(aItem, true);
+ }
+ parentItem.calendar = this.superCalendar;
+
+ parentItem.makeImmutable();
+ this.mItems[aItem.id] = parentItem;
+
+ // notify observers
+ this.mObservers.notify("onAddItem", [aItem]);
+
+ return aItem;
+ },
+
+ // Promise<calIItemBase> modifyItem(in calIItemBase aNewItem, in calIItemBase aOldItem)
+ async modifyItem(aNewItem, aOldItem) {
+ if (this.readOnly) {
+ throw Ci.calIErrors.CAL_IS_READONLY;
+ }
+ if (!aNewItem) {
+ throw Components.Exception("aNewItem must be set", Cr.NS_ERROR_INVALID_ARG);
+ }
+
+ let reportError = (errStr, errId = Cr.NS_ERROR_FAILURE) => {
+ this.notifyOperationComplete(
+ null,
+ errId,
+ Ci.calIOperationListener.MODIFY,
+ aNewItem.id,
+ errStr
+ );
+ return Promise.reject(new Components.Exception(errStr, errId));
+ };
+
+ if (!aNewItem.id) {
+ // this is definitely an error
+ return reportError("ID for modifyItem item is null");
+ }
+
+ let modifiedItem = aNewItem.parentItem.clone();
+ if (aNewItem.parentItem != aNewItem) {
+ modifiedItem.recurrenceInfo.modifyException(aNewItem, false);
+ }
+
+ // If no old item was passed, then we should overwrite in any case.
+ // Pick up the old item from our items array and use this as an old item
+ // later on.
+ if (!aOldItem) {
+ aOldItem = this.mItems[aNewItem.id];
+ }
+
+ if (this.relaxedMode) {
+ // We've already filled in the old item above, if this doesn't exist
+ // then just take the current item as its old version
+ if (!aOldItem) {
+ aOldItem = modifiedItem;
+ }
+ aOldItem = aOldItem.parentItem;
+ } else if (!this.relaxedMode) {
+ if (!aOldItem || !this.mItems[aNewItem.id]) {
+ // no old item found? should be using addItem, then.
+ return reportError(
+ "ID for modifyItem doesn't exist, is null, or is from different calendar"
+ );
+ }
+
+ // do the old and new items match?
+ if (aOldItem.id != modifiedItem.id) {
+ return reportError("item ID mismatch between old and new items");
+ }
+
+ aOldItem = aOldItem.parentItem;
+ let storedOldItem = this.mItems[aOldItem.id];
+
+ // compareItems is not suitable here. See bug 418805.
+ // Cannot compare here due to bug 380060
+ if (!cal.item.compareContent(storedOldItem, aOldItem)) {
+ return reportError(
+ "old item mismatch in modifyItem. storedId:" +
+ storedOldItem.icalComponent +
+ " old item:" +
+ aOldItem.icalComponent
+ );
+ }
+ // offline bug
+
+ if (aOldItem.generation != storedOldItem.generation) {
+ return reportError("generation mismatch in modifyItem");
+ }
+
+ if (aOldItem.generation == modifiedItem.generation) {
+ // has been cloned and modified
+ // Only take care of incrementing the generation if relaxed mode is
+ // off. Users of relaxed mode need to take care of this themselves.
+ modifiedItem.generation += 1;
+ }
+ }
+
+ modifiedItem.makeImmutable();
+ this.mItems[modifiedItem.id] = modifiedItem;
+
+ this.notifyOperationComplete(
+ null,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ modifiedItem.id,
+ modifiedItem
+ );
+
+ // notify observers
+ this.mObservers.notify("onModifyItem", [modifiedItem, aOldItem]);
+ return modifiedItem;
+ },
+
+ // Promise<void> deleteItem(in calIItemBase item);
+ async deleteItem(item) {
+ let onError = async (message, exception) => {
+ this.notifyOperationComplete(
+ null,
+ exception,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ message
+ );
+ return Promise.reject(new Components.Exception(message, exception));
+ };
+
+ if (this.readOnly) {
+ return onError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ if (item.id == null) {
+ return onError("ID is null in deleteItem", Cr.NS_ERROR_FAILURE);
+ }
+
+ let oldItem;
+ if (this.relaxedMode) {
+ oldItem = item;
+ } else {
+ oldItem = this.mItems[item.id];
+ if (oldItem.generation != item.generation) {
+ return onError("generation mismatch in deleteItem", Cr.NS_ERROR_FAILURE);
+ }
+ }
+
+ delete this.mItems[item.id];
+ this.mMetaData.delete(item.id);
+
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.DELETE, item.id, item);
+ // notify observers
+ this.mObservers.notify("onDeleteItem", [oldItem]);
+ return null;
+ },
+
+ // Promise<calIItemBase|null> getItem(in string id);
+ async getItem(aId) {
+ return this.mItems[aId] || null;
+ },
+
+ // ReadableStream<calIItemBase> getItems(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd)
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ const calICalendar = Ci.calICalendar;
+
+ let itemsFound = [];
+
+ //
+ // filters
+ //
+
+ let wantUnrespondedInvitations =
+ (itemFilter & calICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION) != 0;
+ let superCal;
+ try {
+ superCal = this.superCalendar.QueryInterface(Ci.calISchedulingSupport);
+ } catch (exc) {
+ wantUnrespondedInvitations = false;
+ }
+ function checkUnrespondedInvitation(item) {
+ let att = superCal.getInvitedAttendee(item);
+ return att && att.participationStatus == "NEEDS-ACTION";
+ }
+
+ // item base type
+ let wantEvents = (itemFilter & calICalendar.ITEM_FILTER_TYPE_EVENT) != 0;
+ let wantTodos = (itemFilter & calICalendar.ITEM_FILTER_TYPE_TODO) != 0;
+ if (!wantEvents && !wantTodos) {
+ // bail.
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ // completed?
+ let itemCompletedFilter = (itemFilter & calICalendar.ITEM_FILTER_COMPLETED_YES) != 0;
+ let itemNotCompletedFilter = (itemFilter & calICalendar.ITEM_FILTER_COMPLETED_NO) != 0;
+ function checkCompleted(item) {
+ item.QueryInterface(Ci.calITodo);
+ return item.isCompleted ? itemCompletedFilter : itemNotCompletedFilter;
+ }
+
+ // return occurrences?
+ let itemReturnOccurrences = (itemFilter & calICalendar.ITEM_FILTER_CLASS_OCCURRENCES) != 0;
+
+ rangeStart = cal.dtz.ensureDateTime(rangeStart);
+ rangeEnd = cal.dtz.ensureDateTime(rangeEnd);
+ let startTime = -9223372036854775000;
+ if (rangeStart) {
+ startTime = rangeStart.nativeTime;
+ }
+
+ let requestedFlag = 0;
+ if ((itemFilter & calICalendar.ITEM_FILTER_OFFLINE_CREATED) != 0) {
+ requestedFlag = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ } else if ((itemFilter & calICalendar.ITEM_FILTER_OFFLINE_MODIFIED) != 0) {
+ requestedFlag = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ } else if ((itemFilter & calICalendar.ITEM_FILTER_OFFLINE_DELETED) != 0) {
+ requestedFlag = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ }
+
+ let matchOffline = function (itemFlag, reqFlag) {
+ // Same as storage calendar sql query. For comparison:
+ // reqFlag is :offline_journal (parameter),
+ // itemFlag is offline_journal (field value)
+ // ...
+ // AND (:offline_journal IS NULL
+ // AND (offline_journal IS NULL
+ // OR offline_journal != ${cICL.OFFLINE_FLAG_DELETED_RECORD}))
+ // OR offline_journal == :offline_journal
+
+ return (
+ (!reqFlag && (!itemFlag || itemFlag != cICL.OFFLINE_FLAG_DELETED_RECORD)) ||
+ itemFlag == reqFlag
+ );
+ };
+
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ return new Promise(resolve => {
+ cal.iterate.forEach(
+ self.mItems,
+ ([id, item]) => {
+ let isEvent_ = item.isEvent();
+ if (isEvent_) {
+ if (!wantEvents) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+ } else if (!wantTodos) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+
+ let hasItemFlag = item.id in self.mOfflineFlags;
+ let itemFlag = hasItemFlag ? self.mOfflineFlags[item.id] : 0;
+
+ // If the offline flag doesn't match, skip the item
+ if (!matchOffline(itemFlag, requestedFlag)) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+
+ if (itemReturnOccurrences && item.recurrenceInfo) {
+ if (item.recurrenceInfo.recurrenceEndDate < startTime) {
+ return cal.iterate.forEach.CONTINUE;
+ }
+
+ let startDate = rangeStart;
+ if (!rangeStart && item.isTodo()) {
+ startDate = item.entryDate;
+ }
+ let occurrences = item.recurrenceInfo.getOccurrences(
+ startDate,
+ rangeEnd,
+ count ? count - itemsFound.length : 0
+ );
+ if (wantUnrespondedInvitations) {
+ occurrences = occurrences.filter(checkUnrespondedInvitation);
+ }
+ if (!isEvent_) {
+ occurrences = occurrences.filter(checkCompleted);
+ }
+ itemsFound = itemsFound.concat(occurrences);
+ } else if (
+ (!wantUnrespondedInvitations || checkUnrespondedInvitation(item)) &&
+ (isEvent_ || checkCompleted(item)) &&
+ cal.item.checkIfInRange(item, rangeStart, rangeEnd)
+ ) {
+ // This needs fixing for recurring items, e.g. DTSTART of parent may occur before rangeStart.
+ // This will be changed with bug 416975.
+ itemsFound.push(item);
+ }
+ if (controller.maxTotalItemsReached) {
+ return cal.iterate.forEach.BREAK;
+ }
+ return cal.iterate.forEach.CONTINUE;
+ },
+ () => {
+ controller.enqueue(itemsFound);
+ controller.close();
+ resolve();
+ }
+ );
+ });
+ },
+ }
+ );
+ },
+
+ //
+ // calIOfflineStorage interface
+ //
+ async addOfflineItem(aItem) {
+ this.mOfflineFlags[aItem.id] = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ },
+
+ async modifyOfflineItem(aItem) {
+ let oldFlag = this.mOfflineFlags[aItem.id];
+ if (
+ oldFlag != cICL.OFFLINE_FLAG_CREATED_RECORD &&
+ oldFlag != cICL.OFFLINE_FLAG_DELETED_RECORD
+ ) {
+ this.mOfflineFlags[aItem.id] = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.MODIFY, aItem.id, aItem);
+ return aItem;
+ },
+
+ async deleteOfflineItem(aItem) {
+ let oldFlag = this.mOfflineFlags[aItem.id];
+ if (oldFlag == cICL.OFFLINE_FLAG_CREATED_RECORD) {
+ delete this.mItems[aItem.id];
+ delete this.mOfflineFlags[aItem.id];
+ } else {
+ this.mOfflineFlags[aItem.id] = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ }
+
+ // notify observers
+ this.observers.notify("onDeleteItem", [aItem]);
+ },
+
+ async getItemOfflineFlag(aItem) {
+ return aItem && aItem.id in this.mOfflineFlags ? this.mOfflineFlags[aItem.id] : null;
+ },
+
+ async resetItemOfflineFlag(aItem) {
+ delete this.mOfflineFlags[aItem.id];
+ },
+
+ //
+ // calISyncWriteCalendar interface
+ //
+ setMetaData(id, value) {
+ this.mMetaData.set(id, value);
+ },
+ deleteMetaData(id) {
+ this.mMetaData.delete(id);
+ },
+ getMetaData(id) {
+ return this.mMetaData.get(id);
+ },
+ getAllMetaDataIds() {
+ return [...this.mMetaData.keys()];
+ },
+ getAllMetaDataValues() {
+ return [...this.mMetaData.values()];
+ },
+};
diff --git a/comm/calendar/providers/memory/components.conf b/comm/calendar/providers/memory/components.conf
new file mode 100644
index 0000000000..a898b8ed8b
--- /dev/null
+++ b/comm/calendar/providers/memory/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/
+
+Classes = [
+ {
+ 'cid': '{bda0dd7f-0a2f-4fcf-ba08-5517e6fbf133}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=memory'],
+ 'jsm': 'resource:///modules/CalMemoryCalendar.jsm',
+ 'constructor': 'CalMemoryCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/memory/moz.build b/comm/calendar/providers/memory/moz.build
new file mode 100644
index 0000000000..c7a6d9ff31
--- /dev/null
+++ b/comm/calendar/providers/memory/moz.build
@@ -0,0 +1,12 @@
+# 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 += [
+ "CalMemoryCalendar.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
diff --git a/comm/calendar/providers/moz.build b/comm/calendar/providers/moz.build
new file mode 100644
index 0000000000..958fc25a8e
--- /dev/null
+++ b/comm/calendar/providers/moz.build
@@ -0,0 +1,12 @@
+# 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/.
+
+DIRS += [
+ "caldav",
+ "composite",
+ "ics",
+ "memory",
+ "storage",
+]
diff --git a/comm/calendar/providers/storage/CalStorageCachedItemModel.jsm b/comm/calendar/providers/storage/CalStorageCachedItemModel.jsm
new file mode 100644
index 0000000000..80a367f2af
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageCachedItemModel.jsm
@@ -0,0 +1,219 @@
+/* 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 = ["CalStorageCachedItemModel"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+const { CalStorageItemModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageItemModel.jsm"
+);
+
+/**
+ * CalStorageCachedItemModel extends CalStorageItemModel to add caching support
+ * for items. Most of the methods here are overridden from the parent class to
+ * either add or retrieve items from the cache.
+ */
+class CalStorageCachedItemModel extends CalStorageItemModel {
+ /**
+ * Cache for all items.
+ *
+ * @type {Map<string, calIItemBase>}
+ */
+ itemCache = new Map();
+
+ /**
+ * Cache for recurring events.
+ *
+ * @type {Map<string, calIEvent>}
+ */
+ #recurringEventsCache = new Map();
+
+ /**
+ * Cache for recurring events offline flags.
+ *
+ * @type {Map<string, number>}
+ */
+ #recurringEventsOfflineFlagCache = new Map();
+
+ /**
+ * Cache for recurring todos.
+ *
+ * @type {Map<string, calITodo>}
+ */
+ #recurringTodosCache = new Map();
+
+ /**
+ * Cache for recurring todo offline flags.
+ *
+ * @type {Map<string, number>}
+ */
+ #recurringTodosOfflineCache = new Map();
+
+ /**
+ * Promise that resolves when the caches have been built up.
+ *
+ * @type {Promise<void>}
+ */
+ #recurringCachePromise = null;
+
+ /**
+ * Build up recurring event and todo cache with its offline flags.
+ */
+ async #ensureRecurringItemCaches() {
+ if (!this.#recurringCachePromise) {
+ this.#recurringCachePromise = this.#buildRecurringItemCaches();
+ }
+ return this.#recurringCachePromise;
+ }
+
+ async #buildRecurringItemCaches() {
+ // Retrieve items and flags for recurring events and todos before combining
+ // storing them in the item cache. Items need to be expunged from the
+ // existing item cache to avoid get(Event|Todo)FromRow providing stale
+ // values.
+ let expunge = id => this.itemCache.delete(id);
+ let [events, eventFlags] = await this.getRecurringEventAndFlagMaps(expunge);
+ let [todos, todoFlags] = await this.getRecurringTodoAndFlagMaps(expunge);
+ let itemsMap = await this.getAdditionalDataForItemMap(new Map([...events, ...todos]));
+
+ this.itemCache = new Map([...this.itemCache, ...itemsMap]);
+ this.#recurringEventsCache = new Map([...this.#recurringEventsCache, ...events]);
+ this.#recurringEventsOfflineFlagCache = new Map([
+ ...this.#recurringEventsOfflineFlagCache,
+ ...eventFlags,
+ ]);
+ this.#recurringTodosCache = new Map([...this.#recurringTodosCache, ...todos]);
+ this.#recurringTodosOfflineCache = new Map([...this.#recurringTodosOfflineCache, ...todoFlags]);
+ }
+
+ /**
+ * Overridden here to build the recurring item caches when needed.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calIItemBase>
+ */
+ getItems(query) {
+ let self = this;
+ let getStream = () => super.getItems(query);
+ return CalReadableStreamFactory.createReadableStream({
+ async start(controller) {
+ // HACK because recurring offline events/todos objects don't have offline_journal information
+ // Hence we need to update the offline flags caches.
+ // It can be an expensive operation but is only used in Online Reconciliation mode
+ if (
+ (query.filters.wantOfflineCreatedItems ||
+ query.filters.wantOfflineDeletedItems ||
+ query.filters.wantOfflineModifiedItems) &&
+ self.mRecItemCachePromise
+ ) {
+ // If there's an existing Promise and it's not complete, wait for it - something else is
+ // already waiting and we don't want to break that by throwing away the caches. If it IS
+ // complete, we'll continue immediately.
+ let recItemCachePromise = self.mRecItemCachePromise;
+ await recItemCachePromise;
+ await new Promise(resolve => ChromeUtils.idleDispatch(resolve));
+ // Check in case someone else already threw away the caches.
+ if (self.mRecItemCachePromise == recItemCachePromise) {
+ self.mRecItemCachePromise = null;
+ }
+ }
+ await self.#ensureRecurringItemCaches();
+
+ for await (let value of cal.iterate.streamValues(getStream())) {
+ controller.enqueue(value);
+ }
+ controller.close();
+ },
+ });
+ }
+
+ /**
+ * Overridden here to provide the events from the cache.
+ *
+ * @returns {[Map<string, calIEvent>, Map<string, number>]}
+ */
+ async getFullRecurringEventAndFlagMaps() {
+ return [this.#recurringEventsCache, this.#recurringEventsOfflineFlagCache];
+ }
+
+ /**
+ * Overridden here to provide the todos from the cache.
+ *
+ * @returns {[Map<string, calITodo>, Map<string, number>]}
+ */
+ async getFullRecurringTodoAndFlagMaps() {
+ return [this.#recurringTodosCache, this.#recurringTodosOfflineCache];
+ }
+
+ async getEventFromRow(row, getAdditionalData = true) {
+ let item = this.itemCache.get(row.getResultByName("id"));
+ if (item) {
+ return item;
+ }
+
+ item = await super.getEventFromRow(row, getAdditionalData);
+ if (getAdditionalData) {
+ this.#cacheItem(item);
+ }
+ return item;
+ }
+
+ async getTodoFromRow(row, getAdditionalData = true) {
+ let item = this.itemCache.get(row.getResultByName("id"));
+ if (item) {
+ return item;
+ }
+
+ item = await super.getTodoFromRow(row, getAdditionalData);
+ if (getAdditionalData) {
+ this.#cacheItem(item);
+ }
+ return item;
+ }
+
+ async addItem(item) {
+ await super.addItem(item);
+ this.#cacheItem(item);
+ }
+
+ async getItemById(id) {
+ await this.#ensureRecurringItemCaches();
+ let item = this.itemCache.get(id);
+ if (item) {
+ return item;
+ }
+ return super.getItemById(id);
+ }
+
+ async deleteItemById(id, keepMeta) {
+ await super.deleteItemById(id, keepMeta);
+ this.itemCache.delete(id);
+ this.#recurringEventsCache.delete(id);
+ this.#recurringTodosCache.delete(id);
+ }
+
+ /**
+ * Adds an item to the relevant caches.
+ *
+ * @param {calIItemBase} item
+ */
+ #cacheItem(item) {
+ if (item.recurrenceId) {
+ // Do not cache recurring item instances. See bug 1686466.
+ return;
+ }
+ this.itemCache.set(item.id, item);
+ if (item.recurrenceInfo) {
+ if (item.isEvent()) {
+ this.#recurringEventsCache.set(item.id, item);
+ } else {
+ this.#recurringTodosCache.set(item.id, item);
+ }
+ }
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageCalendar.jsm b/comm/calendar/providers/storage/CalStorageCalendar.jsm
new file mode 100644
index 0000000000..5d330986b6
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageCalendar.jsm
@@ -0,0 +1,563 @@
+/* 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 = ["CalStorageCalendar"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+const { CalStorageDatabase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageDatabase.jsm"
+);
+const { CalStorageModelFactory } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelFactory.jsm"
+);
+const { CalStorageStatements } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageStatements.jsm"
+);
+const { upgradeDB } = ChromeUtils.import("resource:///modules/calendar/calStorageUpgrade.jsm");
+
+const kCalICalendar = Ci.calICalendar;
+const cICL = Ci.calIChangeLog;
+
+function CalStorageCalendar() {
+ this.initProviderBase();
+}
+var calStorageCalendarClassID = Components.ID("{b3eaa1c4-5dfe-4c0a-b62a-b3a514218461}");
+var calStorageCalendarInterfaces = [
+ "calICalendar",
+ "calICalendarProvider",
+ "calIOfflineStorage",
+ "calISchedulingSupport",
+ "calISyncWriteCalendar",
+];
+CalStorageCalendar.prototype = {
+ __proto__: cal.provider.BaseClass.prototype,
+ classID: calStorageCalendarClassID,
+ QueryInterface: cal.generateQI(calStorageCalendarInterfaces),
+ classInfo: cal.generateCI({
+ classID: calStorageCalendarClassID,
+ contractID: "@mozilla.org/calendar/calendar;1?type=storage",
+ classDescription: "Calendar Storage Provider",
+ interfaces: calStorageCalendarInterfaces,
+ }),
+
+ //
+ // private members
+ //
+ mStorageDb: null,
+ mItemModel: null,
+ mOfflineModel: null,
+ mMetaModel: null,
+
+ //
+ // calICalendarProvider interface
+ //
+
+ get displayName() {
+ return cal.l10n.getCalString("storageName");
+ },
+
+ get shortName() {
+ return "SQLite";
+ },
+
+ async deleteCalendar(aCalendar, listener) {
+ await this.mItemModel.deleteCalendar();
+ try {
+ if (listener) {
+ listener.onDeleteCalendar(aCalendar, Cr.NS_OK, null);
+ }
+ } catch (ex) {
+ this.mStorageDb.logError("error calling listener.onDeleteCalendar", ex);
+ }
+ },
+
+ detectCalendars() {
+ throw Components.Exception(
+ "calStorageCalendar does not implement detectCalendars",
+ Cr.NS_ERROR_NOT_IMPLEMENTED
+ );
+ },
+
+ mRelaxedMode: undefined,
+ get relaxedMode() {
+ if (this.mRelaxedMode === undefined) {
+ this.mRelaxedMode = this.getProperty("relaxedMode");
+ }
+ return this.mRelaxedMode;
+ },
+
+ //
+ // calICalendar interface
+ //
+
+ getProperty(aName) {
+ switch (aName) {
+ case "cache.supported":
+ return false;
+ case "requiresNetwork":
+ return false;
+ case "capabilities.priority.supported":
+ return true;
+ case "capabilities.removeModes":
+ return ["delete"];
+ }
+ return this.__proto__.__proto__.getProperty.apply(this, arguments);
+ },
+
+ get supportsScheduling() {
+ return true;
+ },
+
+ getSchedulingSupport() {
+ return this;
+ },
+
+ // readonly attribute AUTF8String type;
+ get type() {
+ return "storage";
+ },
+
+ // attribute AUTF8String id;
+ get id() {
+ return this.__proto__.__proto__.__lookupGetter__("id").call(this);
+ },
+ set id(val) {
+ this.__proto__.__proto__.__lookupSetter__("id").call(this, val);
+
+ if (!this.mStorageDb && this.uri && this.id) {
+ // Prepare the database as soon as we have an id and an uri.
+ this.prepareInitDB();
+ }
+ },
+
+ // attribute nsIURI uri;
+ get uri() {
+ return this.__proto__.__proto__.__lookupGetter__("uri").call(this);
+ },
+ set uri(aUri) {
+ // We can only load once
+ if (this.uri) {
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ this.__proto__.__proto__.__lookupSetter__("uri").call(this, aUri);
+
+ if (!this.mStorageDb && this.uri && this.id) {
+ // Prepare the database as soon as we have an id and an uri.
+ this.prepareInitDB();
+ }
+ },
+
+ // attribute mozIStorageAsyncConnection db;
+ get db() {
+ return this.mStorageDb.db;
+ },
+
+ /**
+ * Initialize the Database. This should generally only be called from the
+ * uri or id setter and requires those two attributes to be set. It may also
+ * be called again when the schema version of the database is newer than
+ * the version expected by this version of Thunderbird.
+ */
+ prepareInitDB() {
+ this.mStorageDb = CalStorageDatabase.connect(this.uri, this.id);
+ upgradeDB(this);
+ },
+
+ afterUpgradeDB() {
+ this.initDB();
+ Services.obs.addObserver(this, "profile-change-teardown");
+ },
+
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "profile-change-teardown") {
+ Services.obs.removeObserver(this, "profile-change-teardown");
+ // Finalize the storage statements, but don't close the database.
+ // CalStorageDatabase.jsm will take care of that while blocking profile-before-change.
+ this.mStatements?.finalize();
+ }
+ },
+
+ refresh() {
+ // no-op
+ },
+
+ // Promise<calIItemBase> addItem(in calIItemBase aItem);
+ async addItem(aItem) {
+ let newItem = aItem.clone();
+ return this.adoptItem(newItem);
+ },
+
+ // Promise<calIItemBase> adoptItem(in calIItemBase aItem);
+ async adoptItem(aItem) {
+ let onError = async (message, exception) => {
+ this.notifyOperationComplete(
+ null,
+ exception,
+ Ci.calIOperationListener.ADD,
+ aItem.id,
+ message
+ );
+ return Promise.reject(new Components.Exception(message, exception));
+ };
+
+ if (this.readOnly) {
+ return onError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ if (aItem.id == null) {
+ // is this an error? Or should we generate an IID?
+ aItem.id = cal.getUUID();
+ } else {
+ let olditem = await this.mItemModel.getItemById(aItem.id);
+ if (olditem) {
+ if (this.relaxedMode) {
+ // we possibly want to interact with the user before deleting
+ await this.mItemModel.deleteItemById(aItem.id, true);
+ } else {
+ return onError("ID already exists for addItem", Ci.calIErrors.DUPLICATE_ID);
+ }
+ }
+ }
+
+ let parentItem = aItem.parentItem;
+ if (parentItem != aItem) {
+ parentItem = parentItem.clone();
+ parentItem.recurrenceInfo.modifyException(aItem, true);
+ }
+ parentItem.calendar = this.superCalendar;
+ parentItem.makeImmutable();
+
+ await this.mItemModel.addItem(parentItem);
+
+ // notify observers
+ this.observers.notify("onAddItem", [aItem]);
+ return aItem;
+ },
+
+ // Promise<calIItemBase> modifyItem(in calIItemBase aNewItem, in calIItemBase aOldItem)
+ async modifyItem(aNewItem, aOldItem) {
+ // HACK Just modifying the item would clear the offline flag, we need to
+ // retrieve the flag and pass it to the real modify function.
+ let offlineFlag = await this.getItemOfflineFlag(aOldItem);
+ let oldOfflineFlag = offlineFlag;
+
+ let reportError = (errStr, errId = Cr.NS_ERROR_FAILURE) => {
+ this.notifyOperationComplete(
+ null,
+ errId,
+ Ci.calIOperationListener.MODIFY,
+ aNewItem.id,
+ errStr
+ );
+ return Promise.reject(new Components.Exception(errStr, errId));
+ };
+
+ if (this.readOnly) {
+ return reportError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+ if (!aNewItem) {
+ return reportError("A modified version of the item is required", Cr.NS_ERROR_INVALID_ARG);
+ }
+ if (aNewItem.id == null) {
+ // this is definitely an error
+ return reportError("ID for modifyItem item is null");
+ }
+
+ let modifiedItem = aNewItem.parentItem.clone();
+ if (this.getProperty("capabilities.propagate-sequence")) {
+ // Ensure the exception, its parent and the other exceptions have the
+ // same sequence number, to make sure we can send our changes to the
+ // server if the event has been updated via the blue bar
+ let newSequence = aNewItem.getProperty("SEQUENCE");
+ this._propagateSequence(modifiedItem, newSequence);
+ }
+
+ // Ensure that we're looking at the base item if we were given an
+ // occurrence. Later we can optimize this.
+ if (aNewItem.parentItem != aNewItem) {
+ modifiedItem.recurrenceInfo.modifyException(aNewItem, false);
+ }
+
+ // If no old item was passed, then we should overwrite in any case.
+ // Pick up the old item from the database and use this as an old item
+ // later on.
+ if (!aOldItem) {
+ aOldItem = await this.mItemModel.getItemById(aNewItem.id);
+ }
+
+ if (this.relaxedMode) {
+ // We've already filled in the old item above, if this doesn't exist
+ // then just take the current item as its old version
+ if (!aOldItem) {
+ aOldItem = aNewItem;
+ }
+ aOldItem = aOldItem.parentItem;
+ } else {
+ let storedOldItem = null;
+ if (aOldItem) {
+ storedOldItem = await this.mItemModel.getItemById(aOldItem.id);
+ }
+ if (!aOldItem || !storedOldItem) {
+ // no old item found? should be using addItem, then.
+ return reportError("ID does not already exist for modifyItem");
+ }
+ aOldItem = aOldItem.parentItem;
+
+ if (aOldItem.generation != storedOldItem.generation) {
+ return reportError("generation too old for for modifyItem");
+ }
+
+ // xxx todo: this only modified master item's generation properties
+ // I start asking myself why we need a separate X-MOZ-GENERATION.
+ // Just for the sake of checking inconsistencies of modifyItem calls?
+ if (aOldItem.generation == modifiedItem.generation) {
+ // has been cloned and modified
+ // Only take care of incrementing the generation if relaxed mode is
+ // off. Users of relaxed mode need to take care of this themselves.
+ modifiedItem.generation += 1;
+ }
+ }
+
+ modifiedItem.makeImmutable();
+ await this.mItemModel.updateItem(modifiedItem, aOldItem);
+ await this.mOfflineModel.setOfflineJournalFlag(aNewItem, oldOfflineFlag);
+
+ this.notifyOperationComplete(
+ null,
+ Cr.NS_OK,
+ Ci.calIOperationListener.MODIFY,
+ modifiedItem.id,
+ modifiedItem
+ );
+
+ // notify observers
+ this.observers.notify("onModifyItem", [modifiedItem, aOldItem]);
+ return modifiedItem;
+ },
+
+ // Promise<void> deleteItem(in calIItemBase item)
+ async deleteItem(item) {
+ let onError = async (message, exception) => {
+ this.notifyOperationComplete(
+ null,
+ exception,
+ Ci.calIOperationListener.DELETE,
+ item.id,
+ message
+ );
+ return Promise.reject(new Components.Exception(message, exception));
+ };
+
+ if (this.readOnly) {
+ return onError("Calendar is readonly", Ci.calIErrors.CAL_IS_READONLY);
+ }
+
+ if (item.parentItem != item) {
+ item.parentItem.recurrenceInfo.removeExceptionFor(item.recurrenceId);
+ // xxx todo: would we want to support this case? Removing an occurrence currently results
+ // in a modifyItem(parent)
+ return null;
+ }
+
+ if (item.id == null) {
+ return onError("ID is null for deleteItem", Cr.NS_ERROR_FAILURE);
+ }
+
+ await this.mItemModel.deleteItemById(item.id);
+
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.DELETE, item.id, item);
+
+ // notify observers
+ this.observers.notify("onDeleteItem", [item]);
+ return null;
+ },
+
+ // Promise<calIItemBase|null> getItem(in string id);
+ async getItem(aId) {
+ return this.mItemModel.getItemById(aId);
+ },
+
+ // ReadableStream<calIItemBase> getItems(in unsigned long itemFilter,
+ // in unsigned long count,
+ // in calIDateTime rangeStart,
+ // in calIDateTime rangeEnd);
+ getItems(itemFilter, count, rangeStart, rangeEnd) {
+ let query = {
+ rangeStart,
+ rangeEnd,
+ filters: {
+ wantUnrespondedInvitations:
+ (itemFilter & kCalICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION) != 0 &&
+ this.superCalendar.supportsScheduling,
+ wantEvents: (itemFilter & kCalICalendar.ITEM_FILTER_TYPE_EVENT) != 0,
+ wantTodos: (itemFilter & kCalICalendar.ITEM_FILTER_TYPE_TODO) != 0,
+ asOccurrences: (itemFilter & kCalICalendar.ITEM_FILTER_CLASS_OCCURRENCES) != 0,
+ wantOfflineDeletedItems: (itemFilter & kCalICalendar.ITEM_FILTER_OFFLINE_DELETED) != 0,
+ wantOfflineCreatedItems: (itemFilter & kCalICalendar.ITEM_FILTER_OFFLINE_CREATED) != 0,
+ wantOfflineModifiedItems: (itemFilter & kCalICalendar.ITEM_FILTER_OFFLINE_MODIFIED) != 0,
+ itemCompletedFilter: (itemFilter & kCalICalendar.ITEM_FILTER_COMPLETED_YES) != 0,
+ itemNotCompletedFilter: (itemFilter & kCalICalendar.ITEM_FILTER_COMPLETED_NO) != 0,
+ },
+ count,
+ };
+
+ if ((!query.filters.wantEvents && !query.filters.wantTodos) || this.getProperty("disabled")) {
+ // nothing to do
+ return CalReadableStreamFactory.createEmptyReadableStream();
+ }
+
+ return this.mItemModel.getItems(query);
+ },
+
+ async getItemOfflineFlag(aItem) {
+ // It is possible that aItem can be null, flag provided should be null in this case
+ return aItem ? this.mOfflineModel.getItemOfflineFlag(aItem) : null;
+ },
+
+ //
+ // calIOfflineStorage interface
+ //
+ async addOfflineItem(aItem) {
+ let newOfflineJournalFlag = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, newOfflineJournalFlag);
+ },
+
+ async modifyOfflineItem(aItem) {
+ let oldOfflineJournalFlag = await this.getItemOfflineFlag(aItem);
+ let newOfflineJournalFlag = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ if (
+ oldOfflineJournalFlag == cICL.OFFLINE_FLAG_CREATED_RECORD ||
+ oldOfflineJournalFlag == cICL.OFFLINE_FLAG_DELETED_RECORD
+ ) {
+ // Do nothing since a flag of "created" or "deleted" exists
+ } else {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, newOfflineJournalFlag);
+ }
+ this.notifyOperationComplete(null, Cr.NS_OK, Ci.calIOperationListener.MODIFY, aItem.id, aItem);
+ },
+
+ async deleteOfflineItem(aItem) {
+ let oldOfflineJournalFlag = await this.getItemOfflineFlag(aItem);
+ if (oldOfflineJournalFlag) {
+ // Delete item if flag is set
+ if (oldOfflineJournalFlag == cICL.OFFLINE_FLAG_CREATED_RECORD) {
+ await this.mItemModel.deleteItemById(aItem.id);
+ } else if (oldOfflineJournalFlag == cICL.OFFLINE_FLAG_MODIFIED_RECORD) {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, cICL.OFFLINE_FLAG_DELETED_RECORD);
+ }
+ } else {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, cICL.OFFLINE_FLAG_DELETED_RECORD);
+ }
+
+ // notify observers
+ this.observers.notify("onDeleteItem", [aItem]);
+ },
+
+ async resetItemOfflineFlag(aItem) {
+ await this.mOfflineModel.setOfflineJournalFlag(aItem, null);
+ },
+
+ //
+ // database handling
+ //
+
+ // database initialization
+ // assumes this.mStorageDb is valid
+
+ initDB() {
+ cal.ASSERT(this.mStorageDb, "Database has not been opened!", true);
+
+ try {
+ this.mStorageDb.executeSimpleSQL("PRAGMA journal_mode=WAL");
+ this.mStorageDb.executeSimpleSQL("PRAGMA cache_size=-10240"); // 10 MiB
+ this.mStatements = new CalStorageStatements(this.mStorageDb);
+ this.mItemModel = CalStorageModelFactory.createInstance(
+ "cached-item",
+ this.mStorageDb,
+ this.mStatements,
+ this
+ );
+ this.mOfflineModel = CalStorageModelFactory.createInstance(
+ "offline",
+ this.mStorageDb,
+ this.mStatements,
+ this
+ );
+ this.mMetaModel = CalStorageModelFactory.createInstance(
+ "metadata",
+ this.mStorageDb,
+ this.mStatements,
+ this
+ );
+ } catch (e) {
+ this.mStorageDb.logError("Error initializing statements.", e);
+ }
+ },
+
+ async shutdownDB() {
+ try {
+ this.mStatements.finalize();
+ if (this.mStorageDb) {
+ await this.mStorageDb.close();
+ this.mStorageDb = null;
+ }
+ } catch (e) {
+ cal.ERROR("Error closing storage database: " + e);
+ }
+ },
+
+ //
+ // calISyncWriteCalendar interface
+ //
+
+ setMetaData(id, value) {
+ this.mMetaModel.deleteMetaDataById(id);
+ this.mMetaModel.addMetaData(id, value);
+ },
+
+ deleteMetaData(id) {
+ this.mMetaModel.deleteMetaDataById(id);
+ },
+
+ getMetaData(id) {
+ return this.mMetaModel.getMetaData(id);
+ },
+
+ getAllMetaDataIds() {
+ return this.mMetaModel.getAllMetaData("item_id");
+ },
+
+ getAllMetaDataValues() {
+ return this.mMetaModel.getAllMetaData("value");
+ },
+
+ /**
+ * propagate the given sequence in exceptions. It may be needed by some calendar implementations
+ */
+ _propagateSequence(aItem, newSequence) {
+ if (newSequence) {
+ aItem.setProperty("SEQUENCE", newSequence);
+ } else {
+ aItem.deleteProperty("SEQUENCE");
+ }
+ let rec = aItem.recurrenceInfo;
+ if (rec) {
+ let exceptions = rec.getExceptionIds();
+ if (exceptions.length > 0) {
+ for (let exid of exceptions) {
+ let ex = rec.getExceptionFor(exid);
+ if (newSequence) {
+ ex.setProperty("SEQUENCE", newSequence);
+ } else {
+ ex.deleteProperty("SEQUENCE");
+ }
+ }
+ }
+ }
+ },
+};
diff --git a/comm/calendar/providers/storage/CalStorageDatabase.jsm b/comm/calendar/providers/storage/CalStorageDatabase.jsm
new file mode 100644
index 0000000000..b4ba1dc2b9
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageDatabase.jsm
@@ -0,0 +1,333 @@
+/* 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 = ["CalStorageDatabase"];
+
+const { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+let connections = new Map();
+
+/**
+ * Checks for an existing SQLite connection to `file`, or creates a new one.
+ * Calls to `openConnectionTo` and `closeConnection` are counted so we know
+ * if a connection is no longer used.
+ *
+ * @param {nsIFile} file
+ * @returns {mozIStorageConnection}
+ */
+function openConnectionTo(file) {
+ let data = connections.get(file.path);
+
+ if (data) {
+ data.useCount++;
+ return data.connection;
+ }
+
+ let connection = Services.storage.openDatabase(file);
+ connections.set(file.path, { connection, useCount: 1 });
+ return connection;
+}
+
+/**
+ * Closes an SQLite connection if it is no longer in use.
+ *
+ * @param {mozIStorageConnection} connection
+ * @returns {Promise} - resolves when the connection is closed, or immediately
+ * if the database is still in use.
+ */
+function closeConnection(connection, forceClosed) {
+ let file = connection.databaseFile;
+ let data = connections.get(file.path);
+
+ if (forceClosed || !data || --data.useCount == 0) {
+ return new Promise(resolve => {
+ connection.asyncClose({
+ complete() {
+ resolve();
+ },
+ });
+ connections.delete(file.path);
+ });
+ }
+
+ return Promise.resolve();
+}
+
+// Clean up all open databases at shutdown. All storage statements must be closed by now,
+// which CalStorageCalendar does during profile-change-teardown.
+AsyncShutdown.profileBeforeChange.addBlocker("Calendar: closing databases", async () => {
+ let promises = [];
+ for (let data of connections.values()) {
+ promises.push(closeConnection(data.connection, true));
+ }
+ await Promise.allSettled(promises);
+});
+
+/**
+ * CalStorageDatabase is a mozIStorageAsyncConnection wrapper used by the
+ * storage calendar.
+ */
+class CalStorageDatabase {
+ /**
+ * @type {mozIStorageAsyncConnection}
+ */
+ db = null;
+
+ /**
+ * @type {string}
+ */
+ calendarId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ lastStatement = null;
+
+ /**
+ * @param {mozIStorageAsyncConnection} db
+ * @param {string} calendarId
+ */
+ constructor(db, calendarId) {
+ this.db = db;
+ this.calendarId = calendarId;
+ }
+
+ /**
+ * Initializes a CalStorageDatabase using the provided nsIURI and calendar
+ * id.
+ *
+ * @param {nsIURI} uri
+ * @param {string} calendarId
+ *
+ * @returns {CalStorageDatabase}
+ */
+ static connect(uri, calendarId) {
+ if (uri.schemeIs("file")) {
+ let fileURL = uri.QueryInterface(Ci.nsIFileURL);
+
+ if (!fileURL) {
+ throw new Components.Exception("Invalid file", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+ // open the database
+ return new CalStorageDatabase(openConnectionTo(fileURL.file), calendarId);
+ } else if (uri.schemeIs("moz-storage-calendar")) {
+ // New style uri, no need for migration here
+ let localDB = cal.provider.getCalendarDirectory();
+ localDB.append("local.sqlite");
+
+ if (!localDB.exists()) {
+ // This can happen with a database upgrade and the "too new schema" situation.
+ localDB.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o700);
+ }
+
+ return new CalStorageDatabase(openConnectionTo(localDB), calendarId);
+ }
+ throw new Components.Exception("Invalid Scheme " + uri.spec);
+ }
+
+ /**
+ * Calls the same method on the underlying database connection.
+ *
+ * @param {string} sql
+ *
+ * @returns {mozIStorageAsyncStatement}
+ */
+ createAsyncStatement(sql) {
+ return this.db.createAsyncStatement(sql);
+ }
+
+ /**
+ * Calls the same method on the underlying database connection.
+ *
+ * @param {string} sql
+ *
+ * @returns {mozIStorageStatement}
+ */
+ createStatement(sql) {
+ return this.db.createStatement(sql);
+ }
+
+ /**
+ * Calls the same method on the underlying database connection.
+ *
+ * @param {string} sql
+ *
+ * @returns
+ */
+ executeSimpleSQL(sql) {
+ return this.db.executeSimpleSQL(sql);
+ }
+
+ /**
+ * Takes care of necessary preparations for most of our statements.
+ *
+ * @param {mozIStorageAsyncStatement} aStmt
+ */
+ prepareStatement(aStmt) {
+ try {
+ aStmt.params.cal_id = this.calendarId;
+ this.lastStatement = aStmt;
+ } catch (e) {
+ this.logError("prepareStatement exception", e);
+ }
+ return aStmt;
+ }
+
+ /**
+ * Executes a statement using an item as a parameter.
+ *
+ * @param {mozIStorageStatement} stmt - The statement to execute.
+ * @param {string} idParam - The name of the parameter referring to the item id.
+ * @param {string} id - The id of the item.
+ */
+ executeSyncItemStatement(aStmt, aIdParam, aId) {
+ try {
+ aStmt.params.cal_id = this.calendarId;
+ aStmt.params[aIdParam] = aId;
+ aStmt.executeStep();
+ } catch (e) {
+ this.logError("executeSyncItemStatement exception", e);
+ throw e;
+ } finally {
+ aStmt.reset();
+ }
+ }
+
+ prepareAsyncStatement(aStmts, aStmt) {
+ if (!aStmts.has(aStmt)) {
+ aStmts.set(aStmt, aStmt.newBindingParamsArray());
+ }
+ return aStmts.get(aStmt);
+ }
+
+ prepareAsyncParams(aArray) {
+ let params = aArray.newBindingParams();
+ params.bindByName("cal_id", this.calendarId);
+ return params;
+ }
+
+ /**
+ * Executes one or more SQL statemets.
+ *
+ * @param {mozIStorageAsyncStatement|mozIStorageAsyncStatement[]} aStmts
+ * @param {Function} aCallback
+ */
+ executeAsync(aStmts, aCallback) {
+ if (!Array.isArray(aStmts)) {
+ aStmts = [aStmts];
+ }
+
+ let self = this;
+ return new Promise((resolve, reject) => {
+ this.db.executeAsync(aStmts, {
+ resultPromises: [],
+
+ handleResult(aResultSet) {
+ this.resultPromises.push(this.handleResultInner(aResultSet));
+ },
+ async handleResultInner(aResultSet) {
+ let row = aResultSet.getNextRow();
+ while (row) {
+ try {
+ await aCallback(row);
+ } catch (ex) {
+ this.handleError(ex);
+ }
+ if (this.finishCalled) {
+ self.logError(
+ "Async query completed before all rows consumed. This should never happen.",
+ null
+ );
+ }
+ row = aResultSet.getNextRow();
+ }
+ },
+ handleError(aError) {
+ cal.WARN(aError);
+ },
+ async handleCompletion(aReason) {
+ await Promise.all(this.resultPromises);
+
+ switch (aReason) {
+ case Ci.mozIStorageStatementCallback.REASON_FINISHED:
+ this.finishCalled = true;
+ resolve();
+ break;
+ case Ci.mozIStorageStatementCallback.REASON_CANCELLED:
+ reject(Components.Exception("async statement was cancelled", Cr.NS_ERROR_ABORT));
+ break;
+ default:
+ reject(Components.Exception("error executing async statement", Cr.NS_ERROR_FAILURE));
+ break;
+ }
+ },
+ });
+ });
+ }
+
+ prepareItemStatement(aStmts, aStmt, aIdParam, aId) {
+ aStmt.params.cal_id = this.calendarId;
+ aStmt.params[aIdParam] = aId;
+ aStmts.push(aStmt);
+ }
+
+ /**
+ * Internal logging function that should be called on any database error,
+ * it will log as much info as possible about the database context and
+ * last statement so the problem can be investigated more easily.
+ *
+ * @param message Error message to log.
+ * @param exception Exception that caused the error.
+ */
+ logError(message, exception) {
+ let logMessage = "Message: " + message;
+ if (this.db) {
+ if (this.db.connectionReady) {
+ logMessage += "\nConnection Ready: " + this.db.connectionReady;
+ }
+ if (this.db.lastError) {
+ logMessage += "\nLast DB Error Number: " + this.db.lastError;
+ }
+ if (this.db.lastErrorString) {
+ logMessage += "\nLast DB Error Message: " + this.db.lastErrorString;
+ }
+ if (this.db.databaseFile) {
+ logMessage += "\nDatabase File: " + this.db.databaseFile.path;
+ }
+ if (this.db.lastInsertRowId) {
+ logMessage += "\nLast Insert Row Id: " + this.db.lastInsertRowId;
+ }
+ if (this.db.transactionInProgress) {
+ logMessage += "\nTransaction In Progress: " + this.db.transactionInProgress;
+ }
+ }
+
+ if (this.lastStatement) {
+ logMessage += "\nLast DB Statement: " + this.lastStatement;
+ // Async statements do not allow enumeration of parameters.
+ if (this.lastStatement instanceof Ci.mozIStorageStatement && this.lastStatement.params) {
+ for (let param in this.lastStatement.params) {
+ logMessage +=
+ "\nLast Statement param [" + param + "]: " + this.lastStatement.params[param];
+ }
+ }
+ }
+
+ if (exception) {
+ logMessage += "\nException: " + exception;
+ }
+ cal.ERROR("[calStorageCalendar] " + logMessage + "\n" + cal.STACK(10));
+ }
+
+ /**
+ * Close the underlying db connection.
+ */
+ close() {
+ closeConnection(this.db);
+ this.db = null;
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageItemModel.jsm b/comm/calendar/providers/storage/CalStorageItemModel.jsm
new file mode 100644
index 0000000000..a25e5bbd46
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageItemModel.jsm
@@ -0,0 +1,1374 @@
+/* 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 = ["CalStorageItemModel"];
+
+const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CAL_ITEM_FLAG, newDateTime } = ChromeUtils.import(
+ "resource:///modules/calendar/calStorageHelpers.jsm"
+);
+const { CalReadableStreamFactory } = ChromeUtils.import(
+ "resource:///modules/CalReadableStreamFactory.jsm"
+);
+const { CalStorageModelBase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelBase.jsm"
+);
+
+const { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalEvent: "resource:///modules/CalEvent.jsm",
+ CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+ CalTodo: "resource:///modules/CalTodo.jsm",
+});
+
+const cICL = Ci.calIChangeLog;
+const USECS_PER_SECOND = 1000000;
+const DEFAULT_START_TIME = -9223372036854776000;
+
+// endTime needs to be the max value a PRTime can be
+const DEFAULT_END_TIME = 9223372036854776000;
+
+// Calls to get items from the database await this Promise. In normal operation
+// the Promise resolves after most application start-up operations, so that we
+// don't start hitting the database during start-up. Fox XPCShell tests, normal
+// start-up doesn't happen, so we just resolve the Promise instantly.
+let startupPromise;
+if (Services.appinfo.name == "xpcshell") {
+ startupPromise = Promise.resolve();
+} else {
+ const { MailGlue } = ChromeUtils.import("resource:///modules/MailGlue.jsm");
+ startupPromise = MailGlue.afterStartUp;
+}
+
+/**
+ * CalStorageItemModel provides methods for manipulating item data.
+ */
+class CalStorageItemModel extends CalStorageModelBase {
+ /**
+ * calCachedCalendar modifies the superCalendar property so this is made
+ * lazy.
+ *
+ * @type {calISchedulingSupport}
+ */
+ get #schedulingSupport() {
+ return (
+ (this.calendar.superCalendar.supportsScheduling &&
+ this.calendar.superCalendar.getSchedulingSupport()) ||
+ null
+ );
+ }
+
+ /**
+ * Update the item passed.
+ *
+ * @param {calIItemBase} item - The newest version of the item.
+ * @param {calIItemBase} oldItem - The previous version of the item.
+ */
+ async updateItem(item, olditem) {
+ cal.ASSERT(!item.recurrenceId, "no parent item passed!", true);
+ await this.deleteItemById(olditem.id, true);
+ await this.addItem(item);
+ }
+
+ /**
+ * Object containing the parameters for executing a DB query.
+ *
+ * @typedef {object} CalStorageQuery
+ * @property {CalStorageQueryFilter} filter
+ * @property {calIDateTime} rangeStart
+ * @property {calIDateTime?} rangeEnd
+ * @property {number} count
+ */
+
+ /**
+ * Object indicating types and state of items to return.
+ *
+ * @typedef {object} CalStorageQueryFilter
+ * @property {boolean} wantUnrespondedInvitations
+ * @property {boolean} wantEvents
+ * @property {boolean} wantTodos
+ * @property {boolean} asOccurrences
+ * @property {boolean} wantOfflineDeletedItems
+ * @property {boolean} wantOfflineCreatedItems
+ * @property {boolean} wantOfflineModifiedItems
+ * @property {boolean} itemCompletedFilter
+ * @property {boolean} itemNotCompletedFilter
+ */
+
+ /**
+ * Retrieves one or more items from the database based on the query provided.
+ * See the definition of CalStorageQuery for valid query parameters.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calIItemBase>}
+ */
+ getItems(query) {
+ let { filters, count } = query;
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ if (filters) {
+ if (filters.wantEvents) {
+ for await (let value of cal.iterate.streamValues(self.#getEvents(query))) {
+ controller.enqueue(value);
+ }
+ }
+
+ count = count && count - controller.count;
+ if (filters.wantTodos && (!count || count > 0)) {
+ for await (let value of cal.iterate.streamValues(
+ self.#getTodos({ ...query, count })
+ )) {
+ controller.enqueue(value);
+ }
+ }
+ controller.close();
+ }
+ },
+ }
+ );
+ }
+
+ /**
+ * Queries the database for calIEvent records providing them in a streaming
+ * fashion.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calIEvent>}
+ */
+ #getEvents(query) {
+ let { filters, rangeStart, rangeEnd } = query;
+ let startTime = DEFAULT_START_TIME;
+ let endTime = DEFAULT_END_TIME;
+
+ if (rangeStart) {
+ startTime = rangeStart.nativeTime;
+ }
+ if (rangeEnd) {
+ endTime = rangeEnd.nativeTime;
+ }
+
+ let params; // stmt params
+ let requestedOfflineJournal = null;
+
+ if (filters.wantOfflineDeletedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ } else if (filters.wantOfflineCreatedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ } else if (filters.wantOfflineModifiedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ query.count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ await startupPromise;
+ // first get non-recurring events that happen to fall within the range
+ try {
+ self.db.prepareStatement(self.statements.mSelectNonRecurringEventsByRange);
+ params = self.statements.mSelectNonRecurringEventsByRange.params;
+ params.range_start = startTime;
+ params.range_end = endTime;
+ params.start_offset = rangeStart ? rangeStart.timezoneOffset * USECS_PER_SECOND : 0;
+ params.end_offset = rangeEnd ? rangeEnd.timezoneOffset * USECS_PER_SECOND : 0;
+ params.offline_journal = requestedOfflineJournal;
+
+ await self.db.executeAsync(
+ self.statements.mSelectNonRecurringEventsByRange,
+ async row => {
+ let event = self.#expandOccurrences(
+ await self.getEventFromRow(row),
+ startTime,
+ rangeStart,
+ rangeEnd,
+ filters
+ );
+ controller.enqueue(event);
+ }
+ );
+ } catch (e) {
+ self.db.logError("Error selecting non recurring events by range!\n", e);
+ }
+
+ if (!controller.maxTotalItemsReached) {
+ // Process the recurring events
+ let [recEvents, recEventFlags] = await self.getFullRecurringEventAndFlagMaps();
+ for (let [id, evitem] of recEvents.entries()) {
+ let cachedJournalFlag = recEventFlags.get(id);
+ // No need to return flagged unless asked i.e. requestedOfflineJournal == cachedJournalFlag
+ // Return created and modified offline records if requestedOfflineJournal is null alongwith events that have no flag
+ if (
+ (requestedOfflineJournal == null &&
+ cachedJournalFlag != cICL.OFFLINE_FLAG_DELETED_RECORD) ||
+ (requestedOfflineJournal != null && cachedJournalFlag == requestedOfflineJournal)
+ ) {
+ controller.enqueue(
+ self.#expandOccurrences(evitem, startTime, rangeStart, rangeEnd, filters)
+ );
+ if (controller.maxTotalItemsReached) {
+ break;
+ }
+ }
+ }
+ }
+ controller.close();
+ },
+ }
+ );
+ }
+
+ /**
+ * Queries the database for calITodo records providing them in a streaming
+ * fashion.
+ *
+ * @param {CalStorageQuery} query
+ *
+ * @returns {ReadableStream<calITodo>}
+ */
+ #getTodos(query) {
+ let { filters, rangeStart, rangeEnd } = query;
+ let startTime = DEFAULT_START_TIME;
+ let endTime = DEFAULT_END_TIME;
+
+ if (rangeStart) {
+ startTime = rangeStart.nativeTime;
+ }
+ if (rangeEnd) {
+ endTime = rangeEnd.nativeTime;
+ }
+
+ let params; // stmt params
+ let requestedOfflineJournal = null;
+
+ if (filters.wantOfflineCreatedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_CREATED_RECORD;
+ } else if (filters.wantOfflineDeletedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_DELETED_RECORD;
+ } else if (filters.wantOfflineModifiedItems) {
+ requestedOfflineJournal = cICL.OFFLINE_FLAG_MODIFIED_RECORD;
+ }
+
+ let checkCompleted = item =>
+ item.isCompleted ? filters.itemCompletedFilter : filters.itemNotCompletedFilter;
+
+ let self = this;
+ return CalReadableStreamFactory.createBoundedReadableStream(
+ query.count,
+ CalReadableStreamFactory.defaultQueueSize,
+ {
+ async start(controller) {
+ await startupPromise;
+ // first get non-recurring todos that happen to fall within the range
+ try {
+ self.db.prepareStatement(self.statements.mSelectNonRecurringTodosByRange);
+ params = self.statements.mSelectNonRecurringTodosByRange.params;
+ params.range_start = startTime;
+ params.range_end = endTime;
+ params.start_offset = rangeStart ? rangeStart.timezoneOffset * USECS_PER_SECOND : 0;
+ params.end_offset = rangeEnd ? rangeEnd.timezoneOffset * USECS_PER_SECOND : 0;
+ params.offline_journal = requestedOfflineJournal;
+
+ await self.db.executeAsync(
+ self.statements.mSelectNonRecurringTodosByRange,
+ async row => {
+ let todo = self.#expandOccurrences(
+ await self.getTodoFromRow(row),
+ startTime,
+ rangeStart,
+ rangeEnd,
+ filters,
+ checkCompleted
+ );
+ controller.enqueue(todo);
+ }
+ );
+ } catch (e) {
+ self.db.logError("Error selecting non recurring todos by range", e);
+ }
+
+ if (!controller.maxTotalItemsReached) {
+ // Note: Reading the code, completed *occurrences* seems to be broken, because
+ // only the parent item has been filtered; I fixed that.
+ // Moreover item.todo_complete etc seems to be a leftover...
+
+ // process the recurring todos
+ let [recTodos, recTodoFlags] = await self.getFullRecurringTodoAndFlagMaps();
+ for (let [id, todoitem] of recTodos) {
+ let cachedJournalFlag = recTodoFlags.get(id);
+ if (
+ (requestedOfflineJournal == null &&
+ (cachedJournalFlag == cICL.OFFLINE_FLAG_MODIFIED_RECORD ||
+ cachedJournalFlag == cICL.OFFLINE_FLAG_CREATED_RECORD ||
+ cachedJournalFlag == null)) ||
+ (requestedOfflineJournal != null && cachedJournalFlag == requestedOfflineJournal)
+ ) {
+ controller.enqueue(
+ self.#expandOccurrences(
+ todoitem,
+ startTime,
+ rangeStart,
+ rangeEnd,
+ filters,
+ checkCompleted
+ )
+ );
+ if (controller.maxTotalItemsReached) {
+ break;
+ }
+ }
+ }
+ }
+ controller.close();
+ },
+ }
+ );
+ }
+
+ #checkUnrespondedInvitation(item) {
+ let att = this.#schedulingSupport.getInvitedAttendee(item);
+ return att && att.participationStatus == "NEEDS-ACTION";
+ }
+
+ #expandOccurrences(item, startTime, rangeStart, rangeEnd, filters, optionalFilterFunc) {
+ if (item.recurrenceInfo && item.recurrenceInfo.recurrenceEndDate < startTime) {
+ return [];
+ }
+
+ let expandedItems = [];
+ if (item.recurrenceInfo && filters.asOccurrences) {
+ // If the item is recurring, get all occurrences that fall in
+ // the range. If the item doesn't fall into the range at all,
+ // this expands to 0 items.
+ expandedItems = item.recurrenceInfo.getOccurrences(rangeStart, rangeEnd, 0);
+ if (filters.wantUnrespondedInvitations) {
+ expandedItems = expandedItems.filter(item => this.#checkUnrespondedInvitation(item));
+ }
+ } else if (
+ (!filters.wantUnrespondedInvitations || this.#checkUnrespondedInvitation(item)) &&
+ cal.item.checkIfInRange(item, rangeStart, rangeEnd)
+ ) {
+ // If no occurrences are wanted, check only the parent item.
+ // This will be changed with bug 416975.
+ expandedItems = [item];
+ }
+
+ if (expandedItems.length) {
+ if (optionalFilterFunc) {
+ expandedItems = expandedItems.filter(optionalFilterFunc);
+ }
+ }
+ return expandedItems;
+ }
+
+ /**
+ * Read in the common ItemBase attributes from aDBRow, and stick
+ * them on item.
+ *
+ * @param {mozIStorageRow} row
+ * @param {calIItemBase} item
+ */
+ #getItemBaseFromRow(row, item) {
+ item.calendar = this.calendar.superCalendar;
+ item.id = row.getResultByName("id");
+ if (row.getResultByName("title")) {
+ item.title = row.getResultByName("title");
+ }
+ if (row.getResultByName("priority")) {
+ item.priority = row.getResultByName("priority");
+ }
+ if (row.getResultByName("privacy")) {
+ item.privacy = row.getResultByName("privacy");
+ }
+ if (row.getResultByName("ical_status")) {
+ item.status = row.getResultByName("ical_status");
+ }
+
+ if (row.getResultByName("alarm_last_ack")) {
+ // alarm acks are always in utc
+ item.alarmLastAck = newDateTime(row.getResultByName("alarm_last_ack"), "UTC");
+ }
+
+ if (row.getResultByName("recurrence_id")) {
+ item.recurrenceId = newDateTime(
+ row.getResultByName("recurrence_id"),
+ row.getResultByName("recurrence_id_tz")
+ );
+ if ((row.getResultByName("flags") & CAL_ITEM_FLAG.RECURRENCE_ID_ALLDAY) != 0) {
+ item.recurrenceId.isDate = true;
+ }
+ }
+
+ if (row.getResultByName("time_created")) {
+ item.setProperty("CREATED", newDateTime(row.getResultByName("time_created"), "UTC"));
+ }
+
+ // This must be done last because the setting of any other property
+ // after this would overwrite it again.
+ if (row.getResultByName("last_modified")) {
+ item.setProperty("LAST-MODIFIED", newDateTime(row.getResultByName("last_modified"), "UTC"));
+ }
+ }
+
+ /**
+ * @callback OnItemRowCallback
+ * @param {string} id - The id of the item fetched from the row.
+ */
+
+ /**
+ * Provides all recurring events along with offline flag values for each event.
+ *
+ * @param {OnItemRowCallback} [callback] - If provided, will be called on each row
+ * fetched.
+ * @returns {Promise<[Map<string, calIEvent>, Map<string, number>]>}
+ */
+ async getRecurringEventAndFlagMaps(callback) {
+ await startupPromise;
+ let events = new Map();
+ let flags = new Map();
+ this.db.prepareStatement(this.statements.mSelectEventsWithRecurrence);
+ await this.db.executeAsync(this.statements.mSelectEventsWithRecurrence, async row => {
+ let item_id = row.getResultByName("id");
+ if (callback) {
+ callback(item_id);
+ }
+ let item = await this.getEventFromRow(row, false);
+ events.set(item_id, item);
+ flags.set(item_id, row.getResultByName("offline_journal") || null);
+ });
+ return [events, flags];
+ }
+
+ /**
+ * Provides all recurring events with additional data populated along with
+ * offline flags values for each event.
+ *
+ * @returns {Promise<[Map<string, calIEvent>, Map<string, number>]>}
+ */
+ async getFullRecurringEventAndFlagMaps() {
+ let [events, flags] = await this.getRecurringEventAndFlagMaps();
+ return [await this.getAdditionalDataForItemMap(events), flags];
+ }
+
+ /**
+ * Provides all recurring todos along with offline flag values for each event.
+ *
+ * @param {OnItemRowCallback} [callback] - If provided, will be called on each row
+ * fetched.
+ *
+ * @returns {Promise<[Map<string, calITodo>, Map<string, number>]>}
+ */
+ async getRecurringTodoAndFlagMaps(callback) {
+ await startupPromise;
+ let todos = new Map();
+ let flags = new Map();
+ this.db.prepareStatement(this.statements.mSelectTodosWithRecurrence);
+ await this.db.executeAsync(this.statements.mSelectTodosWithRecurrence, async row => {
+ let item_id = row.getResultByName("id");
+ if (callback) {
+ callback(item_id);
+ }
+ let item = await this.getTodoFromRow(row, false);
+ todos.set(item_id, item);
+ flags.set(item_id, row.getResultByName("offline_journal") || null);
+ });
+ return [todos, flags];
+ }
+
+ /**
+ * Provides all recurring todos with additional data populated along with
+ * offline flags values for each todo.
+ *
+ * @returns {Promise<[Map<string, calITodo>, Map<string, number>]>}
+ */
+ async getFullRecurringTodoAndFlagMaps() {
+ let [todos, flags] = await this.getRecurringTodoAndFlagMaps();
+ return [await this.getAdditionalDataForItemMap(todos), flags];
+ }
+
+ /**
+ * The `icalString` database fields could be stored with or without lines
+ * folded, but if this raw data is passed to ical.js it misinterprets the
+ * white-space as significant. Strip it out as the data is fetched.
+ *
+ * @param {mozIStorageRow} row
+ * @returns {string}
+ */
+ #unfoldIcalString(row) {
+ return row.getResultByName("icalString").replaceAll("\r\n ", "");
+ }
+
+ /**
+ * Populates additional data for a Map of items. This method is overridden in
+ * CalStorageCachedItemModel to allow the todos to be loaded from the cache.
+ *
+ * @param {Map<string, calIItem>} itemMap
+ *
+ * @returns {Promise<Map<string, calIItem>>} The original Map with items modified.
+ */
+ async getAdditionalDataForItemMap(itemsMap) {
+ await startupPromise;
+ //NOTE: There seems to be a bug in the SQLite subsystem that causes callers
+ //awaiting on this method to continue prematurely. This can cause unexpected
+ //behaviour. After investigating, it appears triggering the bug is related
+ //to the number of queries executed here.
+ this.db.prepareStatement(this.statements.mSelectAllAttendees);
+ await this.db.executeAsync(this.statements.mSelectAllAttendees, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let attendee = new lazy.CalAttendee(this.#unfoldIcalString(row));
+ if (attendee && attendee.id) {
+ if (attendee.isOrganizer) {
+ item.organizer = attendee;
+ } else {
+ item.addAttendee(attendee);
+ }
+ } else {
+ cal.WARN(
+ "[calStorageCalendar] Skipping invalid attendee for item '" +
+ item.title +
+ "' (" +
+ item.id +
+ ")."
+ );
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllProperties);
+ await this.db.executeAsync(this.statements.mSelectAllProperties, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let name = row.getResultByName("key");
+ switch (name) {
+ case "DURATION":
+ // for events DTEND/DUE is enforced by calEvent/calTodo, so suppress DURATION:
+ break;
+ case "CATEGORIES": {
+ let cats = cal.category.stringToArray(row.getResultByName("value"));
+ item.setCategories(cats);
+ break;
+ }
+ default:
+ let value = row.getResultByName("value");
+ item.setProperty(name, value);
+ break;
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllParameters);
+ await this.db.executeAsync(this.statements.mSelectAllParameters, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let prop = row.getResultByName("key1");
+ let param = row.getResultByName("key2");
+ let value = row.getResultByName("value");
+ item.setPropertyParameter(prop, param, value);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllRecurrences);
+ await this.db.executeAsync(this.statements.mSelectAllRecurrences, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (!item) {
+ return;
+ }
+
+ let recInfo = item.recurrenceInfo;
+ if (!recInfo) {
+ recInfo = new lazy.CalRecurrenceInfo(item);
+ item.recurrenceInfo = recInfo;
+ }
+
+ let ritem = this.#getRecurrenceItemFromRow(row);
+ recInfo.appendRecurrenceItem(ritem);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllEventExceptions);
+ await this.db.executeAsync(this.statements.mSelectAllEventExceptions, async row => {
+ let item = itemsMap.get(row.getResultByName("id"));
+ if (!item) {
+ return;
+ }
+
+ let rec = item.recurrenceInfo;
+ let exc = await this.getEventFromRow(row);
+ rec.modifyException(exc, true);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllTodoExceptions);
+ await this.db.executeAsync(this.statements.mSelectAllTodoExceptions, async row => {
+ let item = itemsMap.get(row.getResultByName("id"));
+ if (!item) {
+ return;
+ }
+
+ let rec = item.recurrenceInfo;
+ let exc = await this.getTodoFromRow(row);
+ rec.modifyException(exc, true);
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllAttachments);
+ await this.db.executeAsync(this.statements.mSelectAllAttachments, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (item) {
+ item.addAttachment(new lazy.CalAttachment(this.#unfoldIcalString(row)));
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllRelations);
+ await this.db.executeAsync(this.statements.mSelectAllRelations, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (item) {
+ item.addRelation(new lazy.CalRelation(this.#unfoldIcalString(row)));
+ }
+ });
+
+ this.db.prepareStatement(this.statements.mSelectAllAlarms);
+ await this.db.executeAsync(this.statements.mSelectAllAlarms, row => {
+ let item = itemsMap.get(row.getResultByName("item_id"));
+ if (item) {
+ item.addAlarm(new lazy.CalAlarm(this.#unfoldIcalString(row)));
+ }
+ });
+
+ for (let item of itemsMap.values()) {
+ this.#fixGoogleCalendarDescriptionIfNeeded(item);
+ item.makeImmutable();
+ }
+ return itemsMap;
+ }
+
+ /**
+ * For items that were cached or stored in previous versions,
+ * put Google's HTML description in the right place.
+ *
+ * @param {calIItemBase} item
+ */
+ #fixGoogleCalendarDescriptionIfNeeded(item) {
+ if (item.id && item.id.endsWith("@google.com")) {
+ let description = item.getProperty("DESCRIPTION");
+ if (description) {
+ let altrep = item.getPropertyParameter("DESCRIPTION", "ALTREP");
+ if (!altrep) {
+ cal.view.fixGoogleCalendarDescription(item);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param {mozIStorageRow} row
+ * @param {boolean} getAdditionalData
+ */
+ async getEventFromRow(row, getAdditionalData = true) {
+ let item = new lazy.CalEvent();
+ let flags = row.getResultByName("flags");
+
+ if (row.getResultByName("event_start")) {
+ item.startDate = newDateTime(
+ row.getResultByName("event_start"),
+ row.getResultByName("event_start_tz")
+ );
+ }
+ if (row.getResultByName("event_end")) {
+ item.endDate = newDateTime(
+ row.getResultByName("event_end"),
+ row.getResultByName("event_end_tz")
+ );
+ }
+ if (row.getResultByName("event_stamp")) {
+ item.setProperty("DTSTAMP", newDateTime(row.getResultByName("event_stamp"), "UTC"));
+ }
+ if (flags & CAL_ITEM_FLAG.EVENT_ALLDAY) {
+ item.startDate.isDate = true;
+ item.endDate.isDate = true;
+ }
+
+ // This must be done last to keep the modification time intact.
+ this.#getItemBaseFromRow(row, item);
+ if (getAdditionalData) {
+ await this.#getAdditionalDataForItem(item, row.getResultByName("flags"));
+ item.makeImmutable();
+ }
+ return item;
+ }
+
+ /**
+ * @param {mozIStorageRow} row
+ * @param {boolean} getAdditionalData
+ */
+ async getTodoFromRow(row, getAdditionalData = true) {
+ let item = new lazy.CalTodo();
+ let flags = row.getResultByName("flags");
+
+ if (row.getResultByName("todo_entry")) {
+ item.entryDate = newDateTime(
+ row.getResultByName("todo_entry"),
+ row.getResultByName("todo_entry_tz")
+ );
+ }
+ if (row.getResultByName("todo_due")) {
+ item.dueDate = newDateTime(
+ row.getResultByName("todo_due"),
+ row.getResultByName("todo_due_tz")
+ );
+ }
+ if (row.getResultByName("todo_stamp")) {
+ item.setProperty("DTSTAMP", newDateTime(row.getResultByName("todo_stamp"), "UTC"));
+ }
+ if (row.getResultByName("todo_completed")) {
+ item.completedDate = newDateTime(
+ row.getResultByName("todo_completed"),
+ row.getResultByName("todo_completed_tz")
+ );
+ }
+ if (row.getResultByName("todo_complete")) {
+ item.percentComplete = row.getResultByName("todo_complete");
+ }
+ if (flags & CAL_ITEM_FLAG.EVENT_ALLDAY) {
+ if (item.entryDate) {
+ item.entryDate.isDate = true;
+ }
+ if (item.dueDate) {
+ item.dueDate.isDate = true;
+ }
+ }
+
+ // This must be done last to keep the modification time intact.
+ this.#getItemBaseFromRow(row, item);
+ if (getAdditionalData) {
+ await this.#getAdditionalDataForItem(item, row.getResultByName("flags"));
+ item.makeImmutable();
+ }
+ return item;
+ }
+
+ /**
+ * After we get the base item, we need to check if we need to pull in
+ * any extra data from other tables. We do that here.
+ */
+ async #getAdditionalDataForItem(item, flags) {
+ // This is needed to keep the modification time intact.
+ let savedLastModifiedTime = item.lastModifiedTime;
+
+ if (flags & CAL_ITEM_FLAG.HAS_ATTENDEES) {
+ let selectItem = null;
+ if (item.recurrenceId == null) {
+ selectItem = this.statements.mSelectAttendeesForItem;
+ } else {
+ selectItem = this.statements.mSelectAttendeesForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectItem, "recurrence_id", item.recurrenceId);
+ }
+
+ try {
+ this.db.prepareStatement(selectItem);
+ selectItem.params.item_id = item.id;
+ await this.db.executeAsync(selectItem, row => {
+ let attendee = new lazy.CalAttendee(this.#unfoldIcalString(row));
+ if (attendee && attendee.id) {
+ if (attendee.isOrganizer) {
+ item.organizer = attendee;
+ } else {
+ item.addAttendee(attendee);
+ }
+ } else {
+ cal.WARN(
+ `[calStorageCalendar] Skipping invalid attendee for item '${item.title}' (${item.id}).`
+ );
+ }
+ });
+ } catch (e) {
+ this.db.logError(`Error getting attendees for item '${item.title}' (${item.id})!`, e);
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_PROPERTIES) {
+ let selectItem = null;
+ let selectParam = null;
+ if (item.recurrenceId == null) {
+ selectItem = this.statements.mSelectPropertiesForItem;
+ selectParam = this.statements.mSelectParametersForItem;
+ } else {
+ selectItem = this.statements.mSelectPropertiesForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectItem, "recurrence_id", item.recurrenceId);
+ selectParam = this.statements.mSelectParametersForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectParam, "recurrence_id", item.recurrenceId);
+ }
+
+ try {
+ this.db.prepareStatement(selectItem);
+ selectItem.params.item_id = item.id;
+ await this.db.executeAsync(selectItem, row => {
+ let name = row.getResultByName("key");
+ switch (name) {
+ case "DURATION":
+ // for events DTEND/DUE is enforced by calEvent/calTodo, so suppress DURATION:
+ break;
+ case "CATEGORIES": {
+ let cats = cal.category.stringToArray(row.getResultByName("value"));
+ item.setCategories(cats);
+ break;
+ }
+ default:
+ let value = row.getResultByName("value");
+ item.setProperty(name, value);
+ break;
+ }
+ });
+
+ this.db.prepareStatement(selectParam);
+ selectParam.params.item_id = item.id;
+ await this.db.executeAsync(selectParam, row => {
+ let prop = row.getResultByName("key1");
+ let param = row.getResultByName("key2");
+ let value = row.getResultByName("value");
+ item.setPropertyParameter(prop, param, value);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting extra properties for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_RECURRENCE) {
+ if (item.recurrenceId) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let recInfo = new lazy.CalRecurrenceInfo(item);
+ item.recurrenceInfo = recInfo;
+
+ try {
+ this.db.prepareStatement(this.statements.mSelectRecurrenceForItem);
+ this.statements.mSelectRecurrenceForItem.params.item_id = item.id;
+ await this.db.executeAsync(this.statements.mSelectRecurrenceForItem, row => {
+ let ritem = this.#getRecurrenceItemFromRow(row);
+ recInfo.appendRecurrenceItem(ritem);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting recurrence for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_EXCEPTIONS) {
+ // it's safe that we don't run into this branch again for exceptions
+ // (getAdditionalDataForItem->get[Event|Todo]FromRow->getAdditionalDataForItem):
+ // every excepton has a recurrenceId and isn't flagged as CAL_ITEM_FLAG.HAS_EXCEPTIONS
+ if (item.recurrenceId) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+
+ let rec = item.recurrenceInfo;
+
+ if (item.isEvent()) {
+ this.statements.mSelectEventExceptions.params.id = item.id;
+ this.db.prepareStatement(this.statements.mSelectEventExceptions);
+ try {
+ await this.db.executeAsync(this.statements.mSelectEventExceptions, async row => {
+ let exc = await this.getEventFromRow(row, false);
+ rec.modifyException(exc, true);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting exceptions for event '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ } else if (item.isTodo()) {
+ this.statements.mSelectTodoExceptions.params.id = item.id;
+ this.db.prepareStatement(this.statements.mSelectTodoExceptions);
+ try {
+ await this.db.executeAsync(this.statements.mSelectTodoExceptions, async row => {
+ let exc = await this.getTodoFromRow(row, false);
+ rec.modifyException(exc, true);
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting exceptions for task '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_ATTACHMENTS) {
+ let selectAttachment = this.statements.mSelectAttachmentsForItem;
+ if (item.recurrenceId != null) {
+ selectAttachment = this.statements.mSelectAttachmentsForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectAttachment, "recurrence_id", item.recurrenceId);
+ }
+ try {
+ this.db.prepareStatement(selectAttachment);
+ selectAttachment.params.item_id = item.id;
+ await this.db.executeAsync(selectAttachment, row => {
+ item.addAttachment(new lazy.CalAttachment(this.#unfoldIcalString(row)));
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting attachments for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_RELATIONS) {
+ let selectRelation = this.statements.mSelectRelationsForItem;
+ if (item.recurrenceId != null) {
+ selectRelation = this.statements.mSelectRelationsForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectRelation, "recurrence_id", item.recurrenceId);
+ }
+ try {
+ this.db.prepareStatement(selectRelation);
+ selectRelation.params.item_id = item.id;
+ await this.db.executeAsync(selectRelation, row => {
+ item.addRelation(new lazy.CalRelation(this.#unfoldIcalString(row)));
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting relations for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ if (flags & CAL_ITEM_FLAG.HAS_ALARMS) {
+ let selectAlarm = this.statements.mSelectAlarmsForItem;
+ if (item.recurrenceId != null) {
+ selectAlarm = this.statements.mSelectAlarmsForItemWithRecurrenceId;
+ this.#setDateParamHelper(selectAlarm, "recurrence_id", item.recurrenceId);
+ }
+ try {
+ selectAlarm.params.item_id = item.id;
+ this.db.prepareStatement(selectAlarm);
+ await this.db.executeAsync(selectAlarm, row => {
+ item.addAlarm(new lazy.CalAlarm(this.#unfoldIcalString(row)));
+ });
+ } catch (e) {
+ this.db.logError(
+ "Error getting alarms for item '" + item.title + "' (" + item.id + ")!",
+ e
+ );
+ }
+ }
+
+ this.#fixGoogleCalendarDescriptionIfNeeded(item);
+ // Restore the saved modification time
+ item.setProperty("LAST-MODIFIED", savedLastModifiedTime);
+ }
+
+ #getRecurrenceItemFromRow(row) {
+ let ritem;
+ let prop = cal.icsService.createIcalPropertyFromString(this.#unfoldIcalString(row));
+ switch (prop.propertyName) {
+ case "RDATE":
+ case "EXDATE":
+ ritem = cal.createRecurrenceDate();
+ break;
+ case "RRULE":
+ case "EXRULE":
+ ritem = cal.createRecurrenceRule();
+ break;
+ default:
+ throw new Error("Unknown recurrence item: " + prop.propertyName);
+ }
+
+ ritem.icalProperty = prop;
+ return ritem;
+ }
+
+ /**
+ * Get an item from db given its id.
+ *
+ * @param {string} aID
+ */
+ async getItemById(aID) {
+ let item = null;
+ try {
+ // try events first
+ this.db.prepareStatement(this.statements.mSelectEvent);
+ this.statements.mSelectEvent.params.id = aID;
+ await this.db.executeAsync(this.statements.mSelectEvent, async row => {
+ item = await this.getEventFromRow(row);
+ });
+ } catch (e) {
+ this.db.logError("Error selecting item by id " + aID + "!", e);
+ }
+
+ // try todo if event fails
+ if (!item) {
+ try {
+ this.db.prepareStatement(this.statements.mSelectTodo);
+ this.statements.mSelectTodo.params.id = aID;
+ await this.db.executeAsync(this.statements.mSelectTodo, async row => {
+ item = await this.getTodoFromRow(row);
+ });
+ } catch (e) {
+ this.db.logError("Error selecting item by id " + aID + "!", e);
+ }
+ }
+ return item;
+ }
+
+ #setDateParamHelper(params, entryname, cdt) {
+ if (cdt) {
+ params.bindByName(entryname, cdt.nativeTime);
+ let timezone = cdt.timezone;
+ let ownTz = cal.timezoneService.getTimezone(timezone.tzid);
+ if (ownTz) {
+ // if we know that TZID, we use it
+ params.bindByName(entryname + "_tz", ownTz.tzid);
+ } else if (timezone.icalComponent) {
+ // foreign one
+ params.bindByName(entryname + "_tz", timezone.icalComponent.serializeToICS());
+ } else {
+ // timezone component missing
+ params.bindByName(entryname + "_tz", "floating");
+ }
+ } else {
+ params.bindByName(entryname, null);
+ params.bindByName(entryname + "_tz", null);
+ }
+ }
+
+ /**
+ * Adds an item to the database, the item should have an id that is not
+ * already in use.
+ *
+ * @param {calIItemBase} item
+ */
+ async addItem(item) {
+ let stmts = new Map();
+ this.#prepareItem(stmts, item);
+ for (let [stmt, array] of stmts) {
+ stmt.bindParameters(array);
+ }
+ await this.db.executeAsync([...stmts.keys()]);
+ }
+
+ // The prepare* functions prepare the database bits
+ // to write the given item type. They're to return
+ // any bits they want or'd into flags, which will be
+ // prepared for writing by #prepareEvent/#prepareTodo.
+ //
+ #prepareItem(stmts, item) {
+ let flags = 0;
+
+ flags |= this.#prepareAttendees(stmts, item);
+ flags |= this.#prepareRecurrence(stmts, item);
+ flags |= this.#prepareProperties(stmts, item);
+ flags |= this.#prepareAttachments(stmts, item);
+ flags |= this.#prepareRelations(stmts, item);
+ flags |= this.#prepareAlarms(stmts, item);
+
+ if (item.isEvent()) {
+ this.#prepareEvent(stmts, item, flags);
+ } else if (item.isTodo()) {
+ this.#prepareTodo(stmts, item, flags);
+ } else {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ }
+
+ #prepareEvent(stmts, item, flags) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertEvent);
+ let params = this.db.prepareAsyncParams(array);
+
+ this.#setupItemBaseParams(item, params);
+
+ this.#setDateParamHelper(params, "event_start", item.startDate);
+ this.#setDateParamHelper(params, "event_end", item.endDate);
+ let dtstamp = item.stampTime;
+ params.bindByName("event_stamp", dtstamp && dtstamp.nativeTime);
+
+ if (item.startDate.isDate) {
+ flags |= CAL_ITEM_FLAG.EVENT_ALLDAY;
+ }
+
+ params.bindByName("flags", flags);
+
+ array.addParams(params);
+ }
+
+ #prepareTodo(stmts, item, flags) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertTodo);
+ let params = this.db.prepareAsyncParams(array);
+
+ this.#setupItemBaseParams(item, params);
+
+ this.#setDateParamHelper(params, "todo_entry", item.entryDate);
+ this.#setDateParamHelper(params, "todo_due", item.dueDate);
+ let dtstamp = item.stampTime;
+ params.bindByName("todo_stamp", dtstamp && dtstamp.nativeTime);
+ this.#setDateParamHelper(params, "todo_completed", item.getProperty("COMPLETED"));
+
+ params.bindByName("todo_complete", item.getProperty("PERCENT-COMPLETED"));
+
+ let someDate = item.entryDate || item.dueDate;
+ if (someDate && someDate.isDate) {
+ flags |= CAL_ITEM_FLAG.EVENT_ALLDAY;
+ }
+
+ params.bindByName("flags", flags);
+
+ array.addParams(params);
+ }
+
+ #setupItemBaseParams(item, params) {
+ params.bindByName("id", item.id);
+
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+
+ let tmp = item.getProperty("CREATED");
+ params.bindByName("time_created", tmp && tmp.nativeTime);
+
+ tmp = item.getProperty("LAST-MODIFIED");
+ params.bindByName("last_modified", tmp && tmp.nativeTime);
+
+ params.bindByName("title", item.getProperty("SUMMARY"));
+ params.bindByName("priority", item.getProperty("PRIORITY"));
+ params.bindByName("privacy", item.getProperty("CLASS"));
+ params.bindByName("ical_status", item.getProperty("STATUS"));
+
+ params.bindByName("alarm_last_ack", item.alarmLastAck && item.alarmLastAck.nativeTime);
+ }
+
+ #prepareAttendees(stmts, item) {
+ let attendees = item.getAttendees();
+ if (item.organizer) {
+ attendees = attendees.concat([]);
+ attendees.push(item.organizer);
+ }
+ if (attendees.length > 0) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertAttendee);
+ for (let att of attendees) {
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("item_id", item.id);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("icalString", att.icalString);
+ array.addParams(params);
+ }
+
+ return CAL_ITEM_FLAG.HAS_ATTENDEES;
+ }
+
+ return 0;
+ }
+
+ #prepareProperty(stmts, item, propName, propValue) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertProperty);
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("key", propName);
+ let wPropValue = cal.wrapInstance(propValue, Ci.calIDateTime);
+ if (wPropValue) {
+ params.bindByName("value", wPropValue.nativeTime);
+ } else {
+ try {
+ params.bindByName("value", propValue);
+ } catch (e) {
+ // The storage service throws an NS_ERROR_ILLEGAL_VALUE in
+ // case pval is something complex (i.e not a string or
+ // number). Swallow this error, leaving the value empty.
+ if (e.result != Cr.NS_ERROR_ILLEGAL_VALUE) {
+ throw e;
+ }
+ params.bindByName("value", null);
+ }
+ }
+ params.bindByName("item_id", item.id);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ array.addParams(params);
+ }
+
+ #prepareParameter(stmts, item, propName, paramName, propValue) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertParameter);
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("key1", propName);
+ params.bindByName("key2", paramName);
+ let wPropValue = cal.wrapInstance(propValue, Ci.calIDateTime);
+ if (wPropValue) {
+ params.bindByName("value", wPropValue.nativeTime);
+ } else {
+ try {
+ params.bindByName("value", propValue);
+ } catch (e) {
+ // The storage service throws an NS_ERROR_ILLEGAL_VALUE in
+ // case pval is something complex (i.e not a string or
+ // number). Swallow this error, leaving the value empty.
+ if (e.result != Cr.NS_ERROR_ILLEGAL_VALUE) {
+ throw e;
+ }
+ params.bindByName("value", null);
+ }
+ }
+ params.bindByName("item_id", item.id);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ array.addParams(params);
+ }
+
+ #prepareProperties(stmts, item) {
+ let ret = 0;
+ for (let [name, value] of item.properties) {
+ ret = CAL_ITEM_FLAG.HAS_PROPERTIES;
+ if (item.isPropertyPromoted(name)) {
+ continue;
+ }
+ this.#prepareProperty(stmts, item, name, value);
+ // Overridden parameters still enumerate even if their value is now empty.
+ if (item.hasProperty(name)) {
+ for (let param of item.getParameterNames(name)) {
+ value = item.getPropertyParameter(name, param);
+ this.#prepareParameter(stmts, item, name, param, value);
+ }
+ }
+ }
+
+ let cats = item.getCategories();
+ if (cats.length > 0) {
+ ret = CAL_ITEM_FLAG.HAS_PROPERTIES;
+ this.#prepareProperty(stmts, item, "CATEGORIES", cal.category.arrayToString(cats));
+ }
+
+ return ret;
+ }
+
+ #prepareRecurrence(stmts, item) {
+ let flags = 0;
+
+ let rec = item.recurrenceInfo;
+ if (rec) {
+ flags = CAL_ITEM_FLAG.HAS_RECURRENCE;
+ let ritems = rec.getRecurrenceItems();
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertRecurrence);
+ for (let ritem of ritems) {
+ let params = this.db.prepareAsyncParams(array);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", ritem.icalString);
+ array.addParams(params);
+ }
+
+ let exceptions = rec.getExceptionIds();
+ if (exceptions.length > 0) {
+ flags |= CAL_ITEM_FLAG.HAS_EXCEPTIONS;
+
+ // we need to serialize each exid as a separate
+ // event/todo; setupItemBase will handle
+ // writing the recurrenceId for us
+ for (let exid of exceptions) {
+ let ex = rec.getExceptionFor(exid);
+ if (!ex) {
+ throw Components.Exception("", Cr.NS_ERROR_UNEXPECTED);
+ }
+ this.#prepareItem(stmts, ex);
+ }
+ }
+ } else if (item.recurrenceId && item.recurrenceId.isDate) {
+ flags |= CAL_ITEM_FLAG.RECURRENCE_ID_ALLDAY;
+ }
+
+ return flags;
+ }
+
+ #prepareAttachments(stmts, item) {
+ let attachments = item.getAttachments();
+ if (attachments && attachments.length > 0) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertAttachment);
+ for (let att of attachments) {
+ let params = this.db.prepareAsyncParams(array);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", att.icalString);
+
+ array.addParams(params);
+ }
+ return CAL_ITEM_FLAG.HAS_ATTACHMENTS;
+ }
+ return 0;
+ }
+
+ #prepareRelations(stmts, item) {
+ let relations = item.getRelations();
+ if (relations && relations.length > 0) {
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertRelation);
+ for (let rel of relations) {
+ let params = this.db.prepareAsyncParams(array);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", rel.icalString);
+
+ array.addParams(params);
+ }
+ return CAL_ITEM_FLAG.HAS_RELATIONS;
+ }
+ return 0;
+ }
+
+ #prepareAlarms(stmts, item) {
+ let alarms = item.getAlarms();
+ if (alarms.length < 1) {
+ return 0;
+ }
+
+ let array = this.db.prepareAsyncStatement(stmts, this.statements.mInsertAlarm);
+ for (let alarm of alarms) {
+ let params = this.db.prepareAsyncParams(array);
+ this.#setDateParamHelper(params, "recurrence_id", item.recurrenceId);
+ params.bindByName("item_id", item.id);
+ params.bindByName("icalString", alarm.icalString);
+
+ array.addParams(params);
+ }
+
+ return CAL_ITEM_FLAG.HAS_ALARMS;
+ }
+
+ /**
+ * Deletes the item with the given item id.
+ *
+ * @param {string} id The id of the item to delete.
+ * @param {boolean} keepMeta If true, leave metadata for the item.
+ */
+ async deleteItemById(id, keepMeta) {
+ let stmts = [];
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteAttendees, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteProperties, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteRecurrence, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteEvent, "id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteTodo, "id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteAttachments, "item_id", id);
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteRelations, "item_id", id);
+ if (!keepMeta) {
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteMetaData, "item_id", id);
+ }
+ this.db.prepareItemStatement(stmts, this.statements.mDeleteAlarms, "item_id", id);
+ await this.db.executeAsync(stmts);
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageMetaDataModel.jsm b/comm/calendar/providers/storage/CalStorageMetaDataModel.jsm
new file mode 100644
index 0000000000..b004b3d45b
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageMetaDataModel.jsm
@@ -0,0 +1,94 @@
+/* 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 EXPORTED_SYMBOLS = ["CalStorageMetaDataModel"];
+
+var { CalStorageModelBase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelBase.jsm"
+);
+
+/**
+ * CalStorageMetaDataModel provides methods for manipulating the metadata stored
+ * on items.
+ */
+class CalStorageMetaDataModel extends CalStorageModelBase {
+ /**
+ * Adds meta data for an item.
+ *
+ * @param {string} id
+ * @param {string} value
+ */
+ addMetaData(id, value) {
+ try {
+ this.db.prepareStatement(this.statements.mInsertMetaData);
+ let params = this.statements.mInsertMetaData.params;
+ params.item_id = id;
+ params.value = value;
+ this.statements.mInsertMetaData.executeStep();
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_ILLEGAL_VALUE) {
+ this.db.logError("Unknown error!", e);
+ } else {
+ // The storage service throws an NS_ERROR_ILLEGAL_VALUE in
+ // case pval is something complex (i.e not a string or
+ // number). Swallow this error, leaving the value empty.
+ this.db.logError("Error setting metadata for id " + id + "!", e);
+ }
+ } finally {
+ this.statements.mInsertMetaData.reset();
+ }
+ }
+
+ /**
+ * Deletes meta data for an item using its id.
+ */
+ deleteMetaDataById(id) {
+ this.db.executeSyncItemStatement(this.statements.mDeleteMetaData, "item_id", id);
+ }
+
+ /**
+ * Gets meta data for an item given its id.
+ *
+ * @param {string} id
+ */
+ getMetaData(id) {
+ let query = this.statements.mSelectMetaData;
+ let value = null;
+ try {
+ this.db.prepareStatement(query);
+ query.params.item_id = id;
+
+ if (query.executeStep()) {
+ value = query.row.value;
+ }
+ } catch (e) {
+ this.db.logError("Error getting metadata for id " + id + "!", e);
+ } finally {
+ query.reset();
+ }
+
+ return value;
+ }
+
+ /**
+ * Returns the meta data for all items.
+ *
+ * @param {string} key - Specifies which column to return.
+ */
+ getAllMetaData(key) {
+ let query = this.statements.mSelectAllMetaData;
+ let results = [];
+ try {
+ this.db.prepareStatement(query);
+ while (query.executeStep()) {
+ results.push(query.row[key]);
+ }
+ } catch (e) {
+ this.db.logError(`Error getting all metadata ${key == "item_id" ? "IDs" : "values"} ` + e);
+ } finally {
+ query.reset();
+ }
+ return results;
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageModelBase.jsm b/comm/calendar/providers/storage/CalStorageModelBase.jsm
new file mode 100644
index 0000000000..cf24606192
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageModelBase.jsm
@@ -0,0 +1,65 @@
+/* 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 = ["CalStorageModelBase"];
+
+/**
+ * CalStorageModelBase is the parent class for the storage calendar models.
+ * The idea here is to leave most of the adjustments and integrity checks to
+ * CalStorageCalendar (or other classes) while focusing mostly on
+ * retrieval/persistence in the children of this class.
+ */
+class CalStorageModelBase {
+ /**
+ * @type {CalStorageDatabase}
+ */
+ db = null;
+
+ /**
+ * @type {CalStorageStatements}
+ */
+ statements = null;
+
+ /**
+ * @type {calICalendar}
+ */
+ calendar = null;
+
+ /**
+ * @param {CalStorageDatabase} db
+ * @param {CalStorageStatements} statements
+ * @param {calICalendar} calendar
+ *
+ * @throws - If unable to initialize SQL statements.
+ */
+ constructor(db, statements, calendar) {
+ this.db = db;
+ this.statements = statements;
+ this.calendar = calendar;
+ }
+
+ /**
+ * Delete all data stored for the calendar this model's database connection
+ * is associated with.
+ */
+ async deleteCalendar() {
+ let stmts = [];
+ if (this.statements.mDeleteEventExtras) {
+ for (let stmt of this.statements.mDeleteEventExtras) {
+ stmts.push(this.db.prepareStatement(stmt));
+ }
+ }
+
+ if (this.statements.mDeleteTodoExtras) {
+ for (let stmt of this.statements.mDeleteTodoExtras) {
+ stmts.push(this.db.prepareStatement(stmt));
+ }
+ }
+
+ stmts.push(this.db.prepareStatement(this.statements.mDeleteAllEvents));
+ stmts.push(this.db.prepareStatement(this.statements.mDeleteAllTodos));
+ stmts.push(this.db.prepareStatement(this.statements.mDeleteAllMetaData));
+ await this.db.executeAsync(stmts);
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageModelFactory.jsm b/comm/calendar/providers/storage/CalStorageModelFactory.jsm
new file mode 100644
index 0000000000..cf36791eba
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageModelFactory.jsm
@@ -0,0 +1,52 @@
+/* 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 EXPORTED_SYMBOLS = ["CalStorageModelFactory"];
+
+var { CalStorageItemModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageItemModel.jsm"
+);
+var { CalStorageCachedItemModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageCachedItemModel.jsm"
+);
+var { CalStorageOfflineModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageOfflineModel.jsm"
+);
+var { CalStorageMetaDataModel } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageMetaDataModel.jsm"
+);
+
+/**
+ * CalStorageModelFactory provides a convenience method for creating instances
+ * of the storage calendar models. Use to avoid having to import each one
+ * directly.
+ */
+class CalStorageModelFactory {
+ /**
+ * Creates an instance of a CalStorageModel for the specified type.
+ *
+ * @param {"item"|"offline"|"metadata"} type - The model type desired.
+ * @param {mozIStorageAsyncConnection} db - The database connection to use.
+ * @param {CalStorageStatement} stmts
+ * @param {CalStorageCalendar} calendar - The calendar associated with the
+ * model.
+ */
+ static createInstance(type, db, stmts, calendar) {
+ switch (type) {
+ case "item":
+ return new CalStorageItemModel(db, stmts, calendar);
+
+ case "cached-item":
+ return new CalStorageCachedItemModel(db, stmts, calendar);
+
+ case "offline":
+ return new CalStorageOfflineModel(db, stmts, calendar);
+
+ case "metadata":
+ return new CalStorageMetaDataModel(db, stmts, calendar);
+ }
+
+ throw new Error(`Unknown model type "${type}" specified!`);
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageOfflineModel.jsm b/comm/calendar/providers/storage/CalStorageOfflineModel.jsm
new file mode 100644
index 0000000000..23f6cd5330
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageOfflineModel.jsm
@@ -0,0 +1,54 @@
+/* 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 EXPORTED_SYMBOLS = ["CalStorageOfflineModel"];
+
+var { CalStorageModelBase } = ChromeUtils.import(
+ "resource:///modules/calendar/CalStorageModelBase.jsm"
+);
+
+/**
+ * CalStorageOfflineModel provides methods for manipulating the offline flags
+ * of items.
+ */
+class CalStorageOfflineModel extends CalStorageModelBase {
+ /**
+ * Returns the offline_journal column value for an item.
+ *
+ * @param {calIItemBase} item
+ *
+ * @returns {number}
+ */
+ async getItemOfflineFlag(item) {
+ let flag = null;
+ let query = item.isEvent() ? this.statements.mSelectEvent : this.statements.mSelectTodo;
+ this.db.prepareStatement(query);
+ query.params.id = item.id;
+ await this.db.executeAsync(query, row => {
+ flag = row.getResultByName("offline_journal") || null;
+ });
+ return flag;
+ }
+
+ /**
+ * Sets the offline_journal column value for an item.
+ *
+ * @param {calIItemBase} item
+ * @param {number} flag
+ */
+ async setOfflineJournalFlag(item, flag) {
+ let id = item.id;
+ let query = item.isEvent()
+ ? this.statements.mEditEventOfflineFlag
+ : this.statements.mEditTodoOfflineFlag;
+ this.db.prepareStatement(query);
+ query.params.id = id;
+ query.params.offline_journal = flag || null;
+ try {
+ await this.db.executeAsync(query);
+ } catch (e) {
+ this.db.logError("Error setting offline journal flag for " + item.title, e);
+ }
+ }
+}
diff --git a/comm/calendar/providers/storage/CalStorageStatements.jsm b/comm/calendar/providers/storage/CalStorageStatements.jsm
new file mode 100644
index 0000000000..4906e036e3
--- /dev/null
+++ b/comm/calendar/providers/storage/CalStorageStatements.jsm
@@ -0,0 +1,751 @@
+/* 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 EXPORTED_SYMBOLS = ["CalStorageStatements"];
+
+const cICL = Ci.calIChangeLog;
+
+/**
+ * CalStorageStatements contains the mozIStorageBaseStatements used by the
+ * various storage calendar models. Remember to call the finalize() method when
+ * shutting down the db.
+ */
+class CalStorageStatements {
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectEvent = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectTodo = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement} mSelectNonRecurringEventsByRange
+ */
+ mSelectNonRecurringEventsByRange = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement} mSelectNonRecurringTodosByRange
+ */
+ mSelectNonRecurringTodosByRange = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttendeesForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttendeesForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllAttendees = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectPropertiesForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectPropertiesForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllProperties = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectParametersForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectParametersForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllParameters = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectRecurrenceForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllRecurrences = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectEventsWithRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectTodosWithRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectEventExceptions = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllEventExceptions = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectTodoExceptions = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllTodoExceptions = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mSelectMetaData = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mSelectAllMetaData = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectRelationsForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllRelations = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectRelationsForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAlarmsForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAlarmsForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllAlarms = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttachmentsForItem = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAttachmentsForItemWithRecurrenceId = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mSelectAllAttachments = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertEvent = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertTodo = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertProperty = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertParameter = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertAttendee = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertAttachment = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertRelation = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mInsertMetaData = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mInsertAlarm = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mEditEventOfflineFlag = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mEditTodoOfflineFlag = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteEvent = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteTodo = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAttendees = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteProperties = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteParameters = null;
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteRecurrence = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAttachments = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteRelations = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mDeleteMetaData = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAlarms = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement[]}
+ */
+ mDeleteEventExtras = [];
+
+ /**
+ * @type {mozIStorageAsyncStatement[]}
+ */
+ mDeleteTodoExtras = [];
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAllEvents = null;
+
+ /**
+ * @type {mozIStorageAsyncStatement}
+ */
+ mDeleteAllTodos = null;
+
+ /**
+ * @type {mozIStorageStatement}
+ */
+ mDeleteAllMetaData = null;
+
+ /**
+ * @param {CalStorageDatabase} db
+ *
+ * @throws - If unable to initialize SQL statements.
+ */
+ constructor(db) {
+ this.mSelectEvent = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL
+ LIMIT 1`
+ );
+
+ this.mSelectTodo = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL
+ LIMIT 1`
+ );
+
+ // The more readable version of the next where-clause is:
+ // WHERE ((event_end > :range_start OR
+ // (event_end = :range_start AND
+ // event_start = :range_start))
+ // AND event_start < :range_end)
+ //
+ // but that doesn't work with floating start or end times. The logic
+ // is the same though.
+ // For readability, a few helpers:
+ let floatingEventStart = "event_start_tz = 'floating' AND event_start";
+ let nonFloatingEventStart = "event_start_tz != 'floating' AND event_start";
+ let floatingEventEnd = "event_end_tz = 'floating' AND event_end";
+ let nonFloatingEventEnd = "event_end_tz != 'floating' AND event_end";
+ // The query needs to take both floating and non floating into account.
+ this.mSelectNonRecurringEventsByRange = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE
+ ((${floatingEventEnd} > :range_start + :start_offset) OR
+ (${nonFloatingEventEnd} > :range_start) OR
+ (((${floatingEventEnd} = :range_start + :start_offset) OR
+ (${nonFloatingEventEnd} = :range_start)) AND
+ ((${floatingEventStart} = :range_start + :start_offset) OR
+ (${nonFloatingEventStart} = :range_start))))
+ AND
+ ((${floatingEventStart} < :range_end + :end_offset) OR
+ (${nonFloatingEventStart} < :range_end))
+ AND cal_id = :cal_id AND flags & 16 == 0 AND recurrence_id IS NULL
+ AND ((:offline_journal IS NULL
+ AND (offline_journal IS NULL
+ OR offline_journal != ${cICL.OFFLINE_FLAG_DELETED_RECORD}))
+ OR (offline_journal == :offline_journal))`
+ );
+
+ //
+ // WHERE (due > rangeStart AND (entry IS NULL OR entry < rangeEnd)) OR
+ // (due = rangeStart AND (entry IS NULL OR entry = rangeStart)) OR
+ // (due IS NULL AND (entry >= rangeStart AND entry < rangeEnd)) OR
+ // (entry IS NULL AND (completed > rangeStart OR completed IS NULL))
+ //
+ let floatingTodoEntry = "todo_entry_tz = 'floating' AND todo_entry";
+ let nonFloatingTodoEntry = "todo_entry_tz != 'floating' AND todo_entry";
+ let floatingTodoDue = "todo_due_tz = 'floating' AND todo_due";
+ let nonFloatingTodoDue = "todo_due_tz != 'floating' AND todo_due";
+ let floatingCompleted = "todo_completed_tz = 'floating' AND todo_completed";
+ let nonFloatingCompleted = "todo_completed_tz != 'floating' AND todo_completed";
+
+ this.mSelectNonRecurringTodosByRange = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE
+ ((((${floatingTodoDue} > :range_start + :start_offset) OR
+ (${nonFloatingTodoDue} > :range_start)) AND
+ ((todo_entry IS NULL) OR
+ ((${floatingTodoEntry} < :range_end + :end_offset) OR
+ (${nonFloatingTodoEntry} < :range_end)))) OR
+ (((${floatingTodoDue} = :range_start + :start_offset) OR
+ (${nonFloatingTodoDue} = :range_start)) AND
+ ((todo_entry IS NULL) OR
+ ((${floatingTodoEntry} = :range_start + :start_offset) OR
+ (${nonFloatingTodoEntry} = :range_start)))) OR
+ ((todo_due IS NULL) AND
+ (((${floatingTodoEntry} >= :range_start + :start_offset) OR
+ (${nonFloatingTodoEntry} >= :range_start)) AND
+ ((${floatingTodoEntry} < :range_end + :end_offset) OR
+ (${nonFloatingTodoEntry} < :range_end)))) OR
+ ((todo_entry IS NULL) AND
+ (((${floatingCompleted} > :range_start + :start_offset) OR
+ (${nonFloatingCompleted} > :range_start)) OR
+ (todo_completed IS NULL))))
+ AND cal_id = :cal_id AND flags & 16 == 0 AND recurrence_id IS NULL
+ AND ((:offline_journal IS NULL
+ AND (offline_journal IS NULL
+ OR offline_journal != ${cICL.OFFLINE_FLAG_DELETED_RECORD}))
+ OR (offline_journal == :offline_journal))`
+ );
+
+ this.mSelectEventsWithRecurrence = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE flags & 16 == 16
+ AND cal_id = :cal_id
+ AND recurrence_id is NULL`
+ );
+
+ this.mSelectTodosWithRecurrence = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE flags & 16 == 16
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectEventExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+ this.mSelectAllEventExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_events
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+
+ this.mSelectTodoExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE id = :id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+ this.mSelectAllTodoExceptions = db.createAsyncStatement(
+ `SELECT * FROM cal_todos
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NOT NULL`
+ );
+
+ this.mSelectAttendeesForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_attendees
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectAttendeesForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_attendees
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllAttendees = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_attendees
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectPropertiesForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_properties
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectPropertiesForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_properties
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllProperties = db.createAsyncStatement(
+ `SELECT item_id, key, value FROM cal_properties
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectParametersForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_parameters
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectParametersForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_parameters
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllParameters = db.createAsyncStatement(
+ `SELECT item_id, key1, key2, value FROM cal_parameters
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectRecurrenceForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_recurrence
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id`
+ );
+ this.mSelectAllRecurrences = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_recurrence
+ WHERE cal_id = :cal_id`
+ );
+
+ this.mSelectAttachmentsForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_attachments
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectAttachmentsForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_attachments
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllAttachments = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_attachments
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectRelationsForItem = db.createAsyncStatement(
+ `SELECT * FROM cal_relations
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+ this.mSelectRelationsForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT * FROM cal_relations
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllRelations = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_relations
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectMetaData = db.createStatement(
+ `SELECT * FROM cal_metadata
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id`
+ );
+
+ this.mSelectAllMetaData = db.createStatement(
+ `SELECT * FROM cal_metadata
+ WHERE cal_id = :cal_id`
+ );
+
+ this.mSelectAlarmsForItem = db.createAsyncStatement(
+ `SELECT icalString FROM cal_alarms
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ this.mSelectAlarmsForItemWithRecurrenceId = db.createAsyncStatement(
+ `SELECT icalString FROM cal_alarms
+ WHERE item_id = :item_id
+ AND cal_id = :cal_id
+ AND recurrence_id = :recurrence_id
+ AND recurrence_id_tz = :recurrence_id_tz`
+ );
+ this.mSelectAllAlarms = db.createAsyncStatement(
+ `SELECT item_id, icalString FROM cal_alarms
+ WHERE cal_id = :cal_id
+ AND recurrence_id IS NULL`
+ );
+
+ // insert statements
+ this.mInsertEvent = db.createAsyncStatement(
+ `INSERT INTO cal_events
+ (cal_id, id, time_created, last_modified,
+ title, priority, privacy, ical_status, flags,
+ event_start, event_start_tz, event_end, event_end_tz, event_stamp,
+ recurrence_id, recurrence_id_tz, alarm_last_ack)
+ VALUES (:cal_id, :id, :time_created, :last_modified,
+ :title, :priority, :privacy, :ical_status, :flags,
+ :event_start, :event_start_tz, :event_end, :event_end_tz, :event_stamp,
+ :recurrence_id, :recurrence_id_tz, :alarm_last_ack)`
+ );
+
+ this.mInsertTodo = db.createAsyncStatement(
+ `INSERT INTO cal_todos
+ (cal_id, id, time_created, last_modified,
+ title, priority, privacy, ical_status, flags,
+ todo_entry, todo_entry_tz, todo_due, todo_due_tz, todo_stamp,
+ todo_completed, todo_completed_tz, todo_complete,
+ recurrence_id, recurrence_id_tz, alarm_last_ack)
+ VALUES (:cal_id, :id, :time_created, :last_modified,
+ :title, :priority, :privacy, :ical_status, :flags,
+ :todo_entry, :todo_entry_tz, :todo_due, :todo_due_tz, :todo_stamp,
+ :todo_completed, :todo_completed_tz, :todo_complete,
+ :recurrence_id, :recurrence_id_tz, :alarm_last_ack)`
+ );
+ this.mInsertProperty = db.createAsyncStatement(
+ `INSERT INTO cal_properties (cal_id, item_id, recurrence_id, recurrence_id_tz, key, value)
+ VALUES (:cal_id, :item_id, :recurrence_id, :recurrence_id_tz, :key, :value)`
+ );
+ this.mInsertParameter = db.createAsyncStatement(
+ `INSERT INTO cal_parameters (cal_id, item_id, recurrence_id, recurrence_id_tz, key1, key2, value)
+ VALUES (:cal_id, :item_id, :recurrence_id, :recurrence_id_tz, :key1, :key2, :value)`
+ );
+ this.mInsertAttendee = db.createAsyncStatement(
+ `INSERT INTO cal_attendees
+ (cal_id, item_id, recurrence_id, recurrence_id_tz, icalString)
+ VALUES (:cal_id, :item_id, :recurrence_id, :recurrence_id_tz, :icalString)`
+ );
+ this.mInsertRecurrence = db.createAsyncStatement(
+ `INSERT INTO cal_recurrence
+ (cal_id, item_id, icalString)
+ VALUES (:cal_id, :item_id, :icalString)`
+ );
+
+ this.mInsertAttachment = db.createAsyncStatement(
+ `INSERT INTO cal_attachments
+ (cal_id, item_id, icalString, recurrence_id, recurrence_id_tz)
+ VALUES (:cal_id, :item_id, :icalString, :recurrence_id, :recurrence_id_tz)`
+ );
+
+ this.mInsertRelation = db.createAsyncStatement(
+ `INSERT INTO cal_relations
+ (cal_id, item_id, icalString, recurrence_id, recurrence_id_tz)
+ VALUES (:cal_id, :item_id, :icalString, :recurrence_id, :recurrence_id_tz)`
+ );
+
+ this.mInsertMetaData = db.createStatement(
+ `INSERT INTO cal_metadata
+ (cal_id, item_id, value)
+ VALUES (:cal_id, :item_id, :value)`
+ );
+
+ this.mInsertAlarm = db.createAsyncStatement(
+ `INSERT INTO cal_alarms
+ (cal_id, item_id, icalString, recurrence_id, recurrence_id_tz)
+ VALUES (:cal_id, :item_id, :icalString, :recurrence_id, :recurrence_id_tz)`
+ );
+ // Offline Operations
+ this.mEditEventOfflineFlag = db.createStatement(
+ `UPDATE cal_events SET offline_journal = :offline_journal
+ WHERE id = :id
+ AND cal_id = :cal_id`
+ );
+
+ this.mEditTodoOfflineFlag = db.createStatement(
+ `UPDATE cal_todos SET offline_journal = :offline_journal
+ WHERE id = :id
+ AND cal_id = :cal_id`
+ );
+
+ // delete statements
+ this.mDeleteEvent = db.createAsyncStatement(
+ "DELETE FROM cal_events WHERE id = :id AND cal_id = :cal_id"
+ );
+ this.mDeleteTodo = db.createAsyncStatement(
+ "DELETE FROM cal_todos WHERE id = :id AND cal_id = :cal_id"
+ );
+ this.mDeleteAttendees = db.createAsyncStatement(
+ "DELETE FROM cal_attendees WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteProperties = db.createAsyncStatement(
+ "DELETE FROM cal_properties WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteParameters = db.createAsyncStatement(
+ "DELETE FROM cal_parameters WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteRecurrence = db.createAsyncStatement(
+ "DELETE FROM cal_recurrence WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteAttachments = db.createAsyncStatement(
+ "DELETE FROM cal_attachments WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteRelations = db.createAsyncStatement(
+ "DELETE FROM cal_relations WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteMetaData = db.createStatement(
+ "DELETE FROM cal_metadata WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+ this.mDeleteAlarms = db.createAsyncStatement(
+ "DELETE FROM cal_alarms WHERE item_id = :item_id AND cal_id = :cal_id"
+ );
+
+ // These are only used when deleting an entire calendar
+ let extrasTables = [
+ "cal_attendees",
+ "cal_properties",
+ "cal_parameters",
+ "cal_recurrence",
+ "cal_attachments",
+ "cal_metadata",
+ "cal_relations",
+ "cal_alarms",
+ ];
+
+ this.mDeleteEventExtras = [];
+ this.mDeleteTodoExtras = [];
+
+ for (let table in extrasTables) {
+ this.mDeleteEventExtras[table] = db.createAsyncStatement(
+ `DELETE FROM ${extrasTables[table]}
+ WHERE item_id IN
+ (SELECT id FROM cal_events WHERE cal_id = :cal_id)
+ AND cal_id = :cal_id`
+ );
+ this.mDeleteTodoExtras[table] = db.createAsyncStatement(
+ `DELETE FROM ${extrasTables[table]}
+ WHERE item_id IN
+ (SELECT id FROM cal_todos WHERE cal_id = :cal_id)
+ AND cal_id = :cal_id`
+ );
+ }
+
+ // Note that you must delete the "extras" _first_ using the above two
+ // statements, before you delete the events themselves.
+ this.mDeleteAllEvents = db.createAsyncStatement(
+ "DELETE from cal_events WHERE cal_id = :cal_id"
+ );
+ this.mDeleteAllTodos = db.createAsyncStatement("DELETE from cal_todos WHERE cal_id = :cal_id");
+
+ this.mDeleteAllMetaData = db.createStatement("DELETE FROM cal_metadata WHERE cal_id = :cal_id");
+ }
+
+ /**
+ * Ensures all Db statements are properly cleaned up before shutdown by
+ * calling their finalize() method.
+ */
+ finalize() {
+ for (let key of Object.keys(this)) {
+ if (this[key] instanceof Ci.mozIStorageBaseStatement) {
+ this[key].finalize();
+ }
+ }
+ for (let stmt of this.mDeleteEventExtras) {
+ stmt.finalize();
+ }
+ for (let stmt of this.mDeleteTodoExtras) {
+ stmt.finalize();
+ }
+ }
+}
diff --git a/comm/calendar/providers/storage/calStorageHelpers.jsm b/comm/calendar/providers/storage/calStorageHelpers.jsm
new file mode 100644
index 0000000000..2f4e303beb
--- /dev/null
+++ b/comm/calendar/providers/storage/calStorageHelpers.jsm
@@ -0,0 +1,121 @@
+/* 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 { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+const { CalTimezone } = ChromeUtils.import("resource:///modules/CalTimezone.jsm");
+
+var { ICAL } = ChromeUtils.import("resource:///modules/calendar/Ical.jsm");
+
+const EXPORTED_SYMBOLS = ["CAL_ITEM_FLAG", "textToDate", "getTimezone", "newDateTime"];
+
+// Storage flags. These are used in the Database |flags| column to give
+// information about the item's features. For example, if the item has
+// attachments, the HAS_ATTACHMENTS flag is added to the flags column.
+var CAL_ITEM_FLAG = {
+ PRIVATE: 1,
+ HAS_ATTENDEES: 2,
+ HAS_PROPERTIES: 4,
+ EVENT_ALLDAY: 8,
+ HAS_RECURRENCE: 16,
+ HAS_EXCEPTIONS: 32,
+ HAS_ATTACHMENTS: 64,
+ HAS_RELATIONS: 128,
+ HAS_ALARMS: 256,
+ RECURRENCE_ID_ALLDAY: 512,
+};
+
+// The cache of foreign timezones
+var gForeignTimezonesCache = {};
+
+/**
+ * Transforms the text representation of this date object to a calIDateTime
+ * object.
+ *
+ * @param text The text to transform.
+ * @returns The resulting calIDateTime.
+ */
+function textToDate(text) {
+ let textval;
+ let timezone = "UTC";
+
+ if (text[0] == "Z") {
+ let strs = text.substr(2).split(":");
+ textval = parseInt(strs[0], 10);
+ timezone = strs[1].replace(/%:/g, ":").replace(/%%/g, "%");
+ } else {
+ textval = parseInt(text.substr(2), 10);
+ }
+
+ let date;
+ if (text[0] == "U" || text[0] == "Z") {
+ date = newDateTime(textval, timezone);
+ } else if (text[0] == "L") {
+ // is local time
+ date = newDateTime(textval, "floating");
+ }
+
+ if (text[1] == "D") {
+ date.isDate = true;
+ }
+ return date;
+}
+
+/**
+ * Gets the timezone for the given definition or identifier
+ *
+ * @param aTimezone The timezone data
+ * @returns The calITimezone object
+ */
+function getTimezone(aTimezone) {
+ let timezone = null;
+ if (aTimezone.startsWith("BEGIN:VTIMEZONE")) {
+ timezone = gForeignTimezonesCache[aTimezone]; // using full definition as key
+ if (!timezone) {
+ timezone = new CalTimezone(
+ ICAL.Timezone.fromData({
+ component: aTimezone,
+ })
+ );
+ gForeignTimezonesCache[aTimezone] = timezone;
+ }
+ } else {
+ timezone = cal.timezoneService.getTimezone(aTimezone);
+ }
+ return timezone;
+}
+
+/**
+ * Creates a new calIDateTime from the given native time and optionally
+ * the passed timezone. The timezone can either be the TZID of the timezone (in
+ * this case the timezone service will be asked for the definition), or a string
+ * representation of the timezone component (i.e a VTIMEZONE component).
+ *
+ * @param aNativeTime The native time, in microseconds
+ * @param aTimezone The timezone identifier or definition.
+ */
+function newDateTime(aNativeTime, aTimezone) {
+ let date = cal.createDateTime();
+
+ // Bug 751821 - Dates before 1970 were incorrectly stored with an unsigned nativeTime value, we need to
+ // convert back to a negative value
+ if (aNativeTime > 9223372036854776000) {
+ cal.WARN("[calStorageCalendar] Converting invalid native time value: " + aNativeTime);
+ aNativeTime = -9223372036854776000 + (aNativeTime - 9223372036854776000);
+ // Round to nearest second to fix microsecond rounding errors
+ aNativeTime = Math.round(aNativeTime / 1000000) * 1000000;
+ }
+
+ date.nativeTime = aNativeTime;
+ if (aTimezone) {
+ let timezone = getTimezone(aTimezone);
+ if (timezone) {
+ date = date.getInTimezone(timezone);
+ } else {
+ cal.ASSERT(false, "Timezone not available: " + aTimezone);
+ }
+ } else {
+ date.timezone = cal.dtz.floating;
+ }
+ return date;
+}
diff --git a/comm/calendar/providers/storage/calStorageUpgrade.jsm b/comm/calendar/providers/storage/calStorageUpgrade.jsm
new file mode 100644
index 0000000000..b5c23bd648
--- /dev/null
+++ b/comm/calendar/providers/storage/calStorageUpgrade.jsm
@@ -0,0 +1,1889 @@
+/* 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/. */
+
+/**
+ * Welcome to the storage database migration.
+ *
+ * If you would like to change anything in the database schema, you must follow
+ * some steps to make sure that upgrading from old versions works fine.
+ *
+ * First of all you must increment the DB_SCHEMA_VERSION variable below. Then
+ * you must write your upgrader. To do this, create a new function and add it to
+ * the upgrade object, similar to the existing upgraders below. An example is
+ * given below.
+ *
+ * An upgrader MUST update both the database (if it is passed) AND the table
+ * data javascript object. An example for a such object is in the v1/v2
+ * upgrader. The process of upgrading calls the latest upgrader with the
+ * database object and the current database version. The whole chain of
+ * upgraders is then called (down to v1). The first upgrader (v1/v2) provides
+ * the basic table data object. Each further upgrader then updates this object
+ * to correspond with the database tables and columns. No actual database calls
+ * are made until the first upgrader with a higher version than the current
+ * database version is called. When this version is arrived, both the table data
+ * object and the database are updated. This process continues until the
+ * database is at the latest version.
+ *
+ * Note that your upgrader is not necessarily called with a database object,
+ * for example if the user's database is already at a higher version. In this
+ * case your upgrader is called to compile the table data object. To make
+ * calling code easier, there are a bunch of helper functions below that can be
+ * called with a null database object and only call the database object if it is
+ * not null. If you need to call new functions on the database object, check out
+ * the createDBDelegate function below.
+ *
+ * When adding new tables to the table data object, please note that there is a
+ * special prefix for indexes. These are also kept in the table data object to
+ * make sure that getAllSql also includes CREATE INDEX statements. New tables
+ * MUST NOT be prefixed with "idx_". If you would like to add a new index,
+ * please use the createIndex function.
+ *
+ * The basic structure for an upgrader is (NN is current version, XX = NN - 1)
+ *
+ * upgrader.vNN = function upgrade_vNN(db, version) {
+ * let tbl = upgrade.vXX(version < XX && db, version);
+ * LOGdb(db, "Storage: Upgrading to vNN");
+ *
+ * beginTransaction(db);
+ * try {
+ * // Do stuff here
+ * setDbVersionAndCommit(db, NN);
+ * } catch (e) {
+ * throw reportErrorAndRollback(db, e);
+ * }
+ * return tbl;
+ * }
+ *
+ * Regardless of how your upgrader looks, make sure you:
+ * - use an sql transaction, if you have a database
+ * - If everything succeeds, call setDbVersionAndCommit to update the database
+ * version (setDbVersionAndCommit also commits the transaction)
+ * - If something fails, throw reportErrorAndRollback(db, e) to report the
+ * failure and roll back the transaction.
+ *
+ * If this documentation isn't sufficient to make upgrading understandable,
+ * please file a bug.
+ */
+
+var EXPORTED_SYMBOLS = [
+ "DB_SCHEMA_VERSION",
+ "getSql",
+ "getAllSql",
+ "getSqlTable",
+ "upgradeDB",
+ "backupDB",
+];
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+var { CAL_ITEM_FLAG, textToDate, getTimezone, newDateTime } = ChromeUtils.import(
+ "resource:///modules/calendar/calStorageHelpers.jsm"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalAlarm: "resource:///modules/CalAlarm.jsm",
+ CalAttachment: "resource:///modules/CalAttachment.jsm",
+ CalAttendee: "resource:///modules/CalAttendee.jsm",
+ CalRelation: "resource:///modules/CalRelation.jsm",
+});
+
+// The current database version. Be sure to increment this when you create a new
+// updater.
+var DB_SCHEMA_VERSION = 23;
+
+/**
+ * Gets the SQL for the given table data and table name. This can be both a real
+ * table or the name of an index. Indexes must contain the idx_ prefix.
+ *
+ * @param tblName The name of the table or index to retrieve sql for
+ * @param tblData The table data object, as returned from the upgrade_v*
+ * functions. If null, then the latest table data is
+ * retrieved.
+ * @param alternateName (optional) The table or index name to be used in the
+ * resulting CREATE statement. If not set, tblName will
+ * be used.
+ * @returns The SQL Statement for the given table or index and
+ * version as a string.
+ */
+function getSql(tblName, tblData, alternateName) {
+ tblData = tblData || getSqlTable();
+ let altName = alternateName || tblName;
+ let sql;
+ if (tblName.substr(0, 4) == "idx_") {
+ // If this is an index, we need construct the SQL differently
+ let idxTbl = tblData[tblName].shift();
+ let idxOn = idxTbl + "(" + tblData[tblName].join(",") + ")";
+ sql = `CREATE INDEX ${altName} ON ${idxOn};`;
+ } else {
+ sql = `CREATE TABLE ${altName} (\n`;
+ for (let [key, type] of Object.entries(tblData[tblName])) {
+ sql += ` ${key} ${type},\n`;
+ }
+ }
+
+ return sql.replace(/,\s*$/, ");");
+}
+
+/**
+ * Gets all SQL for the given table data
+ *
+ * @param version The database schema version to retrieve. If null, the
+ * latest schema version will be used.
+ * @returns The SQL Statement for the given version as a string.
+ */
+function getAllSql(version) {
+ let tblData = getSqlTable(version);
+ let sql = "";
+ for (let tblName in tblData) {
+ sql += getSql(tblName, tblData) + "\n\n";
+ }
+ cal.LOG("Storage: Full SQL statement is " + sql);
+ return sql;
+}
+
+/**
+ * Get the JS object corresponding to the given schema version. This object will
+ * contain both tables and indexes, where indexes are prefixed with "idx_".
+ *
+ * @param schemaVersion The schema version to get. If null, the latest
+ * schema version will be used.
+ * @returns The javascript object containing the table
+ * definition.
+ */
+function getSqlTable(schemaVersion) {
+ let version = "v" + (schemaVersion || DB_SCHEMA_VERSION);
+ if (version in upgrade) {
+ return upgrade[version]();
+ }
+ return {};
+}
+
+/**
+ * Gets the current version of the storage database
+ */
+function getVersion(db) {
+ let selectSchemaVersion;
+ let version = null;
+
+ try {
+ selectSchemaVersion = createStatement(
+ db,
+ "SELECT version FROM cal_calendar_schema_version LIMIT 1"
+ );
+ if (selectSchemaVersion.executeStep()) {
+ version = selectSchemaVersion.row.version;
+ }
+
+ if (version !== null) {
+ // This is the only place to leave this function gracefully.
+ return version;
+ }
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ } finally {
+ if (selectSchemaVersion) {
+ selectSchemaVersion.finalize();
+ }
+ }
+
+ throw new Error("cal_calendar_schema_version SELECT returned no results");
+}
+
+/**
+ * Backup the database and notify the user via error console of the process
+ */
+function backupDB(db, currentVersion) {
+ cal.LOG("Storage: Backing up current database...");
+ try {
+ // Prepare filenames and path
+ let backupFilename = "local.v" + currentVersion + ".sqlite";
+ let backupPath = cal.provider.getCalendarDirectory();
+ backupPath.append("backup");
+ if (!backupPath.exists()) {
+ backupPath.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8));
+ }
+
+ // Create a backup file and notify the user via WARN, since LOG will not
+ // be visible unless a pref is set.
+ let file = Services.storage.backupDatabaseFile(db.databaseFile, backupFilename, backupPath);
+ cal.WARN(
+ "Storage: Upgrading to v" + DB_SCHEMA_VERSION + ", a backup was written to: " + file.path
+ );
+ } catch (e) {
+ cal.ERROR("Storage: Error creating backup file: " + e);
+ }
+}
+
+/**
+ * Upgrade the passed database.
+ *
+ * @param storageCalendar - An instance of CalStorageCalendar.
+ */
+function upgradeDB(storageCalendar) {
+ let db = storageCalendar.db;
+ cal.ASSERT(db, "Database has not been opened!", true);
+
+ if (db.tableExists("cal_calendar_schema_version")) {
+ let version = getVersion(db);
+
+ if (version < DB_SCHEMA_VERSION) {
+ upgradeExistingDB(db, version);
+ } else if (version > DB_SCHEMA_VERSION) {
+ handleTooNewSchema(storageCalendar);
+ return;
+ }
+ } else {
+ upgradeBrandNewDB(db);
+ }
+
+ ensureUpdatedTimezones(db);
+ storageCalendar.afterUpgradeDB();
+}
+
+/**
+ * Upgrade a brand new database.
+ *
+ * @param {mozIStorageAsyncConnection} db - New database to upgrade.
+ */
+function upgradeBrandNewDB(db) {
+ cal.LOG("Storage: Creating tables from scratch");
+ beginTransaction(db);
+ try {
+ executeSimpleSQL(db, getAllSql());
+ setDbVersionAndCommit(db, DB_SCHEMA_VERSION);
+ } catch (e) {
+ reportErrorAndRollback(db, e);
+ }
+}
+
+/**
+ * Upgrade an existing database.
+ *
+ * @param {mozIStorageAsyncConnection} db - Existing database to upgrade.
+ * @param {number} version - Version of the database before upgrading.
+ */
+function upgradeExistingDB(db, version) {
+ // First, create a backup
+ backupDB(db, version);
+
+ // Then start the latest upgrader
+ cal.LOG("Storage: Preparing to upgrade v" + version + " to v" + DB_SCHEMA_VERSION);
+ upgrade["v" + DB_SCHEMA_VERSION](db, version);
+}
+
+/**
+ * Called when the user has downgraded Thunderbird and the older version of
+ * Thunderbird does not know about the newer schema of their calendar data.
+ * Log an error, make a backup copy of the data by renaming the data file, and
+ * restart the database initialization process, which will create a new data
+ * file that will have the correct schema.
+ *
+ * The user will find that their calendar events/tasks are gone. They should
+ * have exported them to an ICS file before downgrading, and then they can
+ * import them to get them back.
+ *
+ * @param storageCalendar - An instance of CalStorageCalendar.
+ */
+function handleTooNewSchema(storageCalendar) {
+ // Create a string like this: "2020-05-11T21-30-17".
+ let dateTime = new Date().toISOString().split(".")[0].replace(/:/g, "-");
+
+ let copyFileName = `local-${dateTime}.sqlite`;
+
+ storageCalendar.db.databaseFile.renameTo(null, copyFileName);
+
+ storageCalendar.db.close();
+
+ let appName = cal.l10n.getAnyString("branding", "brand", "brandShortName");
+ let errorText = cal.l10n.getCalString("tooNewSchemaErrorText", [appName, copyFileName]);
+ cal.ERROR(errorText);
+
+ storageCalendar.prepareInitDB();
+}
+
+/**
+ * Sets the db version and commits any open transaction.
+ *
+ * @param db The mozIStorageConnection to commit on
+ * @param version The version to set
+ */
+function setDbVersionAndCommit(db, version) {
+ let sql =
+ "DELETE FROM cal_calendar_schema_version;" +
+ `INSERT INTO cal_calendar_schema_version (version) VALUES (${version})`;
+
+ executeSimpleSQL(db, sql);
+ if (db && db.transactionInProgress) {
+ commitTransaction(db);
+ }
+}
+
+/**
+ * Creates a function that calls the given function |funcName| on it's passed
+ * database. In addition, if no database is passed, the call is ignored.
+ *
+ * @param funcName The function name to delegate.
+ * @returns The delegate function for the passed named function.
+ */
+function createDBDelegate(funcName) {
+ return function (db, ...args) {
+ if (db) {
+ try {
+ return db[funcName](...args);
+ } catch (e) {
+ cal.ERROR(
+ "Error calling '" +
+ funcName +
+ "' db error: '" +
+ lastErrorString(db) +
+ "'.\nException: " +
+ e
+ );
+ cal.WARN(cal.STACK(10));
+ }
+ }
+ return null;
+ };
+}
+
+/**
+ * Creates a delegate function for a database getter. Returns a function that
+ * can be called to get the specified attribute, if a database is passed. If no
+ * database is passed, no error is thrown but null is returned.
+ *
+ * @param getterAttr The getter to delegate.
+ * @returns The function that delegates the getter.
+ */
+function createDBDelegateGetter(getterAttr) {
+ return function (db) {
+ return db ? db[getterAttr] : null;
+ };
+}
+
+// These functions use the db delegate to allow easier calling of common
+// database functions.
+var beginTransaction = createDBDelegate("beginTransaction");
+var commitTransaction = createDBDelegate("commitTransaction");
+var rollbackTransaction = createDBDelegate("rollbackTransaction");
+var createStatement = createDBDelegate("createStatement");
+var executeSimpleSQL = createDBDelegate("executeSimpleSQL");
+var removeFunction = createDBDelegate("removeFunction");
+var createFunction = createDBDelegate("createFunction");
+
+var lastErrorString = createDBDelegateGetter("lastErrorString");
+
+/**
+ * Helper function to create an index on the database if it doesn't already
+ * exist.
+ *
+ * @param tblData The table data object to save the index in.
+ * @param tblName The name of the table to index.
+ * @param colNameArray An array of columns to index over.
+ * @param db (optional) The database to create the index on.
+ */
+function createIndex(tblData, tblName, colNameArray, db) {
+ let idxName = "idx_" + tblName + "_" + colNameArray.join("_");
+ let idxOn = tblName + "(" + colNameArray.join(",") + ")";
+
+ // Construct the table data for this index
+ tblData[idxName] = colNameArray.concat([]);
+ tblData[idxName].unshift(tblName);
+
+ // Execute the sql, if there is a db
+ return executeSimpleSQL(db, `CREATE INDEX IF NOT EXISTS ${idxName} ON ${idxOn}`);
+}
+
+/**
+ * Often in an upgrader we want to log something only if there is a database. To
+ * make code less cludgy, here a helper function.
+ *
+ * @param db The database, or null if nothing should be logged.
+ * @param msg The message to log.
+ */
+function LOGdb(db, msg) {
+ if (db) {
+ cal.LOG(msg);
+ }
+}
+
+/**
+ * Report an error and roll back the last transaction.
+ *
+ * @param db The database to roll back on.
+ * @param e The exception to report
+ * @returns The passed exception, for chaining.
+ */
+function reportErrorAndRollback(db, e) {
+ if (db && db.transactionInProgress) {
+ rollbackTransaction(db);
+ }
+ cal.ERROR(
+ `++++++ Storage error! ++++++ DB Error: ${lastErrorString(db)}\n++++++ Exception: ${e}`
+ );
+ return e;
+}
+
+/**
+ * Make sure the timezones of the events in the database are up to date.
+ *
+ * @param db The database to bring up to date
+ */
+function ensureUpdatedTimezones(db) {
+ // check if timezone version has changed:
+ let selectTzVersion = createStatement(db, "SELECT version FROM cal_tz_version LIMIT 1");
+ let tzServiceVersion = cal.timezoneService.version;
+ let version;
+ try {
+ version = selectTzVersion.executeStep() ? selectTzVersion.row.version : null;
+ } finally {
+ selectTzVersion.finalize();
+ }
+
+ let versionComp = 1;
+ if (version) {
+ versionComp = Services.vc.compare(tzServiceVersion, version);
+ }
+
+ if (versionComp != 0) {
+ cal.LOG(
+ "[calStorageCalendar] Timezones have been changed from " +
+ version +
+ " to " +
+ tzServiceVersion +
+ ", updating calendar data."
+ );
+
+ let zonesToUpdate = [];
+ let getZones = createStatement(
+ db,
+ "SELECT DISTINCT(zone) FROM (" +
+ "SELECT recurrence_id_tz AS zone FROM cal_attendees WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_events WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT event_start_tz AS zone FROM cal_events WHERE event_start_tz IS NOT NULL UNION " +
+ "SELECT event_end_tz AS zone FROM cal_events WHERE event_end_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_properties WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_todos WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT todo_entry_tz AS zone FROM cal_todos WHERE todo_entry_tz IS NOT NULL UNION " +
+ "SELECT todo_due_tz AS zone FROM cal_todos WHERE todo_due_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_alarms WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_relations WHERE recurrence_id_tz IS NOT NULL UNION " +
+ "SELECT recurrence_id_tz AS zone FROM cal_attachments WHERE recurrence_id_tz IS NOT NULL" +
+ ");"
+ );
+ try {
+ while (getZones.executeStep()) {
+ let zone = getZones.row.zone;
+ // Send the timezones off to the timezone service to attempt conversion:
+ let timezone = getTimezone(zone);
+ if (timezone) {
+ let refTz = cal.timezoneService.getTimezone(timezone.tzid);
+ if (refTz && refTz.tzid != zone) {
+ zonesToUpdate.push({ oldTzId: zone, newTzId: refTz.tzid });
+ }
+ }
+ }
+ } catch (e) {
+ cal.ERROR("Error updating timezones: " + e + "\nDB Error " + lastErrorString(db));
+ } finally {
+ getZones.finalize();
+ }
+
+ beginTransaction(db);
+ try {
+ for (let update of zonesToUpdate) {
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `UPDATE cal_attendees SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_events SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_events SET event_start_tz = '${update.newTzId}' WHERE event_start_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_events SET event_end_tz = '${update.newTzId}' WHERE event_end_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_properties SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_todos SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_todos SET todo_entry_tz = '${update.newTzId}' WHERE todo_entry_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_todos SET todo_due_tz = '${update.newTzId}' WHERE todo_due_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_alarms SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_relations SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}'; ` +
+ `UPDATE cal_attachments SET recurrence_id_tz = '${update.newTzId}' WHERE recurrence_id_tz = '${update.oldTzId}';`
+ );
+ }
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "DELETE FROM cal_tz_version; " +
+ `INSERT INTO cal_tz_version VALUES ('${cal.timezoneService.version}');`
+ );
+ commitTransaction(db);
+ } catch (e) {
+ cal.ASSERT(false, "Timezone update failed! DB Error: " + lastErrorString(db));
+ rollbackTransaction(db);
+ throw e;
+ }
+ }
+}
+
+/**
+ * Adds a column to the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to add on
+ * @param colName The column name to add
+ * @param colType The type of the column to add
+ * @param db (optional) The database to apply the operation on
+ */
+function addColumn(tblData, tblName, colName, colType, db) {
+ cal.ASSERT(tblName in tblData, `Table ${tblName} is missing from table def`, true);
+ tblData[tblName][colName] = colType;
+
+ executeSimpleSQL(db, `ALTER TABLE ${tblName} ADD COLUMN ${colName} ${colType}`);
+}
+
+/**
+ * Deletes columns from the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to delete on
+ * @param colNameArray An array of column names to delete
+ * @param db (optional) The database to apply the operation on
+ */
+function deleteColumns(tblData, tblName, colNameArray, db) {
+ for (let colName of colNameArray) {
+ delete tblData[tblName][colName];
+ }
+
+ let columns = Object.keys(tblData[tblName]);
+ executeSimpleSQL(db, getSql(tblName, tblData, tblName + "_temp"));
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `INSERT INTO ${tblName}_temp (${columns.join(",")}) ` +
+ `SELECT ${columns.join(",")}` +
+ ` FROM ${tblName};`
+ );
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `DROP TABLE ${tblName}; ` +
+ `ALTER TABLE ${tblName}_temp` +
+ ` RENAME TO ${tblName};`
+ );
+}
+
+/**
+ * Does a full copy of the given table
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to copy
+ * @param newTblName The target table name.
+ * @param db (optional) The database to apply the operation on
+ * @param condition (optional) The condition to respect when copying
+ * @param selectOptions (optional) Extra options for the SELECT, i.e DISTINCT
+ */
+function copyTable(tblData, tblName, newTblName, db, condition, selectOptions) {
+ function objcopy(obj) {
+ return JSON.parse(JSON.stringify(obj));
+ }
+
+ tblData[newTblName] = objcopy(tblData[tblName]);
+
+ let columns = Object.keys(tblData[newTblName]);
+ executeSimpleSQL(db, getSql(newTblName, tblData));
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `INSERT INTO ${newTblName} (${columns.join(",")}) ` +
+ `SELECT ${selectOptions} ${columns.join(",")}` +
+ ` FROM ${tblName} ${condition ? condition : ""};`
+ );
+}
+
+/**
+ * Alter the type of a certain column
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to alter
+ * @param colNameArray An array of column names to delete
+ * @param newType The new type of the column
+ * @param db (optional) The database to apply the operation on
+ */
+function alterTypes(tblData, tblName, colNameArray, newType, db) {
+ for (let colName of colNameArray) {
+ tblData[tblName][colName] = newType;
+ }
+
+ let columns = Object.keys(tblData[tblName]);
+ executeSimpleSQL(db, getSql(tblName, tblData, tblName + "_temp"));
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `INSERT INTO ${tblName}_temp (${columns.join(",")}) ` +
+ `SELECT ${columns.join(",")}` +
+ ` FROM ${tblName};`
+ );
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `DROP TABLE ${tblName}; ` +
+ `ALTER TABLE ${tblName}_temp` +
+ ` RENAME TO ${tblName};`
+ );
+}
+
+/**
+ * Renames the given table, giving it a new name.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to rename.
+ * @param newTblName The new name of the table.
+ * @param db (optional) The database to apply the operation on.
+ * @param overwrite (optional) If true, the target table will be dropped
+ * before the rename
+ */
+function renameTable(tblData, tblName, newTblName, db, overwrite) {
+ if (overwrite) {
+ dropTable(tblData, newTblName, db);
+ }
+ tblData[newTblName] = tblData[tblName];
+ delete tblData[tblName];
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `ALTER TABLE ${tblName}` +
+ ` RENAME TO ${newTblName}`
+ );
+}
+
+/**
+ * Drops the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to drop.
+ * @param db (optional) The database to apply the operation on.
+ */
+function dropTable(tblData, tblName, db) {
+ delete tblData[tblName];
+
+ executeSimpleSQL(db, `DROP TABLE IF EXISTS ${tblName};`);
+}
+
+/**
+ * Creates the given table.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to add.
+ * @param def The table definition object.
+ * @param db (optional) The database to apply the operation on.
+ */
+function addTable(tblData, tblName, def, db) {
+ tblData[tblName] = def;
+
+ executeSimpleSQL(db, getSql(tblName, tblData));
+}
+
+/**
+ * Migrates the given columns to a single icalString, using the (previously
+ * created) user function for processing.
+ *
+ * @param tblData The table data object to apply the operation on.
+ * @param tblName The table name to migrate.
+ * @param userFuncName The name of the user function to call for migration
+ * @param oldColumns An array of columns to migrate to the new icalString
+ * column
+ * @param db (optional) The database to apply the operation on.
+ */
+function migrateToIcalString(tblData, tblName, userFuncName, oldColumns, db) {
+ addColumn(tblData, tblName, ["icalString"], "TEXT", db);
+ // prettier-ignore
+ let updateSql =
+ `UPDATE ${tblName} ` +
+ ` SET icalString = ${userFuncName}(${oldColumns.join(",")})`;
+ executeSimpleSQL(db, updateSql);
+ deleteColumns(tblData, tblName, oldColumns, db);
+
+ // If null was returned, its an invalid attendee. Make sure to remove them,
+ // they might break things later on.
+ let cleanupSql = `DELETE FROM ${tblName} WHERE icalString IS NULL`;
+ executeSimpleSQL(db, cleanupSql);
+}
+
+/**
+ * Maps a mozIStorageValueArray to a JS array, converting types correctly.
+ *
+ * @param storArgs The storage value array to convert
+ * @returns An array with the arguments as js values.
+ */
+function mapStorageArgs(storArgs) {
+ const mISVA = Ci.mozIStorageValueArray;
+ let mappedArgs = [];
+ for (let i = 0; i < storArgs.numEntries; i++) {
+ switch (storArgs.getTypeOfIndex(i)) {
+ case mISVA.VALUE_TYPE_NULL:
+ mappedArgs.push(null);
+ break;
+ case mISVA.VALUE_TYPE_INTEGER:
+ mappedArgs.push(storArgs.getInt64(i));
+ break;
+ case mISVA.VALUE_TYPE_FLOAT:
+ mappedArgs.push(storArgs.getDouble(i));
+ break;
+ case mISVA.VALUE_TYPE_TEXT:
+ case mISVA.VALUE_TYPE_BLOB:
+ mappedArgs.push(storArgs.getUTF8String(i));
+ break;
+ }
+ }
+
+ return mappedArgs;
+}
+
+/** Object holding upgraders */
+var upgrade = {};
+
+/**
+ * Returns the initial storage database schema. Note this is not the current
+ * schema, it will be modified by the upgrade.vNN() functions. This function
+ * returns the initial v1 with modifications from v2 applied.
+ *
+ * No bug - new recurrence system. exceptions supported now, along with
+ * everything else ical can throw at us. I hope.
+ * p=vlad
+ */
+// eslint-disable-next-line id-length
+upgrade.v2 = upgrade.v1 = function (db, version) {
+ LOGdb(db, "Storage: Upgrading to v1/v2");
+ let tblData = {
+ cal_calendar_schema_version: { version: "INTEGER" },
+
+ /* While this table is in v1, actually keeping it in the sql object will
+ * cause problems when migrating from storage.sdb to local.sqlite. There,
+ * all tables from storage.sdb will be moved to local.sqlite and so starting
+ * the application again afterwards causes a borked upgrade since its missing
+ * tables it expects.
+ *
+ * cal_calendars: {
+ * id: "INTEGER PRIMARY KEY",
+ * name: "STRING"
+ * },
+ */
+
+ cal_items: {
+ cal_id: "INTEGER",
+ item_type: "INTEGER",
+ id: "STRING",
+ time_created: "INTEGER",
+ last_modified: "INTEGER",
+ title: "STRING",
+ priority: "INTEGER",
+ privacy: "STRING",
+ ical_status: "STRING",
+ flags: "INTEGER",
+ event_start: "INTEGER",
+ event_end: "INTEGER",
+ event_stamp: "INTEGER",
+ todo_entry: "INTEGER",
+ todo_due: "INTEGER",
+ todo_completed: "INTEGER",
+ todo_complete: "INTEGER",
+ alarm_id: "INTEGER",
+ },
+
+ cal_attendees: {
+ item_id: "STRING",
+ attendee_id: "STRING",
+ common_name: "STRING",
+ rsvp: "INTEGER",
+ role: "STRING",
+ status: "STRING",
+ type: "STRING",
+ },
+
+ cal_alarms: {
+ id: "INTEGER PRIMARY KEY",
+ alarm_data: "BLOB",
+ },
+
+ cal_recurrence: {
+ item_id: "STRING",
+ recur_type: "INTEGER",
+ recur_index: "INTEGER",
+ is_negative: "BOOLEAN",
+ dates: "STRING",
+ end_date: "INTEGER",
+ count: "INTEGER",
+ interval: "INTEGER",
+ second: "STRING",
+ minute: "STRING",
+ hour: "STRING",
+ day: "STRING",
+ monthday: "STRING",
+ yearday: "STRING",
+ weekno: "STRING",
+ month: "STRING",
+ setpos: "STRING",
+ },
+
+ cal_properties: {
+ item_id: "STRING",
+ key: "STRING",
+ value: "BLOB",
+ },
+ };
+
+ for (let tbl in tblData) {
+ executeSimpleSQL(db, `DROP TABLE IF EXISTS ${tbl}`);
+ }
+ return tblData;
+};
+
+/**
+ * Upgrade to version 3.
+ * Bug 293707, updates to storage provider; calendar manager database locked
+ * fix, r=shaver, p=vlad
+ * p=vlad
+ */
+// eslint-disable-next-line id-length
+upgrade.v3 = function (db, version) {
+ function updateSql(tbl, field) {
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `UPDATE ${tbl} SET ${field}_tz='UTC'` +
+ ` WHERE ${field} IS NOT NULL`
+ );
+ }
+
+ let tbl = upgrade.v2(version < 2 && db, version);
+ LOGdb(db, "Storage: Upgrading to v3");
+
+ beginTransaction(db);
+ try {
+ copyTable(tbl, "cal_items", "cal_events", db, "item_type = 0");
+ copyTable(tbl, "cal_items", "cal_todos", db, "item_type = 1");
+
+ dropTable(tbl, "cal_items", db);
+
+ let removeEventCols = [
+ "item_type",
+ "item_type",
+ "todo_entry",
+ "todo_due",
+ "todo_completed",
+ "todo_complete",
+ "alarm_id",
+ ];
+ deleteColumns(tbl, "cal_events", removeEventCols, db);
+
+ addColumn(tbl, "cal_events", "event_start_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_events", "event_end_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_events", "alarm_time", "INTEGER", db);
+ addColumn(tbl, "cal_events", "alarm_time_tz", "VARCHAR", db);
+
+ let removeTodoCols = ["item_type", "event_start", "event_end", "event_stamp", "alarm_id"];
+ deleteColumns(tbl, "cal_todos", removeTodoCols, db);
+
+ addColumn(tbl, "cal_todos", "todo_entry_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_todos", "todo_due_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_todos", "todo_completed_tz", "VARCHAR", db);
+ addColumn(tbl, "cal_todos", "alarm_time", "INTEGER", db);
+ addColumn(tbl, "cal_todos", "alarm_time_tz", "VARCHAR", db);
+
+ dropTable(tbl, "cal_alarms", db);
+
+ // The change between 2 and 3 includes the splitting of cal_items into
+ // cal_events and cal_todos, and the addition of columns for
+ // event_start_tz, event_end_tz, todo_entry_tz, todo_due_tz.
+ // These need to default to "UTC" if their corresponding time is
+ // given, since that's what the default was for v2 calendars
+
+ // Fix up the new timezone columns
+ updateSql("cal_events", "event_start");
+ updateSql("cal_events", "event_end");
+ updateSql("cal_todos", "todo_entry");
+ updateSql("cal_todos", "todo_due");
+ updateSql("cal_todos", "todo_completed");
+
+ setDbVersionAndCommit(db, 3);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Upgrade to version 4.
+ * Bug 293183 - implement exception support for recurrence.
+ * r=shaver,p=vlad
+ */
+// eslint-disable-next-line id-length
+upgrade.v4 = function (db, version) {
+ let tbl = upgrade.v3(version < 3 && db, version);
+ LOGdb(db, "Storage: Upgrading to v4");
+
+ beginTransaction(db);
+ try {
+ for (let tblid of ["events", "todos", "attendees", "properties"]) {
+ addColumn(tbl, "cal_" + tblid, "recurrence_id", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblid, "recurrence_id_tz", "VARCHAR", db);
+ }
+ setDbVersionAndCommit(db, 4);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 315051 - Switch to storing alarms based on offsets from start/end time
+ * rather than as absolute times. Ensure that missed alarms are fired.
+ * r=dmose, p=jminta
+ */
+// eslint-disable-next-line id-length
+upgrade.v5 = function (db, version) {
+ let tbl = upgrade.v4(version < 4 && db, version);
+ LOGdb(db, "Storage: Upgrading to v5");
+
+ beginTransaction(db);
+ try {
+ for (let tblid of ["events", "todos"]) {
+ addColumn(tbl, "cal_" + tblid, "alarm_offset", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblid, "alarm_related", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblid, "alarm_last_ack", "INTEGER", db);
+ }
+ setDbVersionAndCommit(db, 5);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 333688 - Converts STRING and VARCHAR columns to TEXT to avoid SQLite's
+ * auto-conversion of strings to numbers (10e4 to 10000)
+ * r=ctalbert,jminta p=lilmatt
+ */
+// eslint-disable-next-line id-length
+upgrade.v6 = function (db, version) {
+ let tbl = upgrade.v5(version < 5 && db, version);
+ LOGdb(db, "Storage: Upgrading to v6");
+
+ beginTransaction(db);
+ try {
+ let eventCols = [
+ "id",
+ "title",
+ "privacy",
+ "ical_status",
+ "recurrence_id_tz",
+ "event_start_tz",
+ "event_end_tz",
+ "alarm_time_tz",
+ ];
+ alterTypes(tbl, "cal_events", eventCols, "TEXT", db);
+
+ let todoCols = [
+ "id",
+ "title",
+ "privacy",
+ "ical_status",
+ "recurrence_id_tz",
+ "todo_entry_tz",
+ "todo_due_tz",
+ "todo_completed_tz",
+ "alarm_time_tz",
+ ];
+ alterTypes(tbl, "cal_todos", todoCols, "TEXT", db);
+
+ let attendeeCols = [
+ "item_id",
+ "recurrence_id_tz",
+ "attendee_id",
+ "common_name",
+ "role",
+ "status",
+ "type",
+ ];
+ alterTypes(tbl, "cal_attendees", attendeeCols, "TEXT", db);
+
+ let recurrenceCols = [
+ "item_id",
+ "recur_type",
+ "dates",
+ "second",
+ "minute",
+ "hour",
+ "day",
+ "monthday",
+ "yearday",
+ "weekno",
+ "month",
+ "setpos",
+ ];
+ alterTypes(tbl, "cal_recurrence", recurrenceCols, "TEXT", db);
+
+ let propertyCols = ["item_id", "recurrence_id_tz", "key"];
+ alterTypes(tbl, "cal_properties", propertyCols, "TEXT", db);
+ setDbVersionAndCommit(db, 6);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 369010: Migrate all old tzids in storage to new one.
+ * r=ctalbert,dmose p=lilmatt
+ */
+// eslint-disable-next-line id-length
+upgrade.v7 = function (db, version) {
+ // No schema changes in v7
+ let tbl = upgrade.v6(db, version);
+ LOGdb(db, "Storage: Upgrading to v7");
+ return tbl;
+};
+
+/**
+ * Bug 410931 - Update internal timezone definitions
+ * r=ctalbert, p=dbo,nth10sd,hb
+ */
+// eslint-disable-next-line id-length
+upgrade.v8 = function (db, version) {
+ // No schema changes in v8
+ let tbl = upgrade.v7(db, version);
+ LOGdb(db, "Storage: Upgrading to v8");
+ return tbl;
+};
+
+/**
+ * Bug 363191 - Handle Timezones more efficiently (Timezone Database)
+ * r=philipp,ctalbert, p=dbo
+ */
+// eslint-disable-next-line id-length
+upgrade.v9 = function (db, version) {
+ // No schema changes in v9
+ let tbl = upgrade.v8(db, version);
+ LOGdb(db, "Storage: Upgrading to v9");
+ return tbl;
+};
+
+/**
+ * Bug 413908 – Events using internal timezones are no longer updated to
+ * recent timezone version;
+ * r=philipp, p=dbo
+ */
+upgrade.v10 = function (db, version) {
+ let tbl = upgrade.v9(version < 9 && db, version);
+ LOGdb(db, "Storage: Upgrading to v10");
+
+ beginTransaction(db);
+ try {
+ addTable(tbl, "cal_tz_version", { version: "TEXT" }, db);
+ setDbVersionAndCommit(db, 10);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Fix bug 319909 - Failure to properly serialize/unserialize ics ATTACH
+ * properties.
+ * r=philipp,p=fred.jen@web.de
+ */
+upgrade.v11 = function (db, version) {
+ let tbl = upgrade.v10(version < 10 && db, version);
+ LOGdb(db, "Storage: Upgrading to v11");
+
+ beginTransaction(db);
+ try {
+ addTable(
+ tbl,
+ "cal_attachments",
+ {
+ item_id: "TEXT",
+ data: "BLOB",
+ format_type: "TEXT",
+ encoding: "TEXT",
+ },
+ db
+ );
+ setDbVersionAndCommit(db, 11);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 449031 - Add meta data API to memory/storage
+ * r=philipp, p=dbo
+ */
+upgrade.v12 = function (db, version) {
+ let tbl = upgrade.v11(version < 11 && db, version);
+ LOGdb(db, "Storage: Upgrading to v12");
+
+ beginTransaction(db);
+ try {
+ addColumn(tbl, "cal_attendees", "is_organizer", "BOOLEAN", db);
+ addColumn(tbl, "cal_attendees", "properties", "BLOB", db);
+
+ addTable(
+ tbl,
+ "cal_metadata",
+ {
+ cal_id: "INTEGER",
+ item_id: "TEXT UNIQUE",
+ value: "BLOB",
+ },
+ db
+ );
+ setDbVersionAndCommit(db, 12);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 449401 - storage provider doesn't cleanly separate items of the same id
+ * across different calendars
+ * r=dbo,philipp, p=wsourdeau@inverse.ca
+ */
+upgrade.v13 = function (db, version) {
+ let tbl = upgrade.v12(version < 12 && db, version);
+ LOGdb(db, "Storage: Upgrading to v13");
+
+ beginTransaction(db);
+ try {
+ alterTypes(tbl, "cal_metadata", ["item_id"], "TEXT", db);
+
+ let calIds = {};
+ if (db) {
+ for (let itemTable of ["events", "todos"]) {
+ let stmt = createStatement(db, `SELECT id, cal_id FROM cal_${itemTable}`);
+ try {
+ while (stmt.executeStep()) {
+ calIds[stmt.row.id] = stmt.row.cal_id;
+ }
+ } finally {
+ stmt.finalize();
+ }
+ }
+ }
+ let tables = ["attendees", "recurrence", "properties", "attachments"];
+ for (let tblid of tables) {
+ addColumn(tbl, "cal_" + tblid, "cal_id", "INTEGER", db);
+
+ for (let itemId in calIds) {
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ `UPDATE cal_${tblid}` +
+ ` SET cal_id = ${calIds[itemId]}` +
+ ` WHERE item_id = '${itemId}'`
+ );
+ }
+ }
+
+ executeSimpleSQL(db, "DROP INDEX IF EXISTS idx_cal_properies_item_id");
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "CREATE INDEX IF NOT EXISTS" +
+ " idx_cal_properies_item_id" +
+ " ON cal_properties(cal_id, item_id);"
+ );
+ setDbVersionAndCommit(db, 13);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 446303 - use the "RELATED-TO" property.
+ * r=philipp,dbo, p=fred.jen@web.de
+ */
+upgrade.v14 = function (db, version) {
+ let tbl = upgrade.v13(version < 13 && db, version);
+ LOGdb(db, "Storage: Upgrading to v14");
+
+ beginTransaction(db);
+ try {
+ addTable(
+ tbl,
+ "cal_relations",
+ {
+ cal_id: "INTEGER",
+ item_id: "TEXT",
+ rel_type: "TEXT",
+ rel_id: "TEXT",
+ },
+ db
+ );
+ setDbVersionAndCommit(db, 14);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 463282 - Tasks cannot be created or imported (regression).
+ * r=philipp,berend, p=dbo
+ */
+upgrade.v15 = function (db, version) {
+ let tbl = upgrade.v14(version < 14 && db, version);
+ LOGdb(db, "Storage: Upgrading to v15");
+
+ beginTransaction(db);
+ try {
+ addColumn(tbl, "cal_todos", "todo_stamp", "INTEGER", db);
+ setDbVersionAndCommit(db, 15);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 353492 - support multiple alarms per events/task, support
+ * absolute alarms with fixed date/time - Storage Provider support for multiple
+ * alarms.
+ * r=dbo,ssitter, p=philipp
+ *
+ * This upgrader is a bit special. To fix bug 494140, we decided to change the
+ * upgrading code afterwards to make sure no data is lost for people upgrading
+ * from 0.9 -> 1.0b1 and later. The v17 upgrader will merely take care of the
+ * upgrade if a user is upgrading from 1.0pre -> 1.0b1 or later.
+ */
+upgrade.v16 = function (db, version) {
+ let tbl = upgrade.v15(version < 15 && db, version);
+ LOGdb(db, "Storage: Upgrading to v16");
+ beginTransaction(db);
+ try {
+ createFunction(db, "translateAlarm", 4, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aOffset, aRelated, aAlarmTime, aTzId] = mapStorageArgs(storArgs);
+
+ let alarm = new lazy.CalAlarm();
+ if (aOffset) {
+ alarm.related = parseInt(aRelated, 10) + 1;
+ alarm.offset = cal.createDuration();
+ alarm.offset.inSeconds = aOffset;
+ } else if (aAlarmTime) {
+ alarm.related = Ci.calIAlarm.ALARM_RELATED_ABSOLUTE;
+ let alarmDate = cal.createDateTime();
+ alarmDate.nativeTime = aAlarmTime;
+ if (aTzId == "floating") {
+ // The current calDateTime code assumes that if a
+ // date is floating then we can just assign the new
+ // timezone. I have the feeling this is wrong so I
+ // filed bug 520463. Since we want to release 1.0b1
+ // soon, I will just fix this on the "client side"
+ // and do the conversion here.
+ alarmDate.timezone = cal.timezoneService.defaultTimezone;
+ alarmDate = alarmDate.getInTimezone(cal.dtz.UTC);
+ } else {
+ alarmDate.timezone = cal.timezoneService.getTimezone(aTzId);
+ }
+ alarm.alarmDate = alarmDate;
+ }
+ return alarm.icalString;
+ } catch (e) {
+ // Errors in this function are not really logged. Do this
+ // separately.
+ cal.ERROR("Error converting alarms: " + e);
+ throw e;
+ }
+ },
+ });
+
+ addTable(
+ tbl,
+ "cal_alarms",
+ {
+ cal_id: "INTEGER",
+ item_id: "TEXT",
+ // Note the following two columns were not originally part of the
+ // v16 upgrade, see note above function.
+ recurrence_id: "INTEGER",
+ recurrence_id_tz: "TEXT",
+ icalString: "TEXT",
+ },
+ db
+ );
+
+ let copyDataOver = function (tblName) {
+ const transAlarm = "translateAlarm(alarm_offset, alarm_related, alarm_time, alarm_time_tz)";
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "INSERT INTO cal_alarms (cal_id, item_id," +
+ " recurrence_id, " +
+ " recurrence_id_tz, " +
+ " icalString)" +
+ " SELECT cal_id, id, recurrence_id," +
+ ` recurrence_id_tz, ${transAlarm}` +
+ ` FROM ${tblName}` +
+ " WHERE alarm_offset IS NOT NULL" +
+ " OR alarm_time IS NOT NULL;"
+ );
+ };
+ copyDataOver("cal_events");
+ copyDataOver("cal_todos");
+ removeFunction(db, "translateAlarm");
+
+ // Make sure the alarm flag is set on the item
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "UPDATE cal_events " +
+ ` SET flags = flags | ${CAL_ITEM_FLAG.HAS_ALARMS}` +
+ " WHERE id IN" +
+ " (SELECT item_id " +
+ " FROM cal_alarms " +
+ " WHERE cal_alarms.cal_id = cal_events.cal_id)"
+ );
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "UPDATE cal_todos " +
+ ` SET flags = flags | ${CAL_ITEM_FLAG.HAS_ALARMS}` +
+ " WHERE id IN" +
+ " (SELECT item_id " +
+ " FROM cal_alarms " +
+ " WHERE cal_alarms.cal_id = cal_todos.cal_id)"
+ );
+
+ // Remote obsolete columns
+ let cols = ["alarm_time", "alarm_time_tz", "alarm_offset", "alarm_related"];
+ for (let tblid of ["events", "todos"]) {
+ deleteColumns(tbl, "cal_" + tblid, cols, db);
+ }
+
+ setDbVersionAndCommit(db, 16);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 494140 - Multiple reminders,relations,attachments created by modifying
+ * repeating event.
+ * r=dbo,ssitter, p=philipp
+ *
+ * This upgrader is special. In bug 494140 we decided it would be better to fix
+ * the v16 upgrader so 0.9 users can update to 1.0b1 and later without dataloss.
+ * Therefore all this upgrader does is handle users of 1.0pre before the
+ * mentioned bug.
+ */
+upgrade.v17 = function (db, version) {
+ let tbl = upgrade.v16(version < 16 && db, version);
+ LOGdb(db, "Storage: Upgrading to v17");
+ beginTransaction(db);
+ try {
+ for (let tblName of ["alarms", "relations", "attachments"]) {
+ let hasColumns = true;
+ let stmt;
+ try {
+ // Stepping this statement will fail if the columns don't exist.
+ // We don't use the delegate here since it would show an error to
+ // the user, even through we expect the error. If the db is null,
+ // then swallowing the error is ok too since the cols will
+ // already be added in v16.
+ stmt = db.createStatement(
+ `SELECT recurrence_id_tz, recurrence_id FROM cal_${tblName} LIMIT 1`
+ );
+ stmt.executeStep();
+ } catch (e) {
+ // An error happened, which means the cols don't exist
+ hasColumns = false;
+ } finally {
+ if (stmt) {
+ stmt.finalize();
+ }
+ }
+
+ // Only add the columns if they are not there yet (i.e added in v16)
+ // Since relations were broken all along, also make sure and add the
+ // columns to the javascript object if there is no database.
+ if (!hasColumns || !db) {
+ addColumn(tbl, "cal_" + tblName, "recurrence_id", "INTEGER", db);
+ addColumn(tbl, "cal_" + tblName, "recurrence_id_tz", "TEXT", db);
+ }
+
+ // Clear out entries that are exactly the same. This corrects alarms
+ // created in 1.0pre and relations and attachments created in 0.9.
+ copyTable(tbl, "cal_" + tblName, "cal_" + tblName + "_v17", db, null, "DISTINCT");
+ renameTable(tbl, "cal_" + tblName + "_v17", "cal_" + tblName, db, true);
+ }
+ setDbVersionAndCommit(db, 17);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 529326 - Create indexes for the local calendar
+ * r=mschroeder, p=philipp
+ *
+ * This bug adds some indexes to improve performance. If you would like to add
+ * additional indexes, please read http://www.sqlite.org/optoverview.html first.
+ */
+upgrade.v18 = function (db, version) {
+ let tbl = upgrade.v17(version < 17 && db, version);
+ LOGdb(db, "Storage: Upgrading to v18");
+ beginTransaction(db);
+ try {
+ // These fields are often indexed over
+ let simpleIds = ["cal_id", "item_id"];
+ let allIds = simpleIds.concat(["recurrence_id", "recurrence_id_tz"]);
+
+ // Alarms, Attachments, Attendees, Relations
+ for (let tblName of ["alarms", "attachments", "attendees", "relations"]) {
+ createIndex(tbl, "cal_" + tblName, allIds, db);
+ }
+
+ // Events and Tasks
+ for (let tblName of ["events", "todos"]) {
+ createIndex(tbl, "cal_" + tblName, ["flags", "cal_id", "recurrence_id"], db);
+ createIndex(tbl, "cal_" + tblName, ["id", "cal_id", "recurrence_id"], db);
+ }
+
+ // Metadata
+ createIndex(tbl, "cal_metadata", simpleIds, db);
+
+ // Properties. Remove the index we used to create first, since our index
+ // is much more complete.
+ executeSimpleSQL(db, "DROP INDEX IF EXISTS idx_cal_properies_item_id");
+ createIndex(tbl, "cal_properties", allIds, db);
+
+ // Recurrence
+ createIndex(tbl, "cal_recurrence", simpleIds, db);
+
+ setDbVersionAndCommit(db, 18);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 479867 - Cached calendars don't set id correctly, causing duplicate
+ * events to be shown for multiple cached calendars
+ * r=simon.at.orcl, p=philipp,dbo
+ */
+upgrade.v19 = function (db, version) {
+ let tbl = upgrade.v18(version < 18 && db, version);
+ LOGdb(db, "Storage: Upgrading to v19");
+ beginTransaction(db);
+ try {
+ let tables = [
+ "cal_alarms",
+ "cal_attachments",
+ "cal_attendees",
+ "cal_events",
+ "cal_metadata",
+ "cal_properties",
+ "cal_recurrence",
+ "cal_relations",
+ "cal_todos",
+ ];
+ // Change types of column to TEXT.
+ for (let tblName of tables) {
+ alterTypes(tbl, tblName, ["cal_id"], "TEXT", db);
+ }
+ setDbVersionAndCommit(db, 19);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+
+ return tbl;
+};
+
+/**
+ * Bug 380060 - Offline Sync feature for calendar
+ * Setting a offline_journal column in cal_events tables
+ * r=philipp, p=redDragon
+ */
+upgrade.v20 = function (db, version) {
+ let tbl = upgrade.v19(version < 19 && db, version);
+ LOGdb(db, "Storage: Upgrading to v20");
+ beginTransaction(db);
+ try {
+ // Adding a offline_journal column
+ for (let tblName of ["cal_events", "cal_todos"]) {
+ addColumn(tbl, tblName, ["offline_journal"], "INTEGER", db);
+ }
+ setDbVersionAndCommit(db, 20);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 785659 - Get rid of calIRecurrenceDateSet
+ * Migrate x-dateset to x-date in the storage database
+ * r=mmecca, p=philipp
+ */
+upgrade.v21 = function (db, version) {
+ let tbl = upgrade.v20(version < 20 && db, version);
+ LOGdb(db, "Storage: Upgrading to v21");
+ beginTransaction(db);
+
+ try {
+ // The following operation is only important on a live DB, since we are
+ // changing only the values on the DB, not the schema itself.
+ if (db) {
+ // Oh boy, here we go :-)
+ // Insert a new row with the following columns...
+ let insertSQL =
+ "INSERT INTO cal_recurrence " +
+ " (item_id, cal_id, recur_type, recur_index," +
+ " is_negative, dates, end_date, count," +
+ " interval, second, minute, hour, day," +
+ " monthday, yearday, weekno, month, setpos)" +
+ // ... by selecting some columns from the existing table ...
+ ' SELECT item_id, cal_id, "x-date" AS recur_type, ' +
+ // ... like a new recur_index, we need it to be maximum for this item ...
+ " (SELECT MAX(recur_index)+1" +
+ " FROM cal_recurrence AS rinner " +
+ " WHERE rinner.item_id = router.item_id" +
+ " AND rinner.cal_id = router.cal_id) AS recur_index," +
+ " is_negative," +
+ // ... the string until the first comma in the current dates field
+ ' SUBSTR(dates, 0, LENGTH(dates) - LENGTH(LTRIM(dates, REPLACE(dates, ",", ""))) + 1) AS dates,' +
+ " end_date, count, interval, second, minute," +
+ " hour, day, monthday, yearday, weekno, month," +
+ " setpos" +
+ // ... from the recurrence table ...
+ " FROM cal_recurrence AS router " +
+ // ... but only on fields that are x-datesets ...
+ ' WHERE recur_type = "x-dateset" ' +
+ // ... and are not already empty.
+ ' AND dates != ""';
+ dump(insertSQL + "\n");
+
+ // Now we need to remove the first segment from the dates field
+ let updateSQL =
+ "UPDATE cal_recurrence" +
+ ' SET dates = SUBSTR(dates, LENGTH(dates) - LENGTH(LTRIM(dates, REPLACE(dates, ",", ""))) + 2)' +
+ ' WHERE recur_type = "x-dateset"' +
+ ' AND dates != ""';
+
+ // Create the statements
+ let insertStmt = createStatement(db, insertSQL);
+ let updateStmt = createStatement(db, updateSQL);
+
+ // Repeat these two statements until the update affects 0 rows
+ // (because the dates field on all x-datesets is empty)
+ do {
+ insertStmt.execute();
+ updateStmt.execute();
+ } while (db.affectedRows > 0);
+
+ // Finally we can delete the x-dateset rows. Note this will leave
+ // gaps in recur_index, but that's ok since its only used for
+ // ordering anyway and will be overwritten on the next item write.
+ executeSimpleSQL(db, 'DELETE FROM cal_recurrence WHERE recur_type = "x-dateset"');
+ }
+
+ setDbVersionAndCommit(db, 21);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+/**
+ * Bug 785733 - Move some properties to use icalString in database.
+ * Use the full icalString in attendees, attachments, relations and recurrence
+ * tables.
+ * r=mmecca, p=philipp
+ */
+upgrade.v22 = function (db, version) {
+ let tbl = upgrade.v21(version < 21 && db, version);
+ LOGdb(db, "Storage: Upgrading to v22");
+ beginTransaction(db);
+ try {
+ // Update attachments to using icalString directly
+ createFunction(db, "translateAttachment", 3, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aData, aFmtType, aEncoding] = mapStorageArgs(storArgs);
+
+ let attach = new lazy.CalAttachment();
+ attach.uri = Services.io.newURI(aData);
+ attach.formatType = aFmtType;
+ attach.encoding = aEncoding;
+ return attach.icalString;
+ } catch (e) {
+ cal.ERROR("Error converting attachment: " + e);
+ throw e;
+ }
+ },
+ });
+ migrateToIcalString(
+ tbl,
+ "cal_attachments",
+ "translateAttachment",
+ ["data", "format_type", "encoding"],
+ db
+ );
+
+ // Update relations to using icalString directly
+ createFunction(db, "translateRelation", 2, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aRelType, aRelId] = mapStorageArgs(storArgs);
+ let relation = new lazy.CalRelation();
+ relation.relType = aRelType;
+ relation.relId = aRelId;
+ return relation.icalString;
+ } catch (e) {
+ cal.ERROR("Error converting relation: " + e);
+ throw e;
+ }
+ },
+ });
+ migrateToIcalString(tbl, "cal_relations", "translateRelation", ["rel_type", "rel_id"], db);
+
+ // Update attendees table to using icalString directly
+ createFunction(db, "translateAttendee", 8, {
+ onFunctionCall(storArgs) {
+ try {
+ let [aAttendeeId, aCommonName, aRsvp, aRole, aStatus, aType, aIsOrganizer, aProperties] =
+ mapStorageArgs(storArgs);
+
+ let attendee = new lazy.CalAttendee();
+
+ attendee.id = aAttendeeId;
+ attendee.commonName = aCommonName;
+
+ switch (aRsvp) {
+ case 0:
+ attendee.rsvp = "FALSE";
+ break;
+ case 1:
+ attendee.rsvp = "TRUE";
+ break;
+ // default: keep undefined
+ }
+
+ attendee.role = aRole;
+ attendee.participationStatus = aStatus;
+ attendee.userType = aType;
+ attendee.isOrganizer = !!aIsOrganizer;
+ if (aProperties) {
+ for (let pair of aProperties.split(",")) {
+ let [key, value] = pair.split(":");
+ attendee.setProperty(decodeURIComponent(key), decodeURIComponent(value));
+ }
+ }
+
+ return attendee.icalString;
+ } catch (e) {
+ // There are some attendees with a null ID. We are taking
+ // the opportunity to remove them here.
+ cal.ERROR("Error converting attendee, removing: " + e);
+ return null;
+ }
+ },
+ });
+ migrateToIcalString(
+ tbl,
+ "cal_attendees",
+ "translateAttendee",
+ [
+ "attendee_id",
+ "common_name",
+ "rsvp",
+ "role",
+ "status",
+ "type",
+ "is_organizer",
+ "properties",
+ ],
+ db
+ );
+
+ // Update recurrence table to using icalString directly
+ createFunction(db, "translateRecurrence", 17, {
+ onFunctionCall(storArgs) {
+ function parseInt10(x) {
+ return parseInt(x, 10);
+ }
+ try {
+ let [
+ // eslint-disable-next-line no-unused-vars
+ aIndex,
+ aType,
+ aIsNegative,
+ aDates,
+ aCount,
+ aEndDate,
+ aInterval,
+ aSecond,
+ aMinute,
+ aHour,
+ aDay,
+ aMonthday,
+ aYearday,
+ aWeekno,
+ aMonth,
+ aSetPos,
+ aTmpFlags,
+ ] = mapStorageArgs(storArgs);
+
+ let ritem;
+ if (aType == "x-date") {
+ ritem = cal.createRecurrenceDate();
+ ritem.date = textToDate(aDates);
+ ritem.isNegative = !!aIsNegative;
+ } else {
+ ritem = cal.createRecurrenceRule();
+ ritem.type = aType;
+ ritem.isNegative = !!aIsNegative;
+ if (aCount) {
+ try {
+ ritem.count = aCount;
+ } catch (exc) {
+ // Don't fail if setting an invalid count
+ }
+ } else if (aEndDate) {
+ let allday = (aTmpFlags & CAL_ITEM_FLAG.EVENT_ALLDAY) != 0;
+ let untilDate = newDateTime(aEndDate, allday ? "" : "UTC");
+ if (allday) {
+ untilDate.isDate = true;
+ }
+ ritem.untilDate = untilDate;
+ } else {
+ ritem.untilDate = null;
+ }
+
+ try {
+ ritem.interval = aInterval;
+ } catch (exc) {
+ // Don't fail if setting an invalid interval
+ }
+
+ let rtypes = {
+ SECOND: aSecond,
+ MINUTE: aMinute,
+ HOUR: aHour,
+ DAY: aDay,
+ MONTHDAY: aMonthday,
+ YEARDAY: aYearday,
+ WEEKNO: aWeekno,
+ MONTH: aMonth,
+ SETPOS: aSetPos,
+ };
+
+ for (let rtype in rtypes) {
+ if (rtypes[rtype]) {
+ let comp = "BY" + rtype;
+ let rstr = rtypes[rtype].toString();
+ let rarray = rstr.split(",").map(parseInt10);
+ ritem.setComponent(comp, rarray);
+ }
+ }
+ }
+
+ return ritem.icalString;
+ } catch (e) {
+ cal.ERROR("Error converting recurrence: " + e);
+ throw e;
+ }
+ },
+ });
+
+ // The old code relies on the item allday state, we need to temporarily
+ // copy this into the rec table so the above function can update easier.
+ // This column will be deleted during the migrateToIcalString call.
+ addColumn(tbl, "cal_recurrence", ["tmp_date_tz"], "", db);
+ executeSimpleSQL(
+ db,
+ // prettier-ignore
+ "UPDATE cal_recurrence SET tmp_date_tz = " +
+ "(SELECT e.flags FROM cal_events AS e " +
+ " WHERE e.id = cal_recurrence.item_id " +
+ " AND e.cal_id = cal_recurrence.cal_id " +
+ " UNION SELECT t.flags FROM cal_todos AS t " +
+ " WHERE t.id = cal_recurrence.item_id " +
+ " AND t.cal_id = cal_recurrence.cal_id)"
+ );
+
+ migrateToIcalString(
+ tbl,
+ "cal_recurrence",
+ "translateRecurrence",
+ [
+ "recur_index",
+ "recur_type",
+ "is_negative",
+ "dates",
+ "count",
+ "end_date",
+ "interval",
+ "second",
+ "minute",
+ "hour",
+ "day",
+ "monthday",
+ "yearday",
+ "weekno",
+ "month",
+ "setpos",
+ "tmp_date_tz",
+ ],
+ db
+ );
+
+ setDbVersionAndCommit(db, 22);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
+
+upgrade.v23 = function (db, version) {
+ let tbl = upgrade.v22(version < 22 && db, version);
+ LOGdb(db, "Storage: Upgrading to v23");
+ beginTransaction(db);
+ try {
+ addTable(
+ tbl,
+ "cal_parameters",
+ {
+ cal_id: "TEXT",
+ item_id: "TEXT",
+ recurrence_id: "INTEGER",
+ recurrence_id_tz: "TEXT",
+ key1: "TEXT",
+ key2: "TEXT",
+ value: "BLOB",
+ },
+ db
+ );
+ let allIds = ["cal_id", "item_id", "recurrence_id", "recurrence_id_tz"];
+ createIndex(tbl, "cal_parameters", allIds, db);
+ setDbVersionAndCommit(db, 23);
+ } catch (e) {
+ throw reportErrorAndRollback(db, e);
+ }
+ return tbl;
+};
diff --git a/comm/calendar/providers/storage/components.conf b/comm/calendar/providers/storage/components.conf
new file mode 100644
index 0000000000..a040500694
--- /dev/null
+++ b/comm/calendar/providers/storage/components.conf
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# 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/
+
+Classes = [
+ {
+ 'cid': '{b3eaa1c4-5dfe-4c0a-b62a-b3a514218461}',
+ 'contract_ids': ['@mozilla.org/calendar/calendar;1?type=storage'],
+ 'jsm': 'resource:///modules/CalStorageCalendar.jsm',
+ 'constructor': 'CalStorageCalendar',
+ },
+] \ No newline at end of file
diff --git a/comm/calendar/providers/storage/moz.build b/comm/calendar/providers/storage/moz.build
new file mode 100644
index 0000000000..01343a30b5
--- /dev/null
+++ b/comm/calendar/providers/storage/moz.build
@@ -0,0 +1,28 @@
+# 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 += [
+ "CalStorageCalendar.jsm",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+EXTRA_JS_MODULES.calendar += [
+ "CalStorageCachedItemModel.jsm",
+ "CalStorageDatabase.jsm",
+ "calStorageHelpers.jsm",
+ "CalStorageItemModel.jsm",
+ "CalStorageMetaDataModel.jsm",
+ "CalStorageModelBase.jsm",
+ "CalStorageModelFactory.jsm",
+ "CalStorageOfflineModel.jsm",
+ "CalStorageStatements.jsm",
+ "calStorageUpgrade.jsm",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Calendar", "Provider: Local Storage")