summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/PlacesSyncUtils.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/PlacesSyncUtils.jsm')
-rw-r--r--toolkit/components/places/PlacesSyncUtils.jsm1155
1 files changed, 1155 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesSyncUtils.jsm b/toolkit/components/places/PlacesSyncUtils.jsm
new file mode 100644
index 000000000..15dd412e8
--- /dev/null
+++ b/toolkit/components/places/PlacesSyncUtils.jsm
@@ -0,0 +1,1155 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["PlacesSyncUtils"];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL", "URLSearchParams"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Log",
+ "resource://gre/modules/Log.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+ "resource://gre/modules/Preferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+
+/**
+ * This module exports functions for Sync to use when applying remote
+ * records. The calls are similar to those in `Bookmarks.jsm` and
+ * `nsINavBookmarksService`, with special handling for smart bookmarks,
+ * tags, keywords, synced annotations, and missing parents.
+ */
+var PlacesSyncUtils = {};
+
+const { SOURCE_SYNC } = Ci.nsINavBookmarksService;
+
+// These are defined as lazy getters to defer initializing the bookmarks
+// service until it's needed.
+XPCOMUtils.defineLazyGetter(this, "ROOT_SYNC_ID_TO_GUID", () => ({
+ menu: PlacesUtils.bookmarks.menuGuid,
+ places: PlacesUtils.bookmarks.rootGuid,
+ tags: PlacesUtils.bookmarks.tagsGuid,
+ toolbar: PlacesUtils.bookmarks.toolbarGuid,
+ unfiled: PlacesUtils.bookmarks.unfiledGuid,
+ mobile: PlacesUtils.bookmarks.mobileGuid,
+}));
+
+XPCOMUtils.defineLazyGetter(this, "ROOT_GUID_TO_SYNC_ID", () => ({
+ [PlacesUtils.bookmarks.menuGuid]: "menu",
+ [PlacesUtils.bookmarks.rootGuid]: "places",
+ [PlacesUtils.bookmarks.tagsGuid]: "tags",
+ [PlacesUtils.bookmarks.toolbarGuid]: "toolbar",
+ [PlacesUtils.bookmarks.unfiledGuid]: "unfiled",
+ [PlacesUtils.bookmarks.mobileGuid]: "mobile",
+}));
+
+XPCOMUtils.defineLazyGetter(this, "ROOTS", () =>
+ Object.keys(ROOT_SYNC_ID_TO_GUID)
+);
+
+const BookmarkSyncUtils = PlacesSyncUtils.bookmarks = Object.freeze({
+ SMART_BOOKMARKS_ANNO: "Places/SmartBookmark",
+ DESCRIPTION_ANNO: "bookmarkProperties/description",
+ SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar",
+ SYNC_PARENT_ANNO: "sync/parent",
+ SYNC_MOBILE_ROOT_ANNO: "mobile/bookmarksRoot",
+
+ KINDS: {
+ BOOKMARK: "bookmark",
+ // Microsummaries were removed from Places in bug 524091. For now, Sync
+ // treats them identically to bookmarks. Bug 745410 tracks removing them
+ // entirely.
+ MICROSUMMARY: "microsummary",
+ QUERY: "query",
+ FOLDER: "folder",
+ LIVEMARK: "livemark",
+ SEPARATOR: "separator",
+ },
+
+ get ROOTS() {
+ return ROOTS;
+ },
+
+ /**
+ * Converts a Places GUID to a Sync ID. Sync IDs are identical to Places
+ * GUIDs for all items except roots.
+ */
+ guidToSyncId(guid) {
+ return ROOT_GUID_TO_SYNC_ID[guid] || guid;
+ },
+
+ /**
+ * Converts a Sync record ID to a Places GUID.
+ */
+ syncIdToGuid(syncId) {
+ return ROOT_SYNC_ID_TO_GUID[syncId] || syncId;
+ },
+
+ /**
+ * Fetches the sync IDs for a folder's children, ordered by their position
+ * within the folder.
+ */
+ fetchChildSyncIds: Task.async(function* (parentSyncId) {
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS.syncId(parentSyncId);
+ let parentGuid = BookmarkSyncUtils.syncIdToGuid(parentSyncId);
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ let children = yield fetchAllChildren(db, parentGuid);
+ return children.map(child =>
+ BookmarkSyncUtils.guidToSyncId(child.guid)
+ );
+ }),
+
+ /**
+ * Reorders a folder's children, based on their order in the array of sync
+ * IDs.
+ *
+ * Sync uses this method to reorder all synced children after applying all
+ * incoming records.
+ *
+ */
+ order: Task.async(function* (parentSyncId, childSyncIds) {
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS.syncId(parentSyncId);
+ if (!childSyncIds.length) {
+ return undefined;
+ }
+ let parentGuid = BookmarkSyncUtils.syncIdToGuid(parentSyncId);
+ if (parentGuid == PlacesUtils.bookmarks.rootGuid) {
+ // Reordering roots doesn't make sense, but Sync will do this on the
+ // first sync.
+ return undefined;
+ }
+ let orderedChildrenGuids = childSyncIds.map(BookmarkSyncUtils.syncIdToGuid);
+ return PlacesUtils.bookmarks.reorder(parentGuid, orderedChildrenGuids,
+ { source: SOURCE_SYNC });
+ }),
+
+ /**
+ * Removes an item from the database. Options are passed through to
+ * PlacesUtils.bookmarks.remove.
+ */
+ remove: Task.async(function* (syncId, options = {}) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(syncId);
+ if (guid in ROOT_GUID_TO_SYNC_ID) {
+ BookmarkSyncLog.warn(`remove: Refusing to remove root ${syncId}`);
+ return null;
+ }
+ return PlacesUtils.bookmarks.remove(guid, Object.assign({}, options, {
+ source: SOURCE_SYNC,
+ }));
+ }),
+
+ /**
+ * Returns true for sync IDs that are considered roots.
+ */
+ isRootSyncID(syncID) {
+ return ROOT_SYNC_ID_TO_GUID.hasOwnProperty(syncID);
+ },
+
+ /**
+ * Changes the GUID of an existing item. This method only allows Places GUIDs
+ * because root sync IDs cannot be changed.
+ *
+ * @return {Promise} resolved once the GUID has been changed.
+ * @resolves to the new GUID.
+ * @rejects if the old GUID does not exist.
+ */
+ changeGuid: Task.async(function* (oldGuid, newGuid) {
+ PlacesUtils.BOOKMARK_VALIDATORS.guid(oldGuid);
+ PlacesUtils.BOOKMARK_VALIDATORS.guid(newGuid);
+
+ let itemId = yield PlacesUtils.promiseItemId(oldGuid);
+ if (PlacesUtils.isRootItem(itemId)) {
+ throw new Error(`Cannot change GUID of Places root ${oldGuid}`);
+ }
+ return PlacesUtils.withConnectionWrapper("BookmarkSyncUtils: changeGuid",
+ Task.async(function* (db) {
+ yield db.executeCached(`UPDATE moz_bookmarks SET guid = :newGuid
+ WHERE id = :itemId`, { newGuid, itemId });
+ PlacesUtils.invalidateCachedGuidFor(itemId);
+ return newGuid;
+ })
+ );
+ }),
+
+ /**
+ * Updates a bookmark with synced properties. Only Sync should call this
+ * method; other callers should use `Bookmarks.update`.
+ *
+ * The following properties are supported:
+ * - kind: Optional.
+ * - guid: Required.
+ * - parentGuid: Optional; reparents the bookmark if specified.
+ * - title: Optional.
+ * - url: Optional.
+ * - tags: Optional; replaces all existing tags.
+ * - keyword: Optional.
+ * - description: Optional.
+ * - loadInSidebar: Optional.
+ * - query: Optional.
+ *
+ * @param info
+ * object representing a bookmark-item, as defined above.
+ *
+ * @return {Promise} resolved when the update is complete.
+ * @resolves to an object representing the updated bookmark.
+ * @rejects if it's not possible to update the given bookmark.
+ * @throws if the arguments are invalid.
+ */
+ update: Task.async(function* (info) {
+ let updateInfo = validateSyncBookmarkObject(info,
+ { syncId: { required: true }
+ });
+
+ return updateSyncBookmark(updateInfo);
+ }),
+
+ /**
+ * Inserts a synced bookmark into the tree. Only Sync should call this
+ * method; other callers should use `Bookmarks.insert`.
+ *
+ * The following properties are supported:
+ * - kind: Required.
+ * - guid: Required.
+ * - parentGuid: Required.
+ * - url: Required for bookmarks.
+ * - query: A smart bookmark query string, optional.
+ * - tags: An optional array of tag strings.
+ * - keyword: An optional keyword string.
+ * - description: An optional description string.
+ * - loadInSidebar: An optional boolean; defaults to false.
+ *
+ * Sync doesn't set the index, since it appends and reorders children
+ * after applying all incoming items.
+ *
+ * @param info
+ * object representing a synced bookmark.
+ *
+ * @return {Promise} resolved when the creation is complete.
+ * @resolves to an object representing the created bookmark.
+ * @rejects if it's not possible to create the requested bookmark.
+ * @throws if the arguments are invalid.
+ */
+ insert: Task.async(function* (info) {
+ let insertInfo = validateNewBookmark(info);
+ return insertSyncBookmark(insertInfo);
+ }),
+
+ /**
+ * Fetches a Sync bookmark object for an item in the tree. The object contains
+ * the following properties, depending on the item's kind:
+ *
+ * - kind (all): A string representing the item's kind.
+ * - syncId (all): The item's sync ID.
+ * - parentSyncId (all): The sync ID of the item's parent.
+ * - parentTitle (all): The title of the item's parent, used for de-duping.
+ * Omitted for the Places root and parents with empty titles.
+ * - title ("bookmark", "folder", "livemark", "query"): The item's title.
+ * Omitted if empty.
+ * - url ("bookmark", "query"): The item's URL.
+ * - tags ("bookmark", "query"): An array containing the item's tags.
+ * - keyword ("bookmark"): The bookmark's keyword, if one exists.
+ * - description ("bookmark", "folder", "livemark"): The item's description.
+ * Omitted if one isn't set.
+ * - loadInSidebar ("bookmark", "query"): Whether to load the bookmark in
+ * the sidebar. Always `false` for queries.
+ * - feed ("livemark"): A `URL` object pointing to the livemark's feed URL.
+ * - site ("livemark"): A `URL` object pointing to the livemark's site URL,
+ * or `null` if one isn't set.
+ * - childSyncIds ("folder"): An array containing the sync IDs of the item's
+ * children, used to determine child order.
+ * - folder ("query"): The tag folder name, if this is a tag query.
+ * - query ("query"): The smart bookmark query name, if this is a smart
+ * bookmark.
+ * - index ("separator"): The separator's position within its parent.
+ */
+ fetch: Task.async(function* (syncId) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(syncId);
+ let bookmarkItem = yield PlacesUtils.bookmarks.fetch(guid);
+ if (!bookmarkItem) {
+ return null;
+ }
+
+ // Convert the Places bookmark object to a Sync bookmark and add
+ // kind-specific properties. Titles are required for bookmarks,
+ // folders, and livemarks; optional for queries, and omitted for
+ // separators.
+ let kind = yield getKindForItem(bookmarkItem);
+ let item;
+ switch (kind) {
+ case BookmarkSyncUtils.KINDS.BOOKMARK:
+ case BookmarkSyncUtils.KINDS.MICROSUMMARY:
+ item = yield fetchBookmarkItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.QUERY:
+ item = yield fetchQueryItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.FOLDER:
+ item = yield fetchFolderItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.LIVEMARK:
+ item = yield fetchLivemarkItem(bookmarkItem);
+ break;
+
+ case BookmarkSyncUtils.KINDS.SEPARATOR:
+ item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+ item.index = bookmarkItem.index;
+ break;
+
+ default:
+ throw new Error(`Unknown bookmark kind: ${kind}`);
+ }
+
+ // Sync uses the parent title for de-duping. All Sync bookmark objects
+ // except the Places root should have this property.
+ if (bookmarkItem.parentGuid) {
+ let parent = yield PlacesUtils.bookmarks.fetch(bookmarkItem.parentGuid);
+ item.parentTitle = parent.title || "";
+ }
+
+ return item;
+ }),
+
+ /**
+ * Get the sync record kind for the record with provided sync id.
+ *
+ * @param syncId
+ * Sync ID for the item in question
+ *
+ * @returns {Promise} A promise that resolves with the sync record kind (e.g.
+ * something under `PlacesSyncUtils.bookmarks.KIND`), or
+ * with `null` if no item with that guid exists.
+ * @throws if `guid` is invalid.
+ */
+ getKindForSyncId(syncId) {
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS.syncId(syncId);
+ let guid = BookmarkSyncUtils.syncIdToGuid(syncId);
+ return PlacesUtils.bookmarks.fetch(guid)
+ .then(item => {
+ if (!item) {
+ return null;
+ }
+ return getKindForItem(item)
+ });
+ },
+});
+
+XPCOMUtils.defineLazyGetter(this, "BookmarkSyncLog", () => {
+ return Log.repository.getLogger("BookmarkSyncUtils");
+});
+
+function validateSyncBookmarkObject(input, behavior) {
+ return PlacesUtils.validateItemProperties(
+ PlacesUtils.SYNC_BOOKMARK_VALIDATORS, input, behavior);
+}
+
+// Similar to the private `fetchBookmarksByParent` implementation in
+// `Bookmarks.jsm`.
+var fetchAllChildren = Task.async(function* (db, parentGuid) {
+ let rows = yield db.executeCached(`
+ SELECT id, parent, position, type, guid
+ FROM moz_bookmarks
+ WHERE parent = (
+ SELECT id FROM moz_bookmarks WHERE guid = :parentGuid
+ )
+ ORDER BY position`,
+ { parentGuid }
+ );
+ return rows.map(row => ({
+ id: row.getResultByName("id"),
+ parentId: row.getResultByName("parent"),
+ index: row.getResultByName("position"),
+ type: row.getResultByName("type"),
+ guid: row.getResultByName("guid"),
+ }));
+});
+
+// A helper for whenever we want to know if a GUID doesn't exist in the places
+// database. Primarily used to detect orphans on incoming records.
+var GUIDMissing = Task.async(function* (guid) {
+ try {
+ yield PlacesUtils.promiseItemId(guid);
+ return false;
+ } catch (ex) {
+ if (ex.message == "no item found for the given GUID") {
+ return true;
+ }
+ throw ex;
+ }
+});
+
+// Tag queries use a `place:` URL that refers to the tag folder ID. When we
+// apply a synced tag query from a remote client, we need to update the URL to
+// point to the local tag folder.
+var updateTagQueryFolder = Task.async(function* (info) {
+ if (info.kind != BookmarkSyncUtils.KINDS.QUERY || !info.folder || !info.url ||
+ info.url.protocol != "place:") {
+ return info;
+ }
+
+ let params = new URLSearchParams(info.url.pathname);
+ let type = +params.get("type");
+
+ if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
+ return info;
+ }
+
+ let id = yield getOrCreateTagFolder(info.folder);
+ BookmarkSyncLog.debug(`updateTagQueryFolder: Tag query folder: ${
+ info.folder} = ${id}`);
+
+ // Rewrite the query to reference the new ID.
+ params.set("folder", id);
+ info.url = new URL(info.url.protocol + params);
+
+ return info;
+});
+
+var annotateOrphan = Task.async(function* (item, requestedParentSyncId) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(item.syncId);
+ let itemId = yield PlacesUtils.promiseItemId(guid);
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SYNC_PARENT_ANNO, requestedParentSyncId, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+});
+
+var reparentOrphans = Task.async(function* (item) {
+ if (item.kind != BookmarkSyncUtils.KINDS.FOLDER) {
+ return;
+ }
+ let orphanGuids = yield fetchGuidsWithAnno(BookmarkSyncUtils.SYNC_PARENT_ANNO,
+ item.syncId);
+ let folderGuid = BookmarkSyncUtils.syncIdToGuid(item.syncId);
+ BookmarkSyncLog.debug(`reparentOrphans: Reparenting ${
+ JSON.stringify(orphanGuids)} to ${item.syncId}`);
+ for (let i = 0; i < orphanGuids.length; ++i) {
+ let isReparented = false;
+ try {
+ // Reparenting can fail if we have a corrupted or incomplete tree
+ // where an item's parent is one of its descendants.
+ BookmarkSyncLog.trace(`reparentOrphans: Attempting to move item ${
+ orphanGuids[i]} to new parent ${item.syncId}`);
+ yield PlacesUtils.bookmarks.update({
+ guid: orphanGuids[i],
+ parentGuid: folderGuid,
+ index: PlacesUtils.bookmarks.DEFAULT_INDEX,
+ source: SOURCE_SYNC,
+ });
+ isReparented = true;
+ } catch (ex) {
+ BookmarkSyncLog.error(`reparentOrphans: Failed to reparent item ${
+ orphanGuids[i]} to ${item.syncId}`, ex);
+ }
+ if (isReparented) {
+ // Remove the annotation once we've reparented the item.
+ let orphanId = yield PlacesUtils.promiseItemId(orphanGuids[i]);
+ PlacesUtils.annotations.removeItemAnnotation(orphanId,
+ BookmarkSyncUtils.SYNC_PARENT_ANNO, SOURCE_SYNC);
+ }
+ }
+});
+
+// Inserts a synced bookmark into the database.
+var insertSyncBookmark = Task.async(function* (insertInfo) {
+ let requestedParentSyncId = insertInfo.parentSyncId;
+ let requestedParentGuid =
+ BookmarkSyncUtils.syncIdToGuid(insertInfo.parentSyncId);
+ let isOrphan = yield GUIDMissing(requestedParentGuid);
+
+ // Default to "unfiled" for new bookmarks if the parent doesn't exist.
+ if (!isOrphan) {
+ BookmarkSyncLog.debug(`insertSyncBookmark: Item ${
+ insertInfo.syncId} is not an orphan`);
+ } else {
+ BookmarkSyncLog.debug(`insertSyncBookmark: Item ${
+ insertInfo.syncId} is an orphan: parent ${
+ insertInfo.parentSyncId} doesn't exist; reparenting to unfiled`);
+ insertInfo.parentSyncId = "unfiled";
+ }
+
+ // If we're inserting a tag query, make sure the tag exists and fix the
+ // folder ID to refer to the local tag folder.
+ insertInfo = yield updateTagQueryFolder(insertInfo);
+
+ let newItem;
+ if (insertInfo.kind == BookmarkSyncUtils.KINDS.LIVEMARK) {
+ newItem = yield insertSyncLivemark(insertInfo);
+ } else {
+ let bookmarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
+ let bookmarkItem = yield PlacesUtils.bookmarks.insert(bookmarkInfo);
+ newItem = yield insertBookmarkMetadata(bookmarkItem, insertInfo);
+ }
+
+ if (!newItem) {
+ return null;
+ }
+
+ // If the item is an orphan, annotate it with its real parent sync ID.
+ if (isOrphan) {
+ yield annotateOrphan(newItem, requestedParentSyncId);
+ }
+
+ // Reparent all orphans that expect this folder as the parent.
+ yield reparentOrphans(newItem);
+
+ return newItem;
+});
+
+// Inserts a synced livemark.
+var insertSyncLivemark = Task.async(function* (insertInfo) {
+ if (!insertInfo.feed) {
+ BookmarkSyncLog.debug(`insertSyncLivemark: ${
+ insertInfo.syncId} missing feed URL`);
+ return null;
+ }
+ let livemarkInfo = syncBookmarkToPlacesBookmark(insertInfo);
+ let parentIsLivemark = yield getAnno(livemarkInfo.parentGuid,
+ PlacesUtils.LMANNO_FEEDURI);
+ if (parentIsLivemark) {
+ // A livemark can't be a descendant of another livemark.
+ BookmarkSyncLog.debug(`insertSyncLivemark: Invalid parent ${
+ insertInfo.parentSyncId}; skipping livemark record ${
+ insertInfo.syncId}`);
+ return null;
+ }
+
+ let livemarkItem = yield PlacesUtils.livemarks.addLivemark(livemarkInfo);
+
+ return insertBookmarkMetadata(livemarkItem, insertInfo);
+});
+
+// Sets annotations, keywords, and tags on a new bookmark. Returns a Sync
+// bookmark object.
+var insertBookmarkMetadata = Task.async(function* (bookmarkItem, insertInfo) {
+ let itemId = yield PlacesUtils.promiseItemId(bookmarkItem.guid);
+ let newItem = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (insertInfo.query) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SMART_BOOKMARKS_ANNO, insertInfo.query, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.query = insertInfo.query;
+ }
+
+ try {
+ newItem.tags = yield tagItem(bookmarkItem, insertInfo.tags);
+ } catch (ex) {
+ BookmarkSyncLog.warn(`insertBookmarkMetadata: Error tagging item ${
+ insertInfo.syncId}`, ex);
+ }
+
+ if (insertInfo.keyword) {
+ yield PlacesUtils.keywords.insert({
+ keyword: insertInfo.keyword,
+ url: bookmarkItem.url.href,
+ source: SOURCE_SYNC,
+ });
+ newItem.keyword = insertInfo.keyword;
+ }
+
+ if (insertInfo.description) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.DESCRIPTION_ANNO, insertInfo.description, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.description = insertInfo.description;
+ }
+
+ if (insertInfo.loadInSidebar) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SIDEBAR_ANNO, insertInfo.loadInSidebar, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.loadInSidebar = insertInfo.loadInSidebar;
+ }
+
+ return newItem;
+});
+
+// Determines the Sync record kind for an existing bookmark.
+var getKindForItem = Task.async(function* (item) {
+ switch (item.type) {
+ case PlacesUtils.bookmarks.TYPE_FOLDER: {
+ let isLivemark = yield getAnno(item.guid,
+ PlacesUtils.LMANNO_FEEDURI);
+ return isLivemark ? BookmarkSyncUtils.KINDS.LIVEMARK :
+ BookmarkSyncUtils.KINDS.FOLDER;
+ }
+ case PlacesUtils.bookmarks.TYPE_BOOKMARK:
+ return item.url.protocol == "place:" ?
+ BookmarkSyncUtils.KINDS.QUERY :
+ BookmarkSyncUtils.KINDS.BOOKMARK;
+
+ case PlacesUtils.bookmarks.TYPE_SEPARATOR:
+ return BookmarkSyncUtils.KINDS.SEPARATOR;
+ }
+ return null;
+});
+
+// Returns the `nsINavBookmarksService` bookmark type constant for a Sync
+// record kind.
+function getTypeForKind(kind) {
+ switch (kind) {
+ case BookmarkSyncUtils.KINDS.BOOKMARK:
+ case BookmarkSyncUtils.KINDS.MICROSUMMARY:
+ case BookmarkSyncUtils.KINDS.QUERY:
+ return PlacesUtils.bookmarks.TYPE_BOOKMARK;
+
+ case BookmarkSyncUtils.KINDS.FOLDER:
+ case BookmarkSyncUtils.KINDS.LIVEMARK:
+ return PlacesUtils.bookmarks.TYPE_FOLDER;
+
+ case BookmarkSyncUtils.KINDS.SEPARATOR:
+ return PlacesUtils.bookmarks.TYPE_SEPARATOR;
+ }
+ throw new Error(`Unknown bookmark kind: ${kind}`);
+}
+
+// Determines if a livemark should be reinserted. Returns true if `updateInfo`
+// specifies different feed or site URLs; false otherwise.
+var shouldReinsertLivemark = Task.async(function* (updateInfo) {
+ let hasFeed = updateInfo.hasOwnProperty("feed");
+ let hasSite = updateInfo.hasOwnProperty("site");
+ if (!hasFeed && !hasSite) {
+ return false;
+ }
+ let guid = BookmarkSyncUtils.syncIdToGuid(updateInfo.syncId);
+ let livemark = yield PlacesUtils.livemarks.getLivemark({
+ guid,
+ });
+ if (hasFeed) {
+ let feedURI = PlacesUtils.toURI(updateInfo.feed);
+ if (!livemark.feedURI.equals(feedURI)) {
+ return true;
+ }
+ }
+ if (hasSite) {
+ if (!updateInfo.site) {
+ return !!livemark.siteURI;
+ }
+ let siteURI = PlacesUtils.toURI(updateInfo.site);
+ if (!livemark.siteURI || !siteURI.equals(livemark.siteURI)) {
+ return true;
+ }
+ }
+ return false;
+});
+
+var updateSyncBookmark = Task.async(function* (updateInfo) {
+ let guid = BookmarkSyncUtils.syncIdToGuid(updateInfo.syncId);
+ let oldBookmarkItem = yield PlacesUtils.bookmarks.fetch(guid);
+ if (!oldBookmarkItem) {
+ throw new Error(`Bookmark with sync ID ${
+ updateInfo.syncId} does not exist`);
+ }
+
+ let shouldReinsert = false;
+ let oldKind = yield getKindForItem(oldBookmarkItem);
+ if (updateInfo.hasOwnProperty("kind") && updateInfo.kind != oldKind) {
+ // If the item's aren't the same kind, we can't update the record;
+ // we must remove and reinsert.
+ shouldReinsert = true;
+ if (BookmarkSyncLog.level <= Log.Level.Warn) {
+ let oldSyncId = BookmarkSyncUtils.guidToSyncId(oldBookmarkItem.guid);
+ BookmarkSyncLog.warn(`updateSyncBookmark: Local ${
+ oldSyncId} kind = ${oldKind}; remote ${
+ updateInfo.syncId} kind = ${
+ updateInfo.kind}. Deleting and recreating`);
+ }
+ } else if (oldKind == BookmarkSyncUtils.KINDS.LIVEMARK) {
+ // Similarly, if we're changing a livemark's site or feed URL, we need to
+ // reinsert.
+ shouldReinsert = yield shouldReinsertLivemark(updateInfo);
+ if (BookmarkSyncLog.level <= Log.Level.Debug) {
+ let oldSyncId = BookmarkSyncUtils.guidToSyncId(oldBookmarkItem.guid);
+ BookmarkSyncLog.debug(`updateSyncBookmark: Local ${
+ oldSyncId} and remote ${
+ updateInfo.syncId} livemarks have different URLs`);
+ }
+ }
+
+ if (shouldReinsert) {
+ let newInfo = validateNewBookmark(updateInfo);
+ yield PlacesUtils.bookmarks.remove({
+ guid,
+ source: SOURCE_SYNC,
+ });
+ // A reinsertion likely indicates a confused client, since there aren't
+ // public APIs for changing livemark URLs or an item's kind (e.g., turning
+ // a folder into a separator while preserving its annos and position).
+ // This might be a good case to repair later; for now, we assume Sync has
+ // passed a complete record for the new item, and don't try to merge
+ // `oldBookmarkItem` with `updateInfo`.
+ return insertSyncBookmark(newInfo);
+ }
+
+ let isOrphan = false, requestedParentSyncId;
+ if (updateInfo.hasOwnProperty("parentSyncId")) {
+ requestedParentSyncId = updateInfo.parentSyncId;
+ let oldParentSyncId =
+ BookmarkSyncUtils.guidToSyncId(oldBookmarkItem.parentGuid);
+ if (requestedParentSyncId != oldParentSyncId) {
+ let oldId = yield PlacesUtils.promiseItemId(oldBookmarkItem.guid);
+ if (PlacesUtils.isRootItem(oldId)) {
+ throw new Error(`Cannot move Places root ${oldId}`);
+ }
+ let requestedParentGuid =
+ BookmarkSyncUtils.syncIdToGuid(requestedParentSyncId);
+ isOrphan = yield GUIDMissing(requestedParentGuid);
+ if (!isOrphan) {
+ BookmarkSyncLog.debug(`updateSyncBookmark: Item ${
+ updateInfo.syncId} is not an orphan`);
+ } else {
+ // Don't move the item if the new parent doesn't exist. Instead, mark
+ // the item as an orphan. We'll annotate it with its real parent after
+ // updating.
+ BookmarkSyncLog.trace(`updateSyncBookmark: Item ${
+ updateInfo.syncId} is an orphan: could not find parent ${
+ requestedParentSyncId}`);
+ delete updateInfo.parentSyncId;
+ }
+ } else {
+ // If the parent is the same, just omit it so that `update` doesn't do
+ // extra work.
+ delete updateInfo.parentSyncId;
+ }
+ }
+
+ updateInfo = yield updateTagQueryFolder(updateInfo);
+
+ let bookmarkInfo = syncBookmarkToPlacesBookmark(updateInfo);
+ let newBookmarkItem = shouldUpdateBookmark(bookmarkInfo) ?
+ yield PlacesUtils.bookmarks.update(bookmarkInfo) :
+ oldBookmarkItem;
+ let newItem = yield updateBookmarkMetadata(oldBookmarkItem, newBookmarkItem,
+ updateInfo);
+
+ // If the item is an orphan, annotate it with its real parent sync ID.
+ if (isOrphan) {
+ yield annotateOrphan(newItem, requestedParentSyncId);
+ }
+
+ // Reparent all orphans that expect this folder as the parent.
+ yield reparentOrphans(newItem);
+
+ return newItem;
+});
+
+// Updates tags, keywords, and annotations for an existing bookmark. Returns a
+// Sync bookmark object.
+var updateBookmarkMetadata = Task.async(function* (oldBookmarkItem,
+ newBookmarkItem,
+ updateInfo) {
+ let itemId = yield PlacesUtils.promiseItemId(newBookmarkItem.guid);
+ let newItem = yield placesBookmarkToSyncBookmark(newBookmarkItem);
+
+ try {
+ newItem.tags = yield tagItem(newBookmarkItem, updateInfo.tags);
+ } catch (ex) {
+ BookmarkSyncLog.warn(`updateBookmarkMetadata: Error tagging item ${
+ updateInfo.syncId}`, ex);
+ }
+
+ if (updateInfo.hasOwnProperty("keyword")) {
+ // Unconditionally remove the old keyword.
+ let entry = yield PlacesUtils.keywords.fetch({
+ url: oldBookmarkItem.url.href,
+ });
+ if (entry) {
+ yield PlacesUtils.keywords.remove({
+ keyword: entry.keyword,
+ source: SOURCE_SYNC,
+ });
+ }
+ if (updateInfo.keyword) {
+ yield PlacesUtils.keywords.insert({
+ keyword: updateInfo.keyword,
+ url: newItem.url.href,
+ source: SOURCE_SYNC,
+ });
+ }
+ newItem.keyword = updateInfo.keyword;
+ }
+
+ if (updateInfo.hasOwnProperty("description")) {
+ if (updateInfo.description) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.DESCRIPTION_ANNO, updateInfo.description, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ } else {
+ PlacesUtils.annotations.removeItemAnnotation(itemId,
+ BookmarkSyncUtils.DESCRIPTION_ANNO, SOURCE_SYNC);
+ }
+ newItem.description = updateInfo.description;
+ }
+
+ if (updateInfo.hasOwnProperty("loadInSidebar")) {
+ if (updateInfo.loadInSidebar) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SIDEBAR_ANNO, updateInfo.loadInSidebar, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ } else {
+ PlacesUtils.annotations.removeItemAnnotation(itemId,
+ BookmarkSyncUtils.SIDEBAR_ANNO, SOURCE_SYNC);
+ }
+ newItem.loadInSidebar = updateInfo.loadInSidebar;
+ }
+
+ if (updateInfo.hasOwnProperty("query")) {
+ PlacesUtils.annotations.setItemAnnotation(itemId,
+ BookmarkSyncUtils.SMART_BOOKMARKS_ANNO, updateInfo.query, 0,
+ PlacesUtils.annotations.EXPIRE_NEVER,
+ SOURCE_SYNC);
+ newItem.query = updateInfo.query;
+ }
+
+ return newItem;
+});
+
+function validateNewBookmark(info) {
+ let insertInfo = validateSyncBookmarkObject(info,
+ { kind: { required: true }
+ , syncId: { required: true }
+ , url: { requiredIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind)
+ , validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , parentSyncId: { required: true }
+ , title: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY
+ , BookmarkSyncUtils.KINDS.FOLDER
+ , BookmarkSyncUtils.KINDS.LIVEMARK ].includes(b.kind) }
+ , query: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }
+ , folder: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.QUERY }
+ , tags: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , keyword: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , description: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY
+ , BookmarkSyncUtils.KINDS.FOLDER
+ , BookmarkSyncUtils.KINDS.LIVEMARK ].includes(b.kind) }
+ , loadInSidebar: { validIf: b => [ BookmarkSyncUtils.KINDS.BOOKMARK
+ , BookmarkSyncUtils.KINDS.MICROSUMMARY
+ , BookmarkSyncUtils.KINDS.QUERY ].includes(b.kind) }
+ , feed: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.LIVEMARK }
+ , site: { validIf: b => b.kind == BookmarkSyncUtils.KINDS.LIVEMARK }
+ });
+
+ return insertInfo;
+}
+
+// Returns an array of GUIDs for items that have an `anno` with the given `val`.
+var fetchGuidsWithAnno = Task.async(function* (anno, val) {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(`
+ SELECT b.guid FROM moz_items_annos a
+ JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+ JOIN moz_bookmarks b ON b.id = a.item_id
+ WHERE n.name = :anno AND
+ a.content = :val`,
+ { anno, val });
+ return rows.map(row => row.getResultByName("guid"));
+});
+
+// Returns the value of an item's annotation, or `null` if it's not set.
+var getAnno = Task.async(function* (guid, anno) {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let rows = yield db.executeCached(`
+ SELECT a.content FROM moz_items_annos a
+ JOIN moz_anno_attributes n ON n.id = a.anno_attribute_id
+ JOIN moz_bookmarks b ON b.id = a.item_id
+ WHERE b.guid = :guid AND
+ n.name = :anno`,
+ { guid, anno });
+ return rows.length ? rows[0].getResultByName("content") : null;
+});
+
+var tagItem = Task.async(function (item, tags) {
+ if (!item.url) {
+ return [];
+ }
+
+ // Remove leading and trailing whitespace, then filter out empty tags.
+ let newTags = tags.map(tag => tag.trim()).filter(Boolean);
+
+ // Removing the last tagged item will also remove the tag. To preserve
+ // tag IDs, we temporarily tag a dummy URI, ensuring the tags exist.
+ let dummyURI = PlacesUtils.toURI("about:weave#BStore_tagURI");
+ let bookmarkURI = PlacesUtils.toURI(item.url.href);
+ PlacesUtils.tagging.tagURI(dummyURI, newTags, SOURCE_SYNC);
+ PlacesUtils.tagging.untagURI(bookmarkURI, null, SOURCE_SYNC);
+ PlacesUtils.tagging.tagURI(bookmarkURI, newTags, SOURCE_SYNC);
+ PlacesUtils.tagging.untagURI(dummyURI, null, SOURCE_SYNC);
+
+ return newTags;
+});
+
+// `PlacesUtils.bookmarks.update` checks if we've supplied enough properties,
+// but doesn't know about additional livemark properties. We check this to avoid
+// having it throw in case we only pass properties like `{ guid, feedURI }`.
+function shouldUpdateBookmark(bookmarkInfo) {
+ return bookmarkInfo.hasOwnProperty("parentGuid") ||
+ bookmarkInfo.hasOwnProperty("title") ||
+ bookmarkInfo.hasOwnProperty("url");
+}
+
+var getTagFolder = Task.async(function* (tag) {
+ let db = yield PlacesUtils.promiseDBConnection();
+ let results = yield db.executeCached(`SELECT id FROM moz_bookmarks
+ WHERE parent = :tagsFolder AND title = :tag LIMIT 1`,
+ { tagsFolder: PlacesUtils.bookmarks.tagsFolder, tag });
+ return results.length ? results[0].getResultByName("id") : null;
+});
+
+var getOrCreateTagFolder = Task.async(function* (tag) {
+ let id = yield getTagFolder(tag);
+ if (id) {
+ return id;
+ }
+ // Create the tag if it doesn't exist.
+ let item = yield PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: tag,
+ source: SOURCE_SYNC,
+ });
+ return PlacesUtils.promiseItemId(item.guid);
+});
+
+// Converts a Places bookmark or livemark to a Sync bookmark. This function
+// maps Places GUIDs to sync IDs and filters out extra Places properties like
+// date added, last modified, and index.
+var placesBookmarkToSyncBookmark = Task.async(function* (bookmarkItem) {
+ let item = {};
+
+ for (let prop in bookmarkItem) {
+ switch (prop) {
+ // Sync IDs are identical to Places GUIDs for all items except roots.
+ case "guid":
+ item.syncId = BookmarkSyncUtils.guidToSyncId(bookmarkItem.guid);
+ break;
+
+ case "parentGuid":
+ item.parentSyncId =
+ BookmarkSyncUtils.guidToSyncId(bookmarkItem.parentGuid);
+ break;
+
+ // Sync uses kinds instead of types, which distinguish between folders,
+ // livemarks, bookmarks, and queries.
+ case "type":
+ item.kind = yield getKindForItem(bookmarkItem);
+ break;
+
+ case "title":
+ case "url":
+ item[prop] = bookmarkItem[prop];
+ break;
+
+ // Livemark objects contain additional properties. The feed URL is
+ // required; the site URL is optional.
+ case "feedURI":
+ item.feed = new URL(bookmarkItem.feedURI.spec);
+ break;
+
+ case "siteURI":
+ if (bookmarkItem.siteURI) {
+ item.site = new URL(bookmarkItem.siteURI.spec);
+ }
+ break;
+ }
+ }
+
+ return item;
+});
+
+// Converts a Sync bookmark object to a Places bookmark or livemark object.
+// This function maps sync IDs to Places GUIDs, and filters out extra Sync
+// properties like keywords, tags, and descriptions. Returns an object that can
+// be passed to `PlacesUtils.livemarks.addLivemark` or
+// `PlacesUtils.bookmarks.{insert, update}`.
+function syncBookmarkToPlacesBookmark(info) {
+ let bookmarkInfo = {
+ source: SOURCE_SYNC,
+ };
+
+ for (let prop in info) {
+ switch (prop) {
+ case "kind":
+ bookmarkInfo.type = getTypeForKind(info.kind);
+ break;
+
+ // Convert sync IDs to Places GUIDs for roots.
+ case "syncId":
+ bookmarkInfo.guid = BookmarkSyncUtils.syncIdToGuid(info.syncId);
+ break;
+
+ case "parentSyncId":
+ bookmarkInfo.parentGuid =
+ BookmarkSyncUtils.syncIdToGuid(info.parentSyncId);
+ // Instead of providing an index, Sync reorders children at the end of
+ // the sync using `BookmarkSyncUtils.order`. We explicitly specify the
+ // default index here to prevent `PlacesUtils.bookmarks.update` and
+ // `PlacesUtils.livemarks.addLivemark` from throwing.
+ bookmarkInfo.index = PlacesUtils.bookmarks.DEFAULT_INDEX;
+ break;
+
+ case "title":
+ case "url":
+ bookmarkInfo[prop] = info[prop];
+ break;
+
+ // Livemark-specific properties.
+ case "feed":
+ bookmarkInfo.feedURI = PlacesUtils.toURI(info.feed);
+ break;
+
+ case "site":
+ if (info.site) {
+ bookmarkInfo.siteURI = PlacesUtils.toURI(info.site);
+ }
+ break;
+ }
+ }
+
+ return bookmarkInfo;
+}
+
+// Creates and returns a Sync bookmark object containing the bookmark's
+// tags, keyword, description, and whether it loads in the sidebar.
+var fetchBookmarkItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (!item.title) {
+ item.title = "";
+ }
+
+ item.tags = PlacesUtils.tagging.getTagsForURI(
+ PlacesUtils.toURI(bookmarkItem.url), {});
+
+ let keywordEntry = yield PlacesUtils.keywords.fetch({
+ url: bookmarkItem.url,
+ });
+ if (keywordEntry) {
+ item.keyword = keywordEntry.keyword;
+ }
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ item.loadInSidebar = !!(yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.SIDEBAR_ANNO));
+
+ return item;
+});
+
+// Creates and returns a Sync bookmark object containing the folder's
+// description and children.
+var fetchFolderItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (!item.title) {
+ item.title = "";
+ }
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ let children = yield fetchAllChildren(db, bookmarkItem.guid);
+ item.childSyncIds = children.map(child =>
+ BookmarkSyncUtils.guidToSyncId(child.guid)
+ );
+
+ return item;
+});
+
+// Creates and returns a Sync bookmark object containing the livemark's
+// description, children (none), feed URI, and site URI.
+var fetchLivemarkItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ if (!item.title) {
+ item.title = "";
+ }
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ let feedAnno = yield getAnno(bookmarkItem.guid, PlacesUtils.LMANNO_FEEDURI);
+ item.feed = new URL(feedAnno);
+
+ let siteAnno = yield getAnno(bookmarkItem.guid, PlacesUtils.LMANNO_SITEURI);
+ if (siteAnno) {
+ item.site = new URL(siteAnno);
+ }
+
+ return item;
+});
+
+// Creates and returns a Sync bookmark object containing the query's tag
+// folder name and smart bookmark query ID.
+var fetchQueryItem = Task.async(function* (bookmarkItem) {
+ let item = yield placesBookmarkToSyncBookmark(bookmarkItem);
+
+ let description = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.DESCRIPTION_ANNO);
+ if (description) {
+ item.description = description;
+ }
+
+ let folder = null;
+ let params = new URLSearchParams(bookmarkItem.url.pathname);
+ let tagFolderId = +params.get("folder");
+ if (tagFolderId) {
+ try {
+ let tagFolderGuid = yield PlacesUtils.promiseItemGuid(tagFolderId);
+ let tagFolder = yield PlacesUtils.bookmarks.fetch(tagFolderGuid);
+ folder = tagFolder.title;
+ } catch (ex) {
+ BookmarkSyncLog.warn("fetchQueryItem: Query " + bookmarkItem.url.href +
+ " points to nonexistent folder " + tagFolderId, ex);
+ }
+ }
+ if (folder != null) {
+ item.folder = folder;
+ }
+
+ let query = yield getAnno(bookmarkItem.guid,
+ BookmarkSyncUtils.SMART_BOOKMARKS_ANNO);
+ if (query) {
+ item.query = query;
+ }
+
+ return item;
+});