new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/PlacesSyncUtils.jsm
@@ -0,0 +1,663 @@
+/* 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(["URLSearchParams"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Bookmarks",
+ "resource://gre/modules/Bookmarks.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkUtils",
+ "resource://gre/modules/BookmarkUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BookmarkValidators",
+ "resource://gre/modules/BookmarkUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Livemark",
+ "resource://gre/modules/Livemark.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "LivemarksCache",
+ "resource://gre/modules/Livemark.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+
+/**
+ * This module exports functions for Sync to use. The update queries are similar
+ * to those in `Bookmarks.jsm` and `nsINavBookmarksService`, but do not
+ * increment the change counter. Otherwise, Sync would track its own changes as
+ * it applied incoming records, causing an infinite sync loop.
+ */
+var PlacesSyncUtils = {};
+
+// TODO(kitcambridge): Move BookmarkAnnos from bookmark_utils.js here.
+const SYNC_ANNOS = {
+ SMART_BOOKMARKS_ANNO: "Places/SmartBookmark",
+ DESCRIPTION_ANNO: "bookmarkProperties/description",
+ SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar",
+ PARENT_ANNO: "sync/parent",
+};
+
+const SYNC_ANNO_PROPERTIES = {
+ query: SYNC_ANNOS.SMART_BOOKMARKS_ANNO,
+ description: SYNC_ANNOS.DESCRIPTION_ANNO,
+ loadInSidebar: SYNC_ANNOS.SIDEBAR_ANNO,
+};
+
+PlacesSyncUtils.bookmarks = Object.freeze({
+ reorder: Task.async(function* () {
+ // TODO(kitcambridge): Handle missing items gracefully.
+ // This will probably need a hook into `reorderBookmarks`.
+ }),
+
+ remove: Task.async(function* (guid) {
+ // TODO(kitcambridge): Implement, return the removed bookmark.
+ }),
+
+ /**
+ * Updates a bookmark with synced properties. Only Sync should call this
+ * method; other callers should use `Bookmarks.update`.
+ *
+ * The following properties are supported:
+ * - type: Required.
+ * - 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 = BookmarkValidators.validateSyncBookmarkObject(info,
+ { type: { required: true }
+ , guid: { required: true }
+ });
+
+ // TODO(kitcambridge): Maybe we can provide an `upsert` method for Sync
+ // that inserts or updates a bookmark, or extract the core into an
+ // `upsertSyncBookmark` method.
+
+ let { item } = yield updateSyncBookmark(updateInfo, {
+ postProcess: Task.async(function* (db, item) {
+ return item;
+ }),
+ });
+
+ // Remove non-enumerable properties.
+ return Object.assign({}, item);
+ }),
+
+ /**
+ * Inserts a synced bookmark into the tree. Only Sync should call this
+ * method; other callers should use `Bookmarks.insert`.
+ *
+ * Unlike `Bookmarks.insert`, this call does not increment the sync change
+ * counter, to avoid infinite loops caused by Sync tracking its own changes.
+ * It also supports tags, keywords, and synced annotations.
+ *
+ * The following properties are supported:
+ * - type: 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 = BookmarkValidators.validateSyncBookmarkObject(info,
+ { type: { required: true }
+ , guid: { required: true }
+ , url: { requiredIf: b => b.type == Bookmarks.TYPE_BOOKMARK
+ , validIf: b => b.type == Bookmarks.TYPE_BOOKMARK }
+ , parentGuid: { required: true }
+ , title: { validIf: b => [ Bookmarks.TYPE_BOOKMARK
+ , Bookmarks.TYPE_FOLDER ].includes(b.type) }
+ , query: { validIf: b => b.type == Bookmarks.TYPE_BOOKMARK }
+ , tags: { validIf: b => b.type == Bookmarks.TYPE_BOOKMARK }
+ , keyword: { validIf: b => b.type == Bookmarks.TYPE_BOOKMARK }
+ , description: { validIf: b => [Bookmarks.TYPE_BOOKMARK,
+ Bookmarks.TYPE_FOLDER].includes(b.type) }
+
+ , loadInSidebar: { validIf: b => b.type == Bookmarks.TYPE_BOOKMARK }
+ });
+
+ // Sync doesn't track modification times, so use the default.
+ let time = new Date();
+ insertInfo.dateAdded = insertInfo.lastModified = time;
+
+ let orphans;
+ let { item } = yield insertSyncBookmark(insertInfo, {
+ postProcess: Task.async(function* (db, item) {
+ // Reparent all unfiled orphans that expect this folder as the parent.
+ if (item.type == Bookmarks.TYPE_FOLDER) {
+ orphans = yield db.executeCached(`SELECT
+ id, parent, position AS 'index', guid, type
+ FROM moz_bookmarks WHERE id IN (
+ SELECT item_id FROM moz_items_annos
+ WHERE anno_attribute_id = (
+ SELECT id FROM moz_anno_attributes
+ WHERE name = :orphanAnno
+ )
+ AND content = :folderGuid
+ )`, { orphanAnno: SYNC_ANNOS.PARENT_ANNO, folderGuid: item.guid });
+
+ // Reparent the children and fix indices. It would be easier to
+ // ignore the indices and update all rows in a single statement,
+ // especially since Sync reorders all children after applying incoming
+ // items. But, because we're operating directly on the bookmarks tree,
+ // we want the indices to be consistent if syncing is interrupted or
+ // fails.
+ for (let index = 0; index < orphans.length; index++) {
+ yield db.executeCached(`UPDATE moz_bookmarks SET
+ position = position - 1
+ WHERE parent = :parentId AND
+ position > :index`, { parentId: orphans[index].getResultByName("parent"),
+ index: orphans[index].getResultByName("index") });
+ yield db.executeCached(`UPDATE moz_bookmarks SET
+ parent = (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid),
+ position = :newIndex
+ WHERE id = :childId`, { folderGuid: item.guid, newIndex: index,
+ childId: orphans[index].getResultByName("id") });
+ }
+
+ // Remove the annotation once we've reparented the item.
+ yield setSyncAnno(db, item.guid, SYNC_ANNOS.PARENT_ANNO, null);
+ }
+ return item;
+ }),
+ });
+
+ // Fire observer notifications for reparented children.
+ if (orphans) {
+ let folderId = yield PlacesUtils.promiseItemId(item.guid);
+ let observers = PlacesUtils.bookmarks.getObservers();
+ for (let index = 0; index < orphans.length; index++) {
+ let orphan = orphans[index];
+ let orphanGuid = orphan.getResultByName("guid");
+ BookmarkUtils.notify(observers, "onItemMoved", [ orphan.getResultByName("id"),
+ orphan.getResultByName("parent"),
+ orphan.getResultByName("index"),
+ folderId,
+ index,
+ orphan.getResultByName("type"),
+ orphanGuid,
+ orphanGuid,
+ item.guid ]);
+ }
+ }
+
+ // Remove non-enumerable properties.
+ return Object.assign({}, item);
+ }),
+});
+
+PlacesSyncUtils.livemarks = Object.freeze({
+ update: Task.async(function* (info) {
+ let updateInfo = BookmarkValidators.validateSyncLivemarkObject(info,
+ { guid: { required: true }
+ });
+
+ let livemarksMap = yield LivemarksCache.promiseLivemarksMap();
+ if ("parentGuid" in updateInfo && livemarksMap.has(updateInfo.parentGuid)) {
+ throw new Error("Cannot move a livemark inside a livemark");
+ }
+
+ // TODO(kitcambridge): It might be easier to remove and recreate the
+ // livemark instead of reparenting it.
+
+ let { item } = yield updateSyncBookmark(updateInfo);
+
+ // TODO(kitcambridge): Implement the rest of this method...
+ }),
+
+ insert: Task.async(function* (info) {
+ let insertInfo = BookmarkValidators.validateSyncLivemarkObject(info,
+ { guid: { required: true }
+ , parentGuid: { required: true }
+ , feed: { required: true }
+ });
+
+ let livemarksMap = yield LivemarksCache.promiseLivemarksMap();
+ if (livemarksMap.has(insertInfo.parentGuid)) {
+ throw new Error("Cannot create a livemark inside a livemark");
+ }
+
+ // Unlike `BookmarkSyncUtils.insert`, we don't need to reparent orphans,
+ // because we don't track a livemark's children.
+ let { parent, item: folder } = yield insertSyncBookmark(insertInfo);
+
+ let feedURI = BookmarkUtils.toURI(insertInfo.feed);
+ let siteURI = insertInfo.site ? BookmarkUtils.toURI(insertInfo.site) : null;
+ let folderId = yield PlacesUtils.promiseItemId(folder.guid);
+ let livemark = new Livemark({ id: folderId
+ , title: folder.title
+ , parentGuid: folder.parentGuid
+ , parentId: parent._id
+ , index: folder.index
+ , feedURI
+ , siteURI
+ , guid: folder.guid
+ , dateAdded: BookmarkUtils.toPRTime(folder.dateAdded)
+ , lastModified: BookmarkUtils.toPRTime(folder.lastModified)
+ });
+
+ livemark.writeFeedURI(feedURI);
+ if (siteURI) {
+ livemark.writeSiteURI(siteURI);
+ }
+ livemarksMap.set(folder.guid, livemark);
+
+ return livemark;
+ }),
+});
+
+// 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.
+function updateQueryTagFolder(item, db) {
+ if (!item.query || !item.title) {
+ return item;
+ }
+ let scheme = "place:";
+ if (!item.url.startsWith(scheme)) {
+ return item;
+ }
+ let params = new URLSearchParams(item.url.slice(scheme.length));
+ let type = +params.get("queryType");
+ if (type != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
+ return item;
+ }
+ return PlacesUtils.withConnectionWrapper("BookmarkSyncUtils: updateQueryTagFolder", Task.async(function* (db) {
+ // Make sure the tag exists.
+ //
+ // TODO(kitcambridge): Set syncStatus = Bookmarks.SYNC_STATUS_NORMAL and
+ // syncChangeCounter = 0 (bug 1258127).
+ yield db.executeCached(`WITH parent AS (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid)
+ INSERT OR IGNORE INTO moz_bookmarks (id, fk, type, parent, position, title,
+ dateAdded, lastModified, guid)
+ VALUES (NULL, NULL, :type, SELECT id FROM parent,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = (SELECT id FROM parent),
+ :title, :dateAdded, :lastModified, :guid)
+ `, { type: Bookmarks.TYPE_FOLDER, parentGuid: PlacesUtils.bookmarks.tagsGuid,
+ title: item.title, dateAdded: BookmarkUtils.toPRTime(item.dateAdded),
+ lastModified: BookmarkUtils.toPRTime(item.lastModified), guid: item.guid });
+ // Rewrite the query to reference the new ID.
+ let id = yield PlacesUtils.promiseItemId(item.guid);
+ params.set("folder", id);
+ item.url = scheme + params;
+
+ return item;
+ }));
+}
+
+function* setTags(db, url, tags) {
+ if (!url) {
+ throw new Error("Cannot tag bookmark without URL");
+ }
+
+ let tagsRoots = yield db.executeCached(
+ `SELECT id, parent FROM moz_bookmarks WHERE guid = :tagsGuid`,
+ { tagsGuid: Bookmarks.tagsGuid });
+ if (!tagsRoots.length) {
+ throw new Error("Missing tags root");
+ }
+ let tagsRoot = { _id: tagsRoots[0].getResultByName("id"),
+ _parentId: tagsRoots[0].getResultByName("parent") };
+
+ let existingTags = new Map();
+ let tagRows = yield db.executeCached(`SELECT id, parent, guid, title FROM moz_bookmarks
+ WHERE parent = :tagsFolderId
+ AND title IN (${tags.map(tag => JSON.stringify(tag))})`,
+ { tagsFolderId: Bookmarks.tagsFolderId });
+ for (let row of tagRows) {
+ let tagId = row.getResultByName("id");
+ existingTags.set(tagId, { _id: tagId,
+ _parentId: row.getResultByName("parent"),
+ guid: row.getResultByName("guid"),
+ title: row.getResultByName("title") });
+ }
+
+ // Remove all existing tags for the bookmark URL. We fetch the tags instead
+ // of using `DELETE` so that we can notify observers afterward.
+ let removedTags = BookmarkUtils.rowsToItemsArray(yield db.executeCached(`SELECT
+ b.guid, IFNULL(p.guid, "") AS parentGuid, b.id AS _id,
+ b.parent AS _parentId, b.position AS 'index', b.type,
+ NULL AS dateAdded, NULL AS lastModified, NULL AS title,
+ NULL AS url, NULL AS _childCount, NULL AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE p.parent = :tagsFolderId
+ AND b.fk = (SELECT id FROM moz_places WHERE url = :url)`,
+ { tagsFolderId: Bookmarks.tagsFolderId, url: url.href }));
+ if (removedTags.length) {
+ yield db.executeCached(`DELETE FROM moz_bookmarks WHERE id IN (${
+ removedTags.map(item => JSON.stringify(item._id))})`, {});
+ }
+
+ let newTags = [];
+ for (let tag of tags) {
+ let parent = existingTags.get(tag);
+ if (!parent) {
+ // Create tag folders for any new tags.
+ let tagFolderGuid = yield BookmarkUtils.generateGuid(db);
+ yield BookmarkUtils.insertBookmarkInto(db, {
+ type: Bookmarks.TYPE_FOLDER,
+ parentGuid: Bookmarks.tagsGuid,
+ title: tag,
+ guid: tagFolderGuid,
+ }, tagsRoot);
+ parent = yield BookmarkUtils.fetchBookmark({ guid: tagFolderGuid });
+ newTags.push(parent);
+ }
+ // Tag the item.
+ let tagGuid = yield BookmarkUtils.generateGuid(db);
+ yield BookmarkUtils.insertBookmarkInto(db, {
+ type: Bookmarks.TYPE_BOOKMARK,
+ parentGuid: parent.guid,
+ url,
+ guid: tagGuid,
+ }, parent);
+ let tagItem = yield BookmarkUtils.fetchBookmark({ guid: tagGuid });
+ newTags.push(tagItem);
+ }
+
+ return { removedTags, newTags };
+}
+
+function* setKeyword(db, guid, url, keyword) {
+ keyword = keyword.trim().toLowerCase();
+
+ let oldRow = yield db.executeCached(`
+ SELECT k.id, b.guid, p.url
+ FROM moz_keywords k
+ LEFT JOIN moz_places p ON k.place_id = p.id
+ LEFT JOIN moz_bookmarks b ON p.id = b.fk
+ WHERE k.keyword = :keyword`,
+ { keyword }
+ );
+ if (!oldRow.length) {
+ yield db.executeCached(`INSERT INTO moz_keywords (keyword, place_id)
+ VALUES (:keyword, (SELECT fk FROM moz_bookmarks WHERE guid = :guid))`,
+ { keyword, guid });
+ return { oldKeywordURL: null };
+ }
+ let oldGuid = oldRow[0].getResultByName("guid");
+ if (oldGuid == guid) {
+ return null;
+ }
+ yield db.executeCached(`UPDATE moz_keywords
+ SET place_id = (SELECT fk FROM moz_bookmarks WHERE guid = :guid)
+ WHERE id = :id`,
+ { guid, keywordId: oldRow[0].getResultByName("id") }
+ );
+ return { oldKeywordURL: oldRow[0].getResultByName("url") };
+}
+
+function* setSyncAnno(db, guid, name, value) {
+ if (value === null) {
+ yield db.executeCached(`DELETE FROM moz_items_annos
+ WHERE item_id = (
+ SELECT id FROM moz_bookmarks WHERE guid = :guid
+ )
+ AND anno_attribute_id = (
+ SELECT id FROM moz_anno_attributes WHERE name = :name
+ )`,
+ { guid, name });
+ return { name, isRemoved: true };
+ }
+
+ let annoType;
+ switch (typeof value) {
+ case "string":
+ annoType = Ci.nsIAnnotationService.TYPE_STRING;
+ break;
+
+ case "boolean":
+ annoType = Ci.nsIAnnotationService.TYPE_INT32;
+ break;
+
+ default:
+ throw new Error("Invalid annotation type");
+ }
+
+ // First, ensure the annotation exists.
+ yield db.executeCached(`INSERT OR IGNORE INTO moz_anno_attributes
+ (name) VALUES (:name)`, { name });
+
+ let annoId = null;
+ let lastModified = BookmarkUtils.toPRTime(Date.now());
+ let dateAdded;
+ let annos = yield db.executeCached(`SELECT id, dateAdded FROM moz_items_annos
+ WHERE item_id = (SELECT id FROM moz_bookmarks WHERE guid = :guid)`,
+ { guid });
+ if (annos.length) {
+ annoId = annos[0].getResultByName("id");
+ dateAdded = annos[0].getResultByName("dateAdded");
+ } else {
+ dateAdded = lastModified;
+ }
+ // TODO(kitcambridge): Set syncStatus = Bookmarks.SYNC_STATUS_NORMAL and
+ // syncChangeCounter = 0 (bug 1258127).
+ yield db.executeCached(
+ `INSERT OR REPLACE INTO moz_items_annos
+ (id, item_id, anno_attribute_id, content, flags,
+ expiration, type, dateAdded, lastModified)
+ VALUES (:id, (SELECT id FROM moz_bookmarks WHERE guid = :guid),
+ (SELECT id FROM moz_anno_attributes WHERE name = :name),
+ :content, :flags, :expiration, :type, :date_added, :last_modified)`,
+ { id: annoId, guid, name, content: value,
+ flags: 0, expiration: PlacesUtils.annotations.EXPIRE_NEVER,
+ type: annoType, date_added: dateAdded, last_modified: lastModified }
+ );
+
+ return { name, isRemoved: false };
+}
+
+function* insertSyncBookmark(insertInfo, { postProcess } = {}) {
+ // Ensure the parent exists; default to "unfiled" if it doesn't.
+ let requestedParentGuid = insertInfo.parentGuid;
+ let parent = yield BookmarkUtils.fetchBookmark({ guid: requestedParentGuid });
+ if (!parent) {
+ insertInfo.parentGuid = Bookmarks.unfiledGuid;
+ parent = yield BookmarkUtils.fetchBookmark({ guid: insertInfo.parentGuid });
+ }
+
+ // Use the default index, since Sync reorders children after syncing.
+ insertInfo.index = parent._childCount;
+
+ // Build an array of `[name, value]` tuples for this item's annotations.
+ // We'll set these in the transaction, and then fire an observer
+ // notification for each annotation.
+ let annos = Object.keys(SYNC_ANNO_PROPERTIES).filter(
+ prop => prop in insertInfo
+ ).map(
+ prop => [SYNC_ANNO_PROPERTIES[prop], insertInfo[prop]]
+ );
+
+ let annosInfo, tagInfo, keywordInfo;
+ // TODO(kitcambridge): Set syncStatus = Bookmarks.SYNC_STATUS_NORMAL and
+ // syncChangeCounter = 0 (bug 1258127).
+ let item = yield BookmarkUtils.insertBookmark(insertInfo, parent, {
+ preProcess: Task.async(function* (db, item) {
+ // If we're inserting a tag query, make sure the tag exists and fix the
+ // folder ID to refer to the local tag folder.
+ return updateQueryTagFolder(item);
+ }),
+
+ postProcess: Task.async(function* (db, item) {
+ // Update the item's annotations, tags, and keyword in the same
+ // transaction. We'll notify observers afterward.
+ if (annos.length) {
+ annosInfo = yield Promise.all(annos.map(
+ ([name, value]) => Task.spawn(setSyncAnno(db, item.guid, name, value))
+ ));
+ }
+ if (item.tags && item.tags.length) {
+ tagInfo = yield setTags(db, item.url, item.tags);
+ }
+ if (item.keyword) {
+ keywordInfo = yield setKeyword(db, item.guid, item.url,
+ item.keyword);
+ }
+
+ // If we reparented the item to "unfiled" because we don't have the
+ // parent yet, annotate the item with its real parent ID.
+ if (item.parentGuid != requestedParentGuid) {
+ let parentAnnoInfo = yield setSyncAnno(db, item.guid,
+ SYNC_ANNOS.PARENT_ANNO, requestedParentGuid);
+ if (!annosInfo) {
+ annosInfo = [parentAnnoInfo];
+ } else {
+ annosInfo.push(parentAnnoInfo);
+ }
+ }
+
+ if (postProcess) {
+ item = yield postProcess(db, item);
+ }
+
+ return item;
+ }),
+ });
+
+ // Notify onItemAdded to listeners.
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // We need the itemId to notify, though once the switch to guids is
+ // complete we may stop using it.
+ let uri = item.hasOwnProperty("url") ? BookmarkUtils.toURI(item.url) : null;
+ let itemId = yield PlacesUtils.promiseItemId(item.guid);
+ BookmarkUtils.notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
+ item.type, uri, item.title || null,
+ BookmarkUtils.toPRTime(item.dateAdded), item.guid,
+ item.parentGuid ]);
+
+ // Fire annotation and bookmark observer notifications for new annos.
+ if (annosInfo) {
+ // We want to notify all annotation observers except the bookmarks
+ // service, because the bookmarks observer will increment the sync
+ // change counter.
+ let annoObservers = PlacesUtils.annotations.getObservers().filter(
+ observer => observer != PlacesUtils.bookmarks.QueryInterface(Ci.nsIObserver));
+ for (let { name, isRemoved } of annosInfo) {
+ if (isRemoved) {
+ BookmarkUtils.notify(annoObservers, "onItemAnnotationRemoved", [ item._id, name ]);
+ } else {
+ BookmarkUtils.notify(annoObservers, "onItemAnnotationSet", [ item._id, name ]);
+ }
+ // ...And we emit an observer notification for the item.
+ BookmarkUtils.notify(observers, "onItemChanged", [ itemId, name, true, "",
+ BookmarkUtils.toPRTime(item.lastModified),
+ item.type, item._parentId,
+ item.guid, item.parentGuid,
+ "" ]);
+ }
+ }
+
+ // Fire observer notifications for tag updates. This coalesces notifications;
+ // for example, untagging and retagging all bookmarks via the API will notify
+ // `onItemChanged` twice for each bookmark, but we only do this once.
+ if (tagInfo) {
+ for (let item of tagInfo.removedTags) {
+ BookmarkUtils.notify(observers, "onItemRemoved", [ item._id, item._parentId,
+ item.index, item.type, uri,
+ item.guid, item.parentGuid ]);
+ }
+ for (let item of tagInfo.newTags) {
+ BookmarkUtils.notify(observers, "onItemAdded", [ item._id, item._parentId, item.index,
+ item.type, uri, item.title || null,
+ BookmarkUtils.toPRTime(item.dateAdded), item.guid,
+ item.parentGuid ]);
+ }
+ for (let entry of (yield BookmarkUtils.fetchBookmarksByURL(item))) {
+ BookmarkUtils.notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ BookmarkUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "" ]);
+ }
+ }
+
+ if (keywordInfo) {
+ if (keywordInfo.oldURL) {
+ for (let entry of (yield BookmarkUtils.fetchBookmarksByURL({ url: keywordInfo.oldURL }))) {
+ BookmarkUtils.notify(observers, "onItemChanged", [ entry._id, "keyword", false, "",
+ BookmarkUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "" ]);
+ }
+ }
+ BookmarkUtils.notify(observers, "onItemChanged", [ item._id, "keyword", false, item.keyword,
+ BookmarkUtils.toPRTime(item.lastModified),
+ item.type, item._parentId,
+ item.guid, item.parentGuid,
+ "" ]);
+ }
+
+ return { parent, item };
+}
+
+function* updateSyncBookmark(updateInfo, { postProcess } = {}) {
+ let item = yield BookmarkUtils.fetchBookmark(updateInfo);
+ if (!item) {
+ throw new Error("No bookmarks found for the provided GUID");
+ }
+ if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type) {
+ throw new Error("The bookmark type cannot be changed");
+ }
+
+ // TODO(kitcambridge): Handle reparenting, like `Bookmarks.update`.
+ let parent = yield BookmarkUtils.fetchBookmark({ guid: item.parentGuid });
+
+ // TODO(kitcambridge): Set syncStatus = Bookmarks.SYNC_STATUS_NORMAL and
+ // syncChangeCounter = 0 (bug 1258127).
+ let updatedItem = yield BookmarkUtils.updateBookmark(updateInfo, item, parent, {
+ preProcess: Task.async(function* (db, item) {
+ // If we're inserting a tag query, make sure the tag exists and fix the
+ // folder ID to refer to the local tag folder.
+ return updateQueryTagFolder(item);
+ }),
+
+ postProcess: Task.async(function* (db, item) {
+ // TODO(kitcambridge): Unlike inserts, I don't think we need to handle
+ // the case where we don't have the parent, since we're updating an
+ // existing bookmark and should already have the parent. Is that right?
+ if (postProcess) {
+ item = yield postProcess(db, item);
+ }
+ return item;
+ }),
+ });
+
+ return { parent, item: updatedItem };
+}