diff options
Diffstat (limited to 'comm/calendar/providers')
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") |