diff options
Diffstat (limited to 'services/sync/modules/engines/bookmarks.js')
-rw-r--r-- | services/sync/modules/engines/bookmarks.js | 1378 |
1 files changed, 1378 insertions, 0 deletions
diff --git a/services/sync/modules/engines/bookmarks.js b/services/sync/modules/engines/bookmarks.js new file mode 100644 index 000000000..76a198a8b --- /dev/null +++ b/services/sync/modules/engines/bookmarks.js @@ -0,0 +1,1378 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +this.EXPORTED_SYMBOLS = ['BookmarksEngine', "PlacesItem", "Bookmark", + "BookmarkFolder", "BookmarkQuery", + "Livemark", "BookmarkSeparator"]; + +var Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/PlacesSyncUtils.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://services-common/async.js"); +Cu.import("resource://services-sync/constants.js"); +Cu.import("resource://services-sync/engines.js"); +Cu.import("resource://services-sync/record.js"); +Cu.import("resource://services-sync/util.js"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/PlacesBackups.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BookmarkValidator", + "resource://services-sync/bookmark_validator.js"); +XPCOMUtils.defineLazyGetter(this, "PlacesBundle", () => { + let bundleService = Cc["@mozilla.org/intl/stringbundle;1"] + .getService(Ci.nsIStringBundleService); + return bundleService.createBundle("chrome://places/locale/places.properties"); +}); + +const ANNOS_TO_TRACK = [PlacesSyncUtils.bookmarks.DESCRIPTION_ANNO, + PlacesSyncUtils.bookmarks.SIDEBAR_ANNO, + PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI]; + +const SERVICE_NOT_SUPPORTED = "Service not supported on this platform"; +const FOLDER_SORTINDEX = 1000000; +const { + SOURCE_SYNC, + SOURCE_IMPORT, + SOURCE_IMPORT_REPLACE, +} = Ci.nsINavBookmarksService; + +const SQLITE_MAX_VARIABLE_NUMBER = 999; + +const ORGANIZERQUERY_ANNO = "PlacesOrganizer/OrganizerQuery"; +const ALLBOOKMARKS_ANNO = "AllBookmarks"; +const MOBILE_ANNO = "MobileBookmarks"; + +// The tracker ignores changes made by bookmark import and restore, and +// changes made by Sync. We don't need to exclude `SOURCE_IMPORT`, but both +// import and restore fire `bookmarks-restore-*` observer notifications, and +// the tracker doesn't currently distinguish between the two. +const IGNORED_SOURCES = [SOURCE_SYNC, SOURCE_IMPORT, SOURCE_IMPORT_REPLACE]; + +// Returns the constructor for a bookmark record type. +function getTypeObject(type) { + switch (type) { + case "bookmark": + case "microsummary": + return Bookmark; + case "query": + return BookmarkQuery; + case "folder": + return BookmarkFolder; + case "livemark": + return Livemark; + case "separator": + return BookmarkSeparator; + case "item": + return PlacesItem; + } + return null; +} + +this.PlacesItem = function PlacesItem(collection, id, type) { + CryptoWrapper.call(this, collection, id); + this.type = type || "item"; +} +PlacesItem.prototype = { + decrypt: function PlacesItem_decrypt(keyBundle) { + // Do the normal CryptoWrapper decrypt, but change types before returning + let clear = CryptoWrapper.prototype.decrypt.call(this, keyBundle); + + // Convert the abstract places item to the actual object type + if (!this.deleted) + this.__proto__ = this.getTypeObject(this.type).prototype; + + return clear; + }, + + getTypeObject: function PlacesItem_getTypeObject(type) { + let recordObj = getTypeObject(type); + if (!recordObj) { + throw new Error("Unknown places item object type: " + type); + } + return recordObj; + }, + + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.PlacesItem", + + // Converts the record to a Sync bookmark object that can be passed to + // `PlacesSyncUtils.bookmarks.{insert, update}`. + toSyncBookmark() { + return { + kind: this.type, + syncId: this.id, + parentSyncId: this.parentid, + }; + }, + + // Populates the record from a Sync bookmark object returned from + // `PlacesSyncUtils.bookmarks.fetch`. + fromSyncBookmark(item) { + this.parentid = item.parentSyncId; + this.parentName = item.parentTitle; + }, +}; + +Utils.deferGetSet(PlacesItem, + "cleartext", + ["hasDupe", "parentid", "parentName", "type"]); + +this.Bookmark = function Bookmark(collection, id, type) { + PlacesItem.call(this, collection, id, type || "bookmark"); +} +Bookmark.prototype = { + __proto__: PlacesItem.prototype, + _logName: "Sync.Record.Bookmark", + + toSyncBookmark() { + let info = PlacesItem.prototype.toSyncBookmark.call(this); + info.title = this.title; + info.url = this.bmkUri; + info.description = this.description; + info.loadInSidebar = this.loadInSidebar; + info.tags = this.tags; + info.keyword = this.keyword; + return info; + }, + + fromSyncBookmark(item) { + PlacesItem.prototype.fromSyncBookmark.call(this, item); + this.title = item.title; + this.bmkUri = item.url.href; + this.description = item.description; + this.loadInSidebar = item.loadInSidebar; + this.tags = item.tags; + this.keyword = item.keyword; + }, +}; + +Utils.deferGetSet(Bookmark, + "cleartext", + ["title", "bmkUri", "description", + "loadInSidebar", "tags", "keyword"]); + +this.BookmarkQuery = function BookmarkQuery(collection, id) { + Bookmark.call(this, collection, id, "query"); +} +BookmarkQuery.prototype = { + __proto__: Bookmark.prototype, + _logName: "Sync.Record.BookmarkQuery", + + toSyncBookmark() { + let info = Bookmark.prototype.toSyncBookmark.call(this); + info.folder = this.folderName; + info.query = this.queryId; + return info; + }, + + fromSyncBookmark(item) { + Bookmark.prototype.fromSyncBookmark.call(this, item); + this.folderName = item.folder; + this.queryId = item.query; + }, +}; + +Utils.deferGetSet(BookmarkQuery, + "cleartext", + ["folderName", "queryId"]); + +this.BookmarkFolder = function BookmarkFolder(collection, id, type) { + PlacesItem.call(this, collection, id, type || "folder"); +} +BookmarkFolder.prototype = { + __proto__: PlacesItem.prototype, + _logName: "Sync.Record.Folder", + + toSyncBookmark() { + let info = PlacesItem.prototype.toSyncBookmark.call(this); + info.description = this.description; + info.title = this.title; + return info; + }, + + fromSyncBookmark(item) { + PlacesItem.prototype.fromSyncBookmark.call(this, item); + this.title = item.title; + this.description = item.description; + this.children = item.childSyncIds; + }, +}; + +Utils.deferGetSet(BookmarkFolder, "cleartext", ["description", "title", + "children"]); + +this.Livemark = function Livemark(collection, id) { + BookmarkFolder.call(this, collection, id, "livemark"); +} +Livemark.prototype = { + __proto__: BookmarkFolder.prototype, + _logName: "Sync.Record.Livemark", + + toSyncBookmark() { + let info = BookmarkFolder.prototype.toSyncBookmark.call(this); + info.feed = this.feedUri; + info.site = this.siteUri; + return info; + }, + + fromSyncBookmark(item) { + BookmarkFolder.prototype.fromSyncBookmark.call(this, item); + this.feedUri = item.feed.href; + if (item.site) { + this.siteUri = item.site.href; + } + }, +}; + +Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]); + +this.BookmarkSeparator = function BookmarkSeparator(collection, id) { + PlacesItem.call(this, collection, id, "separator"); +} +BookmarkSeparator.prototype = { + __proto__: PlacesItem.prototype, + _logName: "Sync.Record.Separator", + + fromSyncBookmark(item) { + PlacesItem.prototype.fromSyncBookmark.call(this, item); + this.pos = item.index; + }, +}; + +Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos"); + +this.BookmarksEngine = function BookmarksEngine(service) { + SyncEngine.call(this, "Bookmarks", service); +} +BookmarksEngine.prototype = { + __proto__: SyncEngine.prototype, + _recordObj: PlacesItem, + _storeObj: BookmarksStore, + _trackerObj: BookmarksTracker, + version: 2, + _defaultSort: "index", + + syncPriority: 4, + allowSkippedRecord: false, + + // A diagnostic helper to get the string value for a bookmark's URL given + // its ID. Always returns a string - on error will return a string in the + // form of "<description of error>" as this is purely for, eg, logging. + // (This means hitting the DB directly and we don't bother using a cached + // statement - we should rarely hit this.) + _getStringUrlForId(id) { + let url; + try { + let stmt = this._store._getStmt(` + SELECT h.url + FROM moz_places h + JOIN moz_bookmarks b ON h.id = b.fk + WHERE b.id = :id`); + stmt.params.id = id; + let rows = Async.querySpinningly(stmt, ["url"]); + url = rows.length == 0 ? "<not found>" : rows[0].url; + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + if (ex instanceof Ci.mozIStorageError) { + url = `<failed: Storage error: ${ex.message} (${ex.result})>`; + } else { + url = `<failed: ${ex.toString()}>`; + } + } + return url; + }, + + _guidMapFailed: false, + _buildGUIDMap: function _buildGUIDMap() { + let store = this._store; + let guidMap = {}; + let tree = Async.promiseSpinningly(PlacesUtils.promiseBookmarksTree("", { + includeItemIds: true + })); + function* walkBookmarksTree(tree, parent=null) { + if (tree) { + // Skip root node + if (parent) { + yield [tree, parent]; + } + if (tree.children) { + for (let child of tree.children) { + store._sleep(0); // avoid jank while looping. + yield* walkBookmarksTree(child, tree); + } + } + } + } + + function* walkBookmarksRoots(tree, rootIDs) { + for (let id of rootIDs) { + let bookmarkRoot = tree.children.find(child => child.id === id); + if (bookmarkRoot === null) { + continue; + } + yield* walkBookmarksTree(bookmarkRoot, tree); + } + } + + let rootsToWalk = getChangeRootIds(); + + for (let [node, parent] of walkBookmarksRoots(tree, rootsToWalk)) { + let {guid, id, type: placeType} = node; + guid = PlacesSyncUtils.bookmarks.guidToSyncId(guid); + let key; + switch (placeType) { + case PlacesUtils.TYPE_X_MOZ_PLACE: + // Bookmark + let query = null; + if (node.annos && node.uri.startsWith("place:")) { + query = node.annos.find(({name}) => + name === PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO); + } + if (query && query.value) { + key = "q" + query.value; + } else { + key = "b" + node.uri + ":" + (node.title || ""); + } + break; + case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: + // Folder + key = "f" + (node.title || ""); + break; + case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: + // Separator + key = "s" + node.index; + break; + default: + this._log.error("Unknown place type: '"+placeType+"'"); + continue; + } + + let parentName = parent.title || ""; + if (guidMap[parentName] == null) + guidMap[parentName] = {}; + + // If the entry already exists, remember that there are explicit dupes. + let entry = new String(guid); + entry.hasDupe = guidMap[parentName][key] != null; + + // Remember this item's GUID for its parent-name/key pair. + guidMap[parentName][key] = entry; + this._log.trace("Mapped: " + [parentName, key, entry, entry.hasDupe]); + } + + return guidMap; + }, + + // Helper function to get a dupe GUID for an item. + _mapDupe: function _mapDupe(item) { + // Figure out if we have something to key with. + let key; + let altKey; + switch (item.type) { + case "query": + // Prior to Bug 610501, records didn't carry their Smart Bookmark + // anno, so we won't be able to dupe them correctly. This altKey + // hack should get them to dupe correctly. + if (item.queryId) { + key = "q" + item.queryId; + altKey = "b" + item.bmkUri + ":" + (item.title || ""); + break; + } + // No queryID? Fall through to the regular bookmark case. + case "bookmark": + case "microsummary": + key = "b" + item.bmkUri + ":" + (item.title || ""); + break; + case "folder": + case "livemark": + key = "f" + (item.title || ""); + break; + case "separator": + key = "s" + item.pos; + break; + default: + return; + } + + // Figure out if we have a map to use! + // This will throw in some circumstances. That's fine. + let guidMap = this._guidMap; + + // Give the GUID if we have the matching pair. + let parentName = item.parentName || ""; + this._log.trace("Finding mapping: " + parentName + ", " + key); + let parent = guidMap[parentName]; + + if (!parent) { + this._log.trace("No parent => no dupe."); + return undefined; + } + + let dupe = parent[key]; + + if (dupe) { + this._log.trace("Mapped dupe: " + dupe); + return dupe; + } + + if (altKey) { + dupe = parent[altKey]; + if (dupe) { + this._log.trace("Mapped dupe using altKey " + altKey + ": " + dupe); + return dupe; + } + } + + this._log.trace("No dupe found for key " + key + "/" + altKey + "."); + return undefined; + }, + + _syncStartup: function _syncStart() { + SyncEngine.prototype._syncStartup.call(this); + + let cb = Async.makeSpinningCallback(); + Task.spawn(function* () { + // For first-syncs, make a backup for the user to restore + if (this.lastSync == 0) { + this._log.debug("Bookmarks backup starting."); + yield PlacesBackups.create(null, true); + this._log.debug("Bookmarks backup done."); + } + }.bind(this)).then( + cb, ex => { + // Failure to create a backup is somewhat bad, but probably not bad + // enough to prevent syncing of bookmarks - so just log the error and + // continue. + this._log.warn("Error while backing up bookmarks, but continuing with sync", ex); + cb(); + } + ); + + cb.wait(); + + this.__defineGetter__("_guidMap", function() { + // Create a mapping of folder titles and separator positions to GUID. + // We do this lazily so that we don't do any work unless we reconcile + // incoming items. + let guidMap; + try { + guidMap = this._buildGUIDMap(); + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.warn("Error while building GUID map, skipping all other incoming items", ex); + throw {code: Engine.prototype.eEngineAbortApplyIncoming, + cause: ex}; + } + delete this._guidMap; + return this._guidMap = guidMap; + }); + + this._store._childrenToOrder = {}; + this._store.clearPendingDeletions(); + }, + + _deletePending() { + // Delete pending items -- See the comment above BookmarkStore's deletePending + let newlyModified = Async.promiseSpinningly(this._store.deletePending()); + let now = this._tracker._now(); + this._log.debug("Deleted pending items", newlyModified); + for (let modifiedSyncID of newlyModified) { + if (!this._modified.has(modifiedSyncID)) { + this._modified.set(modifiedSyncID, { timestamp: now, deleted: false }); + } + } + }, + + // We avoid reviving folders since reviving them properly would require + // reviving their children as well. Unfortunately, this is the wrong choice + // in the case of a bookmark restore where wipeServer failed -- if the + // server has the folder as deleted, we *would* want to reupload this folder. + // This is mitigated by the fact that we move any undeleted children to the + // grandparent when deleting the parent. + _shouldReviveRemotelyDeletedRecord(item) { + let kind = Async.promiseSpinningly( + PlacesSyncUtils.bookmarks.getKindForSyncId(item.id)); + if (kind === PlacesSyncUtils.bookmarks.KINDS.FOLDER) { + return false; + } + + // In addition to preventing the deletion of this record (handled by the caller), + // we need to mark the parent of this record for uploading next sync, in order + // to ensure its children array is accurate. + let modifiedTimestamp = this._modified.getModifiedTimestamp(item.id); + if (!modifiedTimestamp) { + // We only expect this to be called with items locally modified, so + // something strange is going on - play it safe and don't revive it. + this._log.error("_shouldReviveRemotelyDeletedRecord called on unmodified item: " + item.id); + return false; + } + + let localID = this._store.idForGUID(item.id); + let localParentID = PlacesUtils.bookmarks.getFolderIdForItem(localID); + let localParentSyncID = this._store.GUIDForId(localParentID); + + this._log.trace(`Reviving item "${item.id}" and marking parent ${localParentSyncID} as modified.`); + + if (!this._modified.has(localParentSyncID)) { + this._modified.set(localParentSyncID, { + timestamp: modifiedTimestamp, + deleted: false + }); + } + return true + }, + + _processIncoming: function (newitems) { + try { + SyncEngine.prototype._processIncoming.call(this, newitems); + } finally { + try { + this._deletePending(); + } finally { + // Reorder children. + this._store._orderChildren(); + delete this._store._childrenToOrder; + } + } + }, + + _syncFinish: function _syncFinish() { + SyncEngine.prototype._syncFinish.call(this); + this._tracker._ensureMobileQuery(); + }, + + _syncCleanup: function _syncCleanup() { + SyncEngine.prototype._syncCleanup.call(this); + delete this._guidMap; + }, + + _createRecord: function _createRecord(id) { + // Create the record as usual, but mark it as having dupes if necessary. + let record = SyncEngine.prototype._createRecord.call(this, id); + let entry = this._mapDupe(record); + if (entry != null && entry.hasDupe) { + record.hasDupe = true; + } + return record; + }, + + _findDupe: function _findDupe(item) { + this._log.trace("Finding dupe for " + item.id + + " (already duped: " + item.hasDupe + ")."); + + // Don't bother finding a dupe if the incoming item has duplicates. + if (item.hasDupe) { + this._log.trace(item.id + " already a dupe: not finding one."); + return; + } + let mapped = this._mapDupe(item); + this._log.debug(item.id + " mapped to " + mapped); + // We must return a string, not an object, and the entries in the GUIDMap + // are created via "new String()" making them an object. + return mapped ? mapped.toString() : mapped; + }, + + pullAllChanges() { + return new BookmarksChangeset(this._store.getAllIDs()); + }, + + pullNewChanges() { + let modifiedGUIDs = this._getModifiedGUIDs(); + if (!modifiedGUIDs.length) { + return new BookmarksChangeset(this._tracker.changedIDs); + } + + // We don't use `PlacesUtils.promiseDBConnection` here because + // `getChangedIDs` might be called while we're in a batch, meaning we + // won't see any changes until the batch finishes and the transaction + // commits. + let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) + .DBConnection; + + // Filter out tags, organizer queries, and other descendants that we're + // not tracking. We chunk `modifiedGUIDs` because SQLite limits the number + // of bound parameters per query. + for (let startIndex = 0; + startIndex < modifiedGUIDs.length; + startIndex += SQLITE_MAX_VARIABLE_NUMBER) { + + let chunkLength = Math.min(SQLITE_MAX_VARIABLE_NUMBER, + modifiedGUIDs.length - startIndex); + + let query = ` + WITH RECURSIVE + modifiedGuids(guid) AS ( + VALUES ${new Array(chunkLength).fill("(?)").join(", ")} + ), + syncedItems(id) AS ( + VALUES ${getChangeRootIds().map(id => `(${id})`).join(", ")} + UNION ALL + SELECT b.id + FROM moz_bookmarks b + JOIN syncedItems s ON b.parent = s.id + ) + SELECT b.guid + FROM modifiedGuids m + JOIN moz_bookmarks b ON b.guid = m.guid + LEFT JOIN syncedItems s ON b.id = s.id + WHERE s.id IS NULL + `; + + let statement = db.createAsyncStatement(query); + try { + for (let i = 0; i < chunkLength; i++) { + statement.bindByIndex(i, modifiedGUIDs[startIndex + i]); + } + let results = Async.querySpinningly(statement, ["guid"]); + for (let { guid } of results) { + let syncID = PlacesSyncUtils.bookmarks.guidToSyncId(guid); + this._tracker.removeChangedID(syncID); + } + } finally { + statement.finalize(); + } + } + + return new BookmarksChangeset(this._tracker.changedIDs); + }, + + // Returns an array of Places GUIDs for all changed items. Ignores deletions, + // which won't exist in the DB and shouldn't be removed from the tracker. + _getModifiedGUIDs() { + let guids = []; + for (let syncID in this._tracker.changedIDs) { + if (this._tracker.changedIDs[syncID].deleted === true) { + // The `===` check also filters out old persisted timestamps, + // which won't have a `deleted` property. + continue; + } + let guid = PlacesSyncUtils.bookmarks.syncIdToGuid(syncID); + guids.push(guid); + } + return guids; + }, + + // Called when _findDupe returns a dupe item and the engine has decided to + // switch the existing item to the new incoming item. + _switchItemToDupe(localDupeGUID, incomingItem) { + // We unconditionally change the item's ID in case the engine knows of + // an item but doesn't expose it through itemExists. If the API + // contract were stronger, this could be changed. + this._log.debug("Switching local ID to incoming: " + localDupeGUID + " -> " + + incomingItem.id); + this._store.changeItemID(localDupeGUID, incomingItem.id); + + // And mark the parent as being modified. Given we de-dupe based on the + // parent *name* it's possible the item having its GUID changed has a + // different parent from the incoming record. + // So we need to find the GUID of the local parent. + let now = this._tracker._now(); + let localID = this._store.idForGUID(incomingItem.id); + let localParentID = PlacesUtils.bookmarks.getFolderIdForItem(localID); + let localParentGUID = this._store.GUIDForId(localParentID); + this._modified.set(localParentGUID, { modified: now, deleted: false }); + + // And we also add the parent as reflected in the incoming record as the + // de-dupe process might have used an existing item in a different folder. + // But only if the parent exists, otherwise we will upload a deleted item + // when it might actually be valid, just unknown to us. Note that this + // scenario will still leave us with inconsistent client and server states; + // the incoming record on the server references a parent that isn't the + // actual parent locally - see bug 1297955. + if (localParentGUID != incomingItem.parentid) { + let remoteParentID = this._store.idForGUID(incomingItem.parentid); + if (remoteParentID > 0) { + // The parent specified in the record does exist, so we are going to + // attempt a move when we come to applying the record. Mark the parent + // as being modified so we will later upload it with the new child + // reference. + this._modified.set(incomingItem.parentid, { modified: now, deleted: false }); + } else { + // We aren't going to do a move as we don't have the parent (yet?). + // When applying the record we will add our special PARENT_ANNO + // annotation, so if it arrives in the future (either this Sync or a + // later one) it will be reparented. + this._log.debug(`Incoming duplicate item ${incomingItem.id} specifies ` + + `non-existing parent ${incomingItem.parentid}`); + } + } + + // The local, duplicate ID is always deleted on the server - but for + // bookmarks it is a logical delete. + // Simply adding this (now non-existing) ID to the tracker is enough. + this._modified.set(localDupeGUID, { modified: now, deleted: true }); + }, + getValidator() { + return new BookmarkValidator(); + } +}; + +function BookmarksStore(name, engine) { + Store.call(this, name, engine); + this._foldersToDelete = new Set(); + this._atomsToDelete = new Set(); + // Explicitly nullify our references to our cached services so we don't leak + Svc.Obs.add("places-shutdown", function() { + for (let query in this._stmts) { + let stmt = this._stmts[query]; + stmt.finalize(); + } + this._stmts = {}; + }, this); +} +BookmarksStore.prototype = { + __proto__: Store.prototype, + + itemExists: function BStore_itemExists(id) { + return this.idForGUID(id) > 0; + }, + + applyIncoming: function BStore_applyIncoming(record) { + this._log.debug("Applying record " + record.id); + let isSpecial = PlacesSyncUtils.bookmarks.ROOTS.includes(record.id); + + if (record.deleted) { + if (isSpecial) { + this._log.warn("Ignoring deletion for special record " + record.id); + return; + } + + // Don't bother with pre and post-processing for deletions. + Store.prototype.applyIncoming.call(this, record); + return; + } + + // For special folders we're only interested in child ordering. + if (isSpecial && record.children) { + this._log.debug("Processing special node: " + record.id); + // Reorder children later + this._childrenToOrder[record.id] = record.children; + return; + } + + // Skip malformed records. (Bug 806460.) + if (record.type == "query" && + !record.bmkUri) { + this._log.warn("Skipping malformed query bookmark: " + record.id); + return; + } + + // Figure out the local id of the parent GUID if available + let parentGUID = record.parentid; + if (!parentGUID) { + throw "Record " + record.id + " has invalid parentid: " + parentGUID; + } + this._log.debug("Remote parent is " + parentGUID); + + // Do the normal processing of incoming records + Store.prototype.applyIncoming.call(this, record); + + if (record.type == "folder" && record.children) { + this._childrenToOrder[record.id] = record.children; + } + }, + + create: function BStore_create(record) { + let info = record.toSyncBookmark(); + // This can throw if we're inserting an invalid or incomplete bookmark. + // That's fine; the exception will be caught by `applyIncomingBatch` + // without aborting further processing. + let item = Async.promiseSpinningly(PlacesSyncUtils.bookmarks.insert(info)); + if (item) { + this._log.debug(`Created ${item.kind} ${item.syncId} under ${ + item.parentSyncId}`, item); + } + }, + + remove: function BStore_remove(record) { + if (PlacesSyncUtils.bookmarks.isRootSyncID(record.id)) { + this._log.warn("Refusing to remove special folder " + record.id); + return; + } + let recordKind = Async.promiseSpinningly( + PlacesSyncUtils.bookmarks.getKindForSyncId(record.id)); + let isFolder = recordKind === PlacesSyncUtils.bookmarks.KINDS.FOLDER; + this._log.trace(`Buffering removal of item "${record.id}" of type "${recordKind}".`); + if (isFolder) { + this._foldersToDelete.add(record.id); + } else { + this._atomsToDelete.add(record.id); + } + }, + + update: function BStore_update(record) { + let info = record.toSyncBookmark(); + let item = Async.promiseSpinningly(PlacesSyncUtils.bookmarks.update(info)); + if (item) { + this._log.debug(`Updated ${item.kind} ${item.syncId} under ${ + item.parentSyncId}`, item); + } + }, + + _orderChildren: function _orderChildren() { + let promises = Object.keys(this._childrenToOrder).map(syncID => { + let children = this._childrenToOrder[syncID]; + return PlacesSyncUtils.bookmarks.order(syncID, children).catch(ex => { + this._log.debug(`Could not order children for ${syncID}`, ex); + }); + }); + Async.promiseSpinningly(Promise.all(promises)); + }, + + // There's some complexity here around pending deletions. Our goals: + // + // - Don't delete any bookmarks a user has created but not explicitly deleted + // (This includes any bookmark that was not a child of the folder at the + // time the deletion was recorded, and also bookmarks restored from a backup). + // - Don't undelete any bookmark without ensuring the server structure + // includes it (see `BookmarkEngine.prototype._shouldReviveRemotelyDeletedRecord`) + // + // This leads the following approach: + // + // - Additions, moves, and updates are processed before deletions. + // - To do this, all deletion operations are buffered during a sync. Folders + // we plan on deleting have their sync id's stored in `this._foldersToDelete`, + // and non-folders we plan on deleting have their sync id's stored in + // `this._atomsToDelete`. + // - The exception to this is the moves that occur to fix the order of bookmark + // children, which are performed after we process deletions. + // - Non-folders are deleted before folder deletions, so that when we process + // folder deletions we know the correct state. + // - Remote deletions always win for folders, but do not result in recursive + // deletion of children. This is a hack because we're not able to distinguish + // between value changes and structural changes to folders, and we don't even + // have the old server record to compare to. See `BookmarkEngine`'s + // `_shouldReviveRemotelyDeletedRecord` method. + // - When a folder is deleted, its remaining children are moved in order to + // their closest living ancestor. If this is interrupted (unlikely, but + // possible given that we don't perform this operation in a transaction), + // we revive the folder. + // - Remote deletions can lose for non-folders, but only until we handle + // bookmark restores correctly (removing stale state from the server -- this + // is to say, if bug 1230011 is fixed, we should never revive bookmarks). + + deletePending: Task.async(function* deletePending() { + yield this._deletePendingAtoms(); + let guidsToUpdate = yield this._deletePendingFolders(); + this.clearPendingDeletions(); + return guidsToUpdate; + }), + + clearPendingDeletions() { + this._foldersToDelete.clear(); + this._atomsToDelete.clear(); + }, + + _deleteAtom: Task.async(function* _deleteAtom(syncID) { + try { + let info = yield PlacesSyncUtils.bookmarks.remove(syncID, { + preventRemovalOfNonEmptyFolders: true + }); + this._log.trace(`Removed item ${syncID} with type ${info.type}`); + } catch (ex) { + // Likely already removed. + this._log.trace(`Error removing ${syncID}`, ex); + } + }), + + _deletePendingAtoms() { + return Promise.all( + [...this._atomsToDelete.values()] + .map(syncID => this._deleteAtom(syncID))); + }, + + // Returns an array of sync ids that need updates. + _deletePendingFolders: Task.async(function* _deletePendingFolders() { + // To avoid data loss, we don't want to just delete the folder outright, + // so we buffer folder deletions and process them at the end (now). + // + // At this point, any member in the folder that remains is either a folder + // pending deletion (which we'll get to in this function), or an item that + // should not be deleted. To avoid deleting these items, we first move them + // to the parent of the folder we're about to delete. + let needUpdate = new Set(); + for (let syncId of this._foldersToDelete) { + let childSyncIds = yield PlacesSyncUtils.bookmarks.fetchChildSyncIds(syncId); + if (!childSyncIds.length) { + // No children -- just delete the folder. + yield this._deleteAtom(syncId) + continue; + } + // We could avoid some redundant work here by finding the nearest + // grandparent who isn't present in `this._toDelete`... + + let grandparentSyncId = this.GUIDForId( + PlacesUtils.bookmarks.getFolderIdForItem( + this.idForGUID(PlacesSyncUtils.bookmarks.syncIdToGuid(syncId)))); + + this._log.trace(`Moving ${childSyncIds.length} children of "${syncId}" to ` + + `grandparent "${grandparentSyncId}" before deletion.`); + + // Move children out of the parent and into the grandparent + yield Promise.all(childSyncIds.map(child => PlacesSyncUtils.bookmarks.update({ + syncId: child, + parentSyncId: grandparentSyncId + }))); + + // Delete the (now empty) parent + try { + yield PlacesSyncUtils.bookmarks.remove(syncId, { + preventRemovalOfNonEmptyFolders: true + }); + } catch (e) { + // We failed, probably because someone added something to this folder + // between when we got the children and now (or the database is corrupt, + // or something else happened...) This is unlikely, but possible. To + // avoid corruption in this case, we need to reupload the record to the + // server. + // + // (Ideally this whole operation would be done in a transaction, and this + // wouldn't be possible). + needUpdate.add(syncId); + } + + // Add children (for parentid) and grandparent (for children list) to set + // of records needing an update, *unless* they're marked for deletion. + if (!this._foldersToDelete.has(grandparentSyncId)) { + needUpdate.add(grandparentSyncId); + } + for (let childSyncId of childSyncIds) { + if (!this._foldersToDelete.has(childSyncId)) { + needUpdate.add(childSyncId); + } + } + } + return [...needUpdate]; + }), + + changeItemID: function BStore_changeItemID(oldID, newID) { + this._log.debug("Changing GUID " + oldID + " to " + newID); + + Async.promiseSpinningly(PlacesSyncUtils.bookmarks.changeGuid(oldID, newID)); + }, + + // Create a record starting from the weave id (places guid) + createRecord: function createRecord(id, collection) { + let item = Async.promiseSpinningly(PlacesSyncUtils.bookmarks.fetch(id)); + if (!item) { // deleted item + let record = new PlacesItem(collection, id); + record.deleted = true; + return record; + } + + let recordObj = getTypeObject(item.kind); + if (!recordObj) { + this._log.warn("Unknown item type, cannot serialize: " + item.kind); + recordObj = PlacesItem; + } + let record = new recordObj(collection, id); + record.fromSyncBookmark(item); + + record.sortindex = this._calculateIndex(record); + + return record; + }, + + _stmts: {}, + _getStmt: function(query) { + if (query in this._stmts) { + return this._stmts[query]; + } + + this._log.trace("Creating SQL statement: " + query); + let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) + .DBConnection; + return this._stmts[query] = db.createAsyncStatement(query); + }, + + get _frecencyStm() { + return this._getStmt( + "SELECT frecency " + + "FROM moz_places " + + "WHERE url_hash = hash(:url) AND url = :url " + + "LIMIT 1"); + }, + _frecencyCols: ["frecency"], + + GUIDForId: function GUIDForId(id) { + let guid = Async.promiseSpinningly(PlacesUtils.promiseItemGuid(id)); + return PlacesSyncUtils.bookmarks.guidToSyncId(guid); + }, + + idForGUID: function idForGUID(guid) { + // guid might be a String object rather than a string. + guid = PlacesSyncUtils.bookmarks.syncIdToGuid(guid.toString()); + + return Async.promiseSpinningly(PlacesUtils.promiseItemId(guid).catch( + ex => -1)); + }, + + _calculateIndex: function _calculateIndex(record) { + // Ensure folders have a very high sort index so they're not synced last. + if (record.type == "folder") + return FOLDER_SORTINDEX; + + // For anything directly under the toolbar, give it a boost of more than an + // unvisited bookmark + let index = 0; + if (record.parentid == "toolbar") + index += 150; + + // Add in the bookmark's frecency if we have something. + if (record.bmkUri != null) { + this._frecencyStm.params.url = record.bmkUri; + let result = Async.querySpinningly(this._frecencyStm, this._frecencyCols); + if (result.length) + index += result[0].frecency; + } + + return index; + }, + + getAllIDs: function BStore_getAllIDs() { + let items = {}; + + let query = ` + WITH RECURSIVE + changeRootContents(id) AS ( + VALUES ${getChangeRootIds().map(id => `(${id})`).join(", ")} + UNION ALL + SELECT b.id + FROM moz_bookmarks b + JOIN changeRootContents c ON b.parent = c.id + ) + SELECT guid + FROM changeRootContents + JOIN moz_bookmarks USING (id) + `; + + let statement = this._getStmt(query); + let results = Async.querySpinningly(statement, ["guid"]); + for (let { guid } of results) { + let syncID = PlacesSyncUtils.bookmarks.guidToSyncId(guid); + items[syncID] = { modified: 0, deleted: false }; + } + + return items; + }, + + wipe: function BStore_wipe() { + this.clearPendingDeletions(); + Async.promiseSpinningly(Task.spawn(function* () { + // Save a backup before clearing out all bookmarks. + yield PlacesBackups.create(null, true); + yield PlacesUtils.bookmarks.eraseEverything({ + source: SOURCE_SYNC, + }); + })); + } +}; + +function BookmarksTracker(name, engine) { + this._batchDepth = 0; + this._batchSawScoreIncrement = false; + Tracker.call(this, name, engine); + + Svc.Obs.add("places-shutdown", this); +} +BookmarksTracker.prototype = { + __proto__: Tracker.prototype, + + //`_ignore` checks the change source for each observer notification, so we + // don't want to let the engine ignore all changes during a sync. + get ignoreAll() { + return false; + }, + + // Define an empty setter so that the engine doesn't throw a `TypeError` + // setting a read-only property. + set ignoreAll(value) {}, + + startTracking: function() { + PlacesUtils.bookmarks.addObserver(this, true); + Svc.Obs.add("bookmarks-restore-begin", this); + Svc.Obs.add("bookmarks-restore-success", this); + Svc.Obs.add("bookmarks-restore-failed", this); + }, + + stopTracking: function() { + PlacesUtils.bookmarks.removeObserver(this); + Svc.Obs.remove("bookmarks-restore-begin", this); + Svc.Obs.remove("bookmarks-restore-success", this); + Svc.Obs.remove("bookmarks-restore-failed", this); + }, + + observe: function observe(subject, topic, data) { + Tracker.prototype.observe.call(this, subject, topic, data); + + switch (topic) { + case "bookmarks-restore-begin": + this._log.debug("Ignoring changes from importing bookmarks."); + break; + case "bookmarks-restore-success": + this._log.debug("Tracking all items on successful import."); + + this._log.debug("Restore succeeded: wiping server and other clients."); + this.engine.service.resetClient([this.name]); + this.engine.service.wipeServer([this.name]); + this.engine.service.clientsEngine.sendCommand("wipeEngine", [this.name]); + break; + case "bookmarks-restore-failed": + this._log.debug("Tracking all items on failed import."); + break; + } + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsINavBookmarkObserver, + Ci.nsINavBookmarkObserver_MOZILLA_1_9_1_ADDITIONS, + Ci.nsISupportsWeakReference + ]), + + addChangedID(id, change) { + if (!id) { + this._log.warn("Attempted to add undefined ID to tracker"); + return false; + } + if (this._ignored.includes(id)) { + return false; + } + let shouldSaveChange = false; + let currentChange = this.changedIDs[id]; + if (currentChange) { + if (typeof currentChange == "number") { + // Allow raw timestamps for backward-compatibility with persisted + // changed IDs. The new format uses tuples to track deleted items. + shouldSaveChange = currentChange < change.modified; + } else { + shouldSaveChange = currentChange.modified < change.modified || + currentChange.deleted != change.deleted; + } + } else { + shouldSaveChange = true; + } + if (shouldSaveChange) { + this._saveChangedID(id, change); + } + return true; + }, + + /** + * Add a bookmark GUID to be uploaded and bump up the sync score. + * + * @param itemId + * The Places item ID of the bookmark to upload. + * @param guid + * The Places GUID of the bookmark to upload. + * @param isTombstone + * Whether we're uploading a tombstone for a removed bookmark. + */ + _add: function BMT__add(itemId, guid, isTombstone = false) { + let syncID = PlacesSyncUtils.bookmarks.guidToSyncId(guid); + let info = { modified: Date.now() / 1000, deleted: isTombstone }; + if (this.addChangedID(syncID, info)) { + this._upScore(); + } + }, + + /* Every add/remove/change will trigger a sync for MULTI_DEVICE (except in + a batch operation, where we do it at the end of the batch) */ + _upScore: function BMT__upScore() { + if (this._batchDepth == 0) { + this.score += SCORE_INCREMENT_XLARGE; + } else { + this._batchSawScoreIncrement = true; + } + }, + + onItemAdded: function BMT_onItemAdded(itemId, folder, index, + itemType, uri, title, dateAdded, + guid, parentGuid, source) { + if (IGNORED_SOURCES.includes(source)) { + return; + } + + this._log.trace("onItemAdded: " + itemId); + this._add(itemId, guid); + this._add(folder, parentGuid); + }, + + onItemRemoved: function (itemId, parentId, index, type, uri, + guid, parentGuid, source) { + if (IGNORED_SOURCES.includes(source)) { + return; + } + + // Ignore changes to tags (folders under the tags folder). + if (parentId == PlacesUtils.tagsFolderId) { + return; + } + + let grandParentId = -1; + try { + grandParentId = PlacesUtils.bookmarks.getFolderIdForItem(parentId); + } catch (ex) { + // `getFolderIdForItem` can throw if the item no longer exists, such as + // when we've removed a subtree using `removeFolderChildren`. + return; + } + + // Ignore tag items (the actual instance of a tag for a bookmark). + if (grandParentId == PlacesUtils.tagsFolderId) { + return; + } + + /** + * The above checks are incomplete: we can still write tombstones for + * items that we don't track, and upload extraneous roots. + * + * Consider the left pane root: it's a child of the Places root, and has + * children and grandchildren. `PlacesUIUtils` can create, delete, and + * recreate it as needed. We can't determine ancestors when the root or its + * children are deleted, because they've already been removed from the + * database when `onItemRemoved` is called. Likewise, we can't check their + * "exclude from backup" annos, because they've *also* been removed. + * + * So, we end up writing tombstones for the left pane queries and left + * pane root. For good measure, we'll also upload the Places root, because + * it's the parent of the left pane root. + * + * As a workaround, we can track the parent GUID and reconstruct the item's + * ancestry at sync time. This is complicated, and the previous behavior was + * already wrong, so we'll wait for bug 1258127 to fix this generally. + */ + this._log.trace("onItemRemoved: " + itemId); + this._add(itemId, guid, /* isTombstone */ true); + this._add(parentId, parentGuid); + }, + + _ensureMobileQuery: function _ensureMobileQuery() { + let find = val => + PlacesUtils.annotations.getItemsWithAnnotation(ORGANIZERQUERY_ANNO, {}).filter( + id => PlacesUtils.annotations.getItemAnnotation(id, ORGANIZERQUERY_ANNO) == val + ); + + // Don't continue if the Library isn't ready + let all = find(ALLBOOKMARKS_ANNO); + if (all.length == 0) + return; + + let mobile = find(MOBILE_ANNO); + let queryURI = Utils.makeURI("place:folder=" + PlacesUtils.mobileFolderId); + let title = PlacesBundle.GetStringFromName("MobileBookmarksFolderTitle"); + + // Don't add OR remove the mobile bookmarks if there's nothing. + if (PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.mobileFolderId, 0) == -1) { + if (mobile.length != 0) + PlacesUtils.bookmarks.removeItem(mobile[0], SOURCE_SYNC); + } + // Add the mobile bookmarks query if it doesn't exist + else if (mobile.length == 0) { + let query = PlacesUtils.bookmarks.insertBookmark(all[0], queryURI, -1, title, /* guid */ null, SOURCE_SYNC); + PlacesUtils.annotations.setItemAnnotation(query, ORGANIZERQUERY_ANNO, MOBILE_ANNO, 0, + PlacesUtils.annotations.EXPIRE_NEVER, SOURCE_SYNC); + PlacesUtils.annotations.setItemAnnotation(query, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, 1, 0, + PlacesUtils.annotations.EXPIRE_NEVER, SOURCE_SYNC); + } + // Make sure the existing query URL and title are correct + else { + if (!PlacesUtils.bookmarks.getBookmarkURI(mobile[0]).equals(queryURI)) { + PlacesUtils.bookmarks.changeBookmarkURI(mobile[0], queryURI, + SOURCE_SYNC); + } + let queryTitle = PlacesUtils.bookmarks.getItemTitle(mobile[0]); + if (queryTitle != title) { + PlacesUtils.bookmarks.setItemTitle(mobile[0], title, SOURCE_SYNC); + } + let rootTitle = + PlacesUtils.bookmarks.getItemTitle(PlacesUtils.mobileFolderId); + if (rootTitle != title) { + PlacesUtils.bookmarks.setItemTitle(PlacesUtils.mobileFolderId, title, + SOURCE_SYNC); + } + } + }, + + // This method is oddly structured, but the idea is to return as quickly as + // possible -- this handler gets called *every time* a bookmark changes, for + // *each change*. + onItemChanged: function BMT_onItemChanged(itemId, property, isAnno, value, + lastModified, itemType, parentId, + guid, parentGuid, oldValue, + source) { + if (IGNORED_SOURCES.includes(source)) { + return; + } + + if (isAnno && (ANNOS_TO_TRACK.indexOf(property) == -1)) + // Ignore annotations except for the ones that we sync. + return; + + // Ignore favicon changes to avoid unnecessary churn. + if (property == "favicon") + return; + + this._log.trace("onItemChanged: " + itemId + + (", " + property + (isAnno? " (anno)" : "")) + + (value ? (" = \"" + value + "\"") : "")); + this._add(itemId, guid); + }, + + onItemMoved: function BMT_onItemMoved(itemId, oldParent, oldIndex, + newParent, newIndex, itemType, + guid, oldParentGuid, newParentGuid, + source) { + if (IGNORED_SOURCES.includes(source)) { + return; + } + + this._log.trace("onItemMoved: " + itemId); + this._add(oldParent, oldParentGuid); + if (oldParent != newParent) { + this._add(itemId, guid); + this._add(newParent, newParentGuid); + } + + // Remove any position annotations now that the user moved the item + PlacesUtils.annotations.removeItemAnnotation(itemId, + PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO, SOURCE_SYNC); + }, + + onBeginUpdateBatch: function () { + ++this._batchDepth; + }, + onEndUpdateBatch: function () { + if (--this._batchDepth === 0 && this._batchSawScoreIncrement) { + this.score += SCORE_INCREMENT_XLARGE; + this._batchSawScoreIncrement = false; + } + }, + onItemVisited: function () {} +}; + +// Returns an array of root IDs to recursively query for synced bookmarks. +// Items in other roots, including tags and organizer queries, will be +// ignored. +function getChangeRootIds() { + return [ + PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.toolbarFolderId, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.mobileFolderId, + ]; +} + +class BookmarksChangeset extends Changeset { + getModifiedTimestamp(id) { + let change = this.changes[id]; + return change ? change.modified : Number.NaN; + } +} |