Bug 1274108 - Sketch out a bookmarks API for Sync. draft
authorKit Cambridge <kcambridge@mozilla.com>
Thu, 02 Jun 2016 11:10:31 -0700
changeset 375311 28db65c232a702bcce7905ec243586afab63a775
parent 375300 5d3982be429442a41785a5d8a0bfe555d4b36efa
child 375312 4bbd859bfa282702c6f896068949335739190a2c
push id20224
push userkcambridge@mozilla.com
push dateFri, 03 Jun 2016 21:48:57 +0000
bugs1274108
milestone49.0a1
Bug 1274108 - Sketch out a bookmarks API for Sync. MozReview-Commit-ID: CE2lmoCzlST
toolkit/components/places/BookmarkUtils.jsm
toolkit/components/places/PlacesSyncUtils.jsm
toolkit/components/places/moz.build
--- a/toolkit/components/places/BookmarkUtils.jsm
+++ b/toolkit/components/places/BookmarkUtils.jsm
@@ -27,16 +27,24 @@ const DB_TITLE_LENGTH_MAX = 4096;
 const MATCH_ANYWHERE_UNMODIFIED = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED;
 const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
 
 var BookmarkValidators = {
   validateBookmarkObject(input, behavior) {
     return validateBookmarkProperties(VALIDATORS, input, behavior);
   },
 
+  validateSyncBookmarkObject(input, behavior) {
+    return validateBookmarkProperties(SYNC_VALIDATORS, input, behavior);
+  },
+
+  validateSyncLivemarkObject(input, behavior) {
+    return validateBookmarkProperties(SYNC_LIVEMARK_VALIDATORS, input, behavior);
+  },
+
   isValidGuid(v) {
     return typeof(v) == "string" && /^[a-zA-Z0-9\-_]{12}$/.test(v);
   },
 };
 
 var BookmarkUtils = {
   generateGuid(db) {
     return db.executeCached("SELECT GENERATE_GUID() AS guid").then(rows => rows[0].getResultByName("guid"));
@@ -771,16 +779,49 @@ const VALIDATORS = Object.freeze({
     if (typeof(v) === "string")
       return new URL(v);
     if (v instanceof Ci.nsIURI)
       return new URL(v.spec);
     return v;
   }
 });
 
+const SYNC_VALIDATORS = Object.freeze(Object.assign({
+  // Allow null and undefined for queries.
+  query: simpleValidateFunc(v => v === null || (typeof v == "string" &&
+                                                v.length <= DB_URL_LENGTH_MAX)),
+  tags: v => {
+    if (v === null) {
+      return null;
+    }
+    if (!Array.isArray(v)) {
+      throw new Error("Invalid tag array");
+    }
+    for (let tag of v) {
+      if (!tag || tag.length > Ci.nsITaggingService.MAX_TAG_LENGTH ||
+          (/^\s|\s$/).test(tag)) {
+        throw new Error(`Invalid tag: ${tag}`);
+      }
+    }
+    return v;
+  },
+  keyword: simpleValidateFunc(v => typeof v == "string"),
+  description: simpleValidateFunc(v => v === null || typeof v == "string"),
+  loadInSidebar: simpleValidateFunc(v => v === true || v === false),
+}, VALIDATORS));
+
+const SYNC_LIVEMARK_VALIDATORS = Object.freeze({
+  guid: VALIDATORS.guid,
+  parentGuid: VALIDATORS.parentGuid,
+  index: VALIDATORS.index,
+  title: VALIDATORS.title,
+  feed: VALIDATORS.url,
+  site: VALIDATORS.url,
+});
+
 /**
  * Checks validity of a bookmark object, filling up default values for optional
  * properties.
  *
  * @param validators (object)
           An object containing input validators. Keys should be field names;
           values should be validation functions.
  * @param input (object)
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 };
+}
--- a/toolkit/components/places/moz.build
+++ b/toolkit/components/places/moz.build
@@ -66,16 +66,17 @@ if CONFIG['MOZ_PLACES']:
         'ColorAnalyzer_worker.js',
         'ColorConversion.js',
         'History.jsm',
         'Livemark.jsm',
         'PlacesBackups.jsm',
         'PlacesDBUtils.jsm',
         'PlacesRemoteTabsAutocompleteProvider.jsm',
         'PlacesSearchAutocompleteProvider.jsm',
+        'PlacesSyncUtils.jsm',
         'PlacesTransactions.jsm',
         'PlacesUtils.jsm',
     ]
 
     EXTRA_COMPONENTS += [
         'ColorAnalyzer.js',
         'nsLivemarkService.js',
         'nsPlacesExpiration.js',