/* 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 "" 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 ? "" : rows[0].url; } catch (ex) { if (Async.isShutdownException(ex)) { throw ex; } if (ex instanceof Ci.mozIStorageError) { url = ``; } else { url = ``; } } 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; } }