diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/providers/storage | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/providers/storage')
-rw-r--r-- | comm/calendar/providers/storage/CalStorageCachedItemModel.jsm | 219 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageCalendar.jsm | 563 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageDatabase.jsm | 333 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageItemModel.jsm | 1374 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageMetaDataModel.jsm | 94 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageModelBase.jsm | 65 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageModelFactory.jsm | 52 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageOfflineModel.jsm | 54 | ||||
-rw-r--r-- | comm/calendar/providers/storage/CalStorageStatements.jsm | 751 | ||||
-rw-r--r-- | comm/calendar/providers/storage/calStorageHelpers.jsm | 121 | ||||
-rw-r--r-- | comm/calendar/providers/storage/calStorageUpgrade.jsm | 1889 | ||||
-rw-r--r-- | comm/calendar/providers/storage/components.conf | 14 | ||||
-rw-r--r-- | comm/calendar/providers/storage/moz.build | 28 |
13 files changed, 5557 insertions, 0 deletions
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") |