/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

this.EXPORTED_SYMBOLS = ['BookmarksEngine', "PlacesItem", "Bookmark",
                         "BookmarkFolder", "BookmarkQuery",
                         "Livemark", "BookmarkSeparator"];

var Cc = Components.classes;
var Ci = Components.interfaces;
var Cu = Components.utils;

Cu.import("resource://gre/modules/PlacesUtils.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://services-common/async.js");
Cu.import("resource://services-sync/constants.js");
Cu.import("resource://services-sync/engines.js");
Cu.import("resource://services-sync/record.js");
Cu.import("resource://services-sync/util.js");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/PlacesBackups.jsm");

const ALLBOOKMARKS_ANNO    = "AllBookmarks";
const DESCRIPTION_ANNO     = "bookmarkProperties/description";
const SIDEBAR_ANNO         = "bookmarkProperties/loadInSidebar";
const MOBILEROOT_ANNO      = "mobile/bookmarksRoot";
const MOBILE_ANNO          = "MobileBookmarks";
const EXCLUDEBACKUP_ANNO   = "places/excludeFromBackup";
const SMART_BOOKMARKS_ANNO = "Places/SmartBookmark";
const PARENT_ANNO          = "sync/parent";
const ORGANIZERQUERY_ANNO  = "PlacesOrganizer/OrganizerQuery";
const ANNOS_TO_TRACK = [DESCRIPTION_ANNO, SIDEBAR_ANNO,
                        PlacesUtils.LMANNO_FEEDURI, PlacesUtils.LMANNO_SITEURI];

const SERVICE_NOT_SUPPORTED = "Service not supported on this platform";
const FOLDER_SORTINDEX = 1000000;

this.PlacesItem = function PlacesItem(collection, id, type) {
  CryptoWrapper.call(this, collection, id);
  this.type = type || "item";
}
PlacesItem.prototype = {
  decrypt: function PlacesItem_decrypt(keyBundle) {
    // Do the normal CryptoWrapper decrypt, but change types before returning
    let clear = CryptoWrapper.prototype.decrypt.call(this, keyBundle);

    // Convert the abstract places item to the actual object type
    if (!this.deleted)
      this.__proto__ = this.getTypeObject(this.type).prototype;

    return clear;
  },

  getTypeObject: function PlacesItem_getTypeObject(type) {
    switch (type) {
      case "bookmark":
      case "microsummary":
        return Bookmark;
      case "query":
        return BookmarkQuery;
      case "folder":
        return BookmarkFolder;
      case "livemark":
        return Livemark;
      case "separator":
        return BookmarkSeparator;
      case "item":
        return PlacesItem;
    }
    throw "Unknown places item object type: " + type;
  },

  __proto__: CryptoWrapper.prototype,
  _logName: "Sync.Record.PlacesItem",
};

Utils.deferGetSet(PlacesItem,
                  "cleartext",
                  ["hasDupe", "parentid", "parentName", "type"]);

this.Bookmark = function Bookmark(collection, id, type) {
  PlacesItem.call(this, collection, id, type || "bookmark");
}
Bookmark.prototype = {
  __proto__: PlacesItem.prototype,
  _logName: "Sync.Record.Bookmark",
};

Utils.deferGetSet(Bookmark,
                  "cleartext",
                  ["title", "bmkUri", "description",
                   "loadInSidebar", "tags", "keyword"]);

this.BookmarkQuery = function BookmarkQuery(collection, id) {
  Bookmark.call(this, collection, id, "query");
}
BookmarkQuery.prototype = {
  __proto__: Bookmark.prototype,
  _logName: "Sync.Record.BookmarkQuery",
};

Utils.deferGetSet(BookmarkQuery,
                  "cleartext",
                  ["folderName", "queryId"]);

this.BookmarkFolder = function BookmarkFolder(collection, id, type) {
  PlacesItem.call(this, collection, id, type || "folder");
}
BookmarkFolder.prototype = {
  __proto__: PlacesItem.prototype,
  _logName: "Sync.Record.Folder",
};

Utils.deferGetSet(BookmarkFolder, "cleartext", ["description", "title",
                                                "children"]);

this.Livemark = function Livemark(collection, id) {
  BookmarkFolder.call(this, collection, id, "livemark");
}
Livemark.prototype = {
  __proto__: BookmarkFolder.prototype,
  _logName: "Sync.Record.Livemark",
};

Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]);

this.BookmarkSeparator = function BookmarkSeparator(collection, id) {
  PlacesItem.call(this, collection, id, "separator");
}
BookmarkSeparator.prototype = {
  __proto__: PlacesItem.prototype,
  _logName: "Sync.Record.Separator",
};

Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos");


var kSpecialIds = {

  // Special IDs. Note that mobile can attempt to create a record on
  // dereference; special accessors are provided to prevent recursion within
  // observers.
  guids: ["menu", "places", "tags", "toolbar", "unfiled", "mobile"],

  // Create the special mobile folder to store mobile bookmarks.
  createMobileRoot: function createMobileRoot() {
    let root = PlacesUtils.placesRootId;
    let mRoot = PlacesUtils.bookmarks.createFolder(root, "mobile", -1);
    PlacesUtils.annotations.setItemAnnotation(
      mRoot, MOBILEROOT_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
    PlacesUtils.annotations.setItemAnnotation(
      mRoot, EXCLUDEBACKUP_ANNO, 1, 0, PlacesUtils.annotations.EXPIRE_NEVER);
    return mRoot;
  },

  findMobileRoot: function findMobileRoot(create) {
    // Use the (one) mobile root if it already exists.
    let root = PlacesUtils.annotations.getItemsWithAnnotation(MOBILEROOT_ANNO, {});
    if (root.length != 0)
      return root[0];

    if (create)
      return this.createMobileRoot();

    return null;
  },

  // Accessors for IDs.
  isSpecialGUID: function isSpecialGUID(g) {
    return this.guids.indexOf(g) != -1;
  },

  specialIdForGUID: function specialIdForGUID(guid, create) {
    if (guid == "mobile") {
      return this.findMobileRoot(create);
    }
    return this[guid];
  },

  // Don't bother creating mobile: if it doesn't exist, this ID can't be it!
  specialGUIDForId: function specialGUIDForId(id) {
    for (let guid of this.guids)
      if (this.specialIdForGUID(guid, false) == id)
        return guid;
    return null;
  },

  get menu() {
    return PlacesUtils.bookmarksMenuFolderId;
  },
  get places() {
    return PlacesUtils.placesRootId;
  },
  get tags() {
    return PlacesUtils.tagsFolderId;
  },
  get toolbar() {
    return PlacesUtils.toolbarFolderId;
  },
  get unfiled() {
    return PlacesUtils.unfiledBookmarksFolderId;
  },
  get mobile() {
    return this.findMobileRoot(true);
  },
};

this.BookmarksEngine = function BookmarksEngine(service) {
  SyncEngine.call(this, "Bookmarks", service);
}
BookmarksEngine.prototype = {
  __proto__: SyncEngine.prototype,
  _recordObj: PlacesItem,
  _storeObj: BookmarksStore,
  _trackerObj: BookmarksTracker,
  version: 2,
  _defaultSort: "index",

  syncPriority: 4,

  _sync: function _sync() {
    let engine = this;
    let batchEx = null;

    // Try running sync in batch mode
    PlacesUtils.bookmarks.runInBatchMode({
      runBatched: function wrappedSync() {
        try {
          SyncEngine.prototype._sync.call(engine);
        }
        catch(ex) {
          batchEx = ex;
        }
      }
    }, null);

    // Expose the exception if something inside the batch failed
    if (batchEx != null) {
      throw batchEx;
    }
  },

  // A diagnostic helper to get the string value for a bookmark's URL given
  // its ID. Always returns a string - on error will return a string in the
  // form of "<description of error>" as this is purely for, eg, logging.
  // (This means hitting the DB directly and we don't bother using a cached
  // statement - we should rarely hit this.)
  _getStringUrlForId(id) {
    let url;
    try {
      let stmt = this._store._getStmt(`
            SELECT h.url
            FROM moz_places h
            JOIN moz_bookmarks b ON h.id = b.fk
            WHERE b.id = :id`);
      stmt.params.id = id;
      let rows = Async.querySpinningly(stmt, ["url"]);
      url = rows.length == 0 ? "<not found>" : rows[0].url;
    } catch (ex if !Async.isShutdownException(ex)) {
      if (ex instanceof Ci.mozIStorageError) {
        url = `<failed: Storage error: ${ex.message} (${ex.result})>`;
      } else {
        url = `<failed: ${ex.toString()}>`;
      }
    }
    return url;
  },

  _guidMapFailed: false,
  _buildGUIDMap: function _buildGUIDMap() {
    let guidMap = {};
    for (let guid in this._store.getAllIDs()) {
      // Figure out with which key to store the mapping.
      let key;
      let id = this._store.idForGUID(guid);
      let itemType;
      try {
        itemType = PlacesUtils.bookmarks.getItemType(id);
      } catch (ex) {
        this._log.warn("Deleting invalid bookmark record with id", id);
        try {
          PlacesUtils.bookmarks.removeItem(id);
        } catch (ex) {
          this._log.warn("Failed to delete invalid bookmark", ex);
        }
        continue;
      }
      switch (itemType) {
        case PlacesUtils.bookmarks.TYPE_BOOKMARK:

          // Smart bookmarks map to their annotation value.
          let queryId;
          try {
            queryId = PlacesUtils.annotations.getItemAnnotation(
              id, SMART_BOOKMARKS_ANNO);
          } catch(ex) {}

          if (queryId) {
            key = "q" + queryId;
          } else {
            let uri;
            try {
              uri = PlacesUtils.bookmarks.getBookmarkURI(id);
            } catch (ex) {
              // Bug 1182366 - NS_ERROR_MALFORMED_URI here stops bookmarks sync.
              // Try and get the string value of the URL for diagnostic purposes.
              let url = this._getStringUrlForId(id);
              this._log.warn(`Deleting bookmark with invalid URI. url="${url}", id=${id}`);
              try {
                PlacesUtils.bookmarks.removeItem(id);
              } catch (ex) {
                this._log.warn("Failed to delete invalid bookmark", ex);
              }
              continue;
            }
            key = "b" + uri.spec + ":" + PlacesUtils.bookmarks.getItemTitle(id);
          }
          break;
        case PlacesUtils.bookmarks.TYPE_FOLDER:
          key = "f" + PlacesUtils.bookmarks.getItemTitle(id);
          break;
        case PlacesUtils.bookmarks.TYPE_SEPARATOR:
          key = "s" + PlacesUtils.bookmarks.getItemIndex(id);
          break;
        default:
          continue;
      }

      // The mapping is on a per parent-folder-name basis.
      let parent = PlacesUtils.bookmarks.getFolderIdForItem(id);
      if (parent <= 0)
        continue;

      let parentName = PlacesUtils.bookmarks.getItemTitle(parent);
      if (guidMap[parentName] == null)
        guidMap[parentName] = {};

      // If the entry already exists, remember that there are explicit dupes.
      let entry = new String(guid);
      entry.hasDupe = guidMap[parentName][key] != null;

      // Remember this item's GUID for its parent-name/key pair.
      guidMap[parentName][key] = entry;
      this._log.trace("Mapped: " + [parentName, key, entry, entry.hasDupe]);
    }

    return guidMap;
  },

  // Helper function to get a dupe GUID for an item.
  _mapDupe: function _mapDupe(item) {
    // Figure out if we have something to key with.
    let key;
    let altKey;
    switch (item.type) {
      case "query":
        // Prior to Bug 610501, records didn't carry their Smart Bookmark
        // anno, so we won't be able to dupe them correctly. This altKey
        // hack should get them to dupe correctly.
        if (item.queryId) {
          key = "q" + item.queryId;
          altKey = "b" + item.bmkUri + ":" + item.title;
          break;
        }
        // No queryID? Fall through to the regular bookmark case.
      case "bookmark":
      case "microsummary":
        key = "b" + item.bmkUri + ":" + item.title;
        break;
      case "folder":
      case "livemark":
        key = "f" + item.title;
        break;
      case "separator":
        key = "s" + item.pos;
        break;
      default:
        return;
    }

    // Figure out if we have a map to use!
    // This will throw in some circumstances. That's fine.
    let guidMap = this._guidMap;

    // Give the GUID if we have the matching pair.
    this._log.trace("Finding mapping: " + item.parentName + ", " + key);
    let parent = guidMap[item.parentName];
    
    if (!parent) {
      this._log.trace("No parent => no dupe.");
      return undefined;
    }
      
    let dupe = parent[key];
    
    if (dupe) {
      this._log.trace("Mapped dupe: " + dupe);
      return dupe;
    }
    
    if (altKey) {
      dupe = parent[altKey];
      if (dupe) {
        this._log.trace("Mapped dupe using altKey " + altKey + ": " + dupe);
        return dupe;
      }
    }
    
    this._log.trace("No dupe found for key " + key + "/" + altKey + ".");
    return undefined;
  },

  _syncStartup: function _syncStart() {
    SyncEngine.prototype._syncStartup.call(this);

    let cb = Async.makeSpinningCallback();
    Task.spawn(function() {
      // For first-syncs, make a backup for the user to restore
      if (this.lastSync == 0) {
        this._log.debug("Bookmarks backup starting.");
        yield PlacesBackups.create(null, true);
        this._log.debug("Bookmarks backup done.");
      }
    }.bind(this)).then(
      cb, ex => {
        // Failure to create a backup is somewhat bad, but probably not bad
        // enough to prevent syncing of bookmarks - so just log the error and
        // continue.
        this._log.warn("Got exception \"" + Utils.exceptionStr(ex) +
                       "\" backing up bookmarks, but continuing with sync.");
        cb();
      }
    );

    cb.wait();

    this.__defineGetter__("_guidMap", function() {
      // Create a mapping of folder titles and separator positions to GUID.
      // We do this lazily so that we don't do any work unless we reconcile
      // incoming items.
      let guidMap;
      try {
        guidMap = this._buildGUIDMap();
      } catch (ex if !Async.isShutdownException(ex)) {
        this._log.warn("Got exception \"" + Utils.exceptionStr(ex) +
                       "\" building GUID map." +
                       " Skipping all other incoming items.");
        throw {code: Engine.prototype.eEngineAbortApplyIncoming,
               cause: ex};
      }
      delete this._guidMap;
      return this._guidMap = guidMap;
    });

    this._store._childrenToOrder = {};
  },

  _processIncoming: function (newitems) {
    try {
      SyncEngine.prototype._processIncoming.call(this, newitems);
    } finally {
      // Reorder children.
      this._tracker.ignoreAll = true;
      this._store._orderChildren();
      this._tracker.ignoreAll = false;
      delete this._store._childrenToOrder;
    }
  },

  _syncFinish: function _syncFinish() {
    SyncEngine.prototype._syncFinish.call(this);
    this._tracker._ensureMobileQuery();
  },

  _syncCleanup: function _syncCleanup() {
    SyncEngine.prototype._syncCleanup.call(this);
    delete this._guidMap;
  },

  _createRecord: function _createRecord(id) {
    // Create the record as usual, but mark it as having dupes if necessary.
    let record = SyncEngine.prototype._createRecord.call(this, id);
    let entry = this._mapDupe(record);
    if (entry != null && entry.hasDupe) {
      record.hasDupe = true;
    }
    return record;
  },

  _findDupe: function _findDupe(item) {
    this._log.trace("Finding dupe for " + item.id +
                    " (already duped: " + item.hasDupe + ").");

    // Don't bother finding a dupe if the incoming item has duplicates.
    if (item.hasDupe) {
      this._log.trace(item.id + " already a dupe: not finding one.");
      return;
    }
    let mapped = this._mapDupe(item);
    this._log.debug(item.id + " mapped to " + mapped);
    return mapped;
  }
};

function BookmarksStore(name, engine) {
  Store.call(this, name, engine);

  // Explicitly nullify our references to our cached services so we don't leak
  Svc.Obs.add("places-shutdown", function() {
    for (let query in this._stmts) {
      let stmt = this._stmts[query];
      stmt.finalize();
    }
    this._stmts = {};
  }, this);
}
BookmarksStore.prototype = {
  __proto__: Store.prototype,

  itemExists: function BStore_itemExists(id) {
    return this.idForGUID(id, true) > 0;
  },
  
  /*
   * If the record is a tag query, rewrite it to refer to the local tag ID.
   * 
   * Otherwise, just return.
   */
  preprocessTagQuery: function preprocessTagQuery(record) {
    if (record.type != "query" ||
        record.bmkUri == null ||
        !record.folderName)
      return;
    
    // Yes, this works without chopping off the "place:" prefix.
    let uri           = record.bmkUri
    let queriesRef    = {};
    let queryCountRef = {};
    let optionsRef    = {};
    PlacesUtils.history.queryStringToQueries(uri, queriesRef, queryCountRef,
                                             optionsRef);
    
    // We only process tag URIs.
    if (optionsRef.value.resultType != optionsRef.value.RESULTS_AS_TAG_CONTENTS)
      return;
    
    // Tag something to ensure that the tag exists.
    let tag = record.folderName;
    let dummyURI = Utils.makeURI("about:weave#BStore_preprocess");
    PlacesUtils.tagging.tagURI(dummyURI, [tag]);

    // Look for the id of the tag, which might just have been added.
    let tags = this._getNode(PlacesUtils.tagsFolderId);
    if (!(tags instanceof Ci.nsINavHistoryQueryResultNode)) {
      this._log.debug("tags isn't an nsINavHistoryQueryResultNode; aborting.");
      return;
    }

    tags.containerOpen = true;
    try {
      for (let i = 0; i < tags.childCount; i++) {
        let child = tags.getChild(i);
        if (child.title == tag) {
          // Found the tag, so fix up the query to use the right id.
          this._log.debug("Tag query folder: " + tag + " = " + child.itemId);
          
          this._log.trace("Replacing folders in: " + uri);
          for (let q of queriesRef.value)
            q.setFolders([child.itemId], 1);
          
          record.bmkUri = PlacesUtils.history.queriesToQueryString(
            queriesRef.value, queryCountRef.value, optionsRef.value);
          return;
        }
      }
    }
    finally {
      tags.containerOpen = false;
    }
  },
  
  applyIncoming: function BStore_applyIncoming(record) {
    this._log.debug("Applying record " + record.id);
    let isSpecial = record.id in kSpecialIds;

    if (record.deleted) {
      if (isSpecial) {
        this._log.warn("Ignoring deletion for special record " + record.id);
        return;
      }

      // Don't bother with pre and post-processing for deletions.
      Store.prototype.applyIncoming.call(this, record);
      return;
    }

    // For special folders we're only interested in child ordering.
    if (isSpecial && record.children) {
      this._log.debug("Processing special node: " + record.id);
      // Reorder children later
      this._childrenToOrder[record.id] = record.children;
      return;
    }

    // Skip malformed records. (Bug 806460.)
    if (record.type == "query" &&
        !record.bmkUri) {
      this._log.warn("Skipping malformed query bookmark: " + record.id);
      return;
    }

    // Preprocess the record before doing the normal apply.
    this.preprocessTagQuery(record);

    // Figure out the local id of the parent GUID if available
    let parentGUID = record.parentid;
    if (!parentGUID) {
      throw "Record " + record.id + " has invalid parentid: " + parentGUID;
    }
    this._log.debug("Local parent is " + parentGUID);

    let parentId = this.idForGUID(parentGUID);
    if (parentId > 0) {
      // Save the parent id for modifying the bookmark later
      record._parent = parentId;
      record._orphan = false;
      this._log.debug("Record " + record.id + " is not an orphan.");
    } else {
      this._log.trace("Record " + record.id +
                      " is an orphan: could not find parent " + parentGUID);
      record._orphan = true;
    }

    // Do the normal processing of incoming records
    Store.prototype.applyIncoming.call(this, record);

    // Do some post-processing if we have an item
    let itemId = this.idForGUID(record.id);
    if (itemId > 0) {
      // Move any children that are looking for this folder as a parent
      if (record.type == "folder") {
        this._reparentOrphans(itemId);
        // Reorder children later
        if (record.children)
          this._childrenToOrder[record.id] = record.children;
      }

      // Create an annotation to remember that it needs reparenting.
      if (record._orphan) {
        PlacesUtils.annotations.setItemAnnotation(
          itemId, PARENT_ANNO, parentGUID, 0,
          PlacesUtils.annotations.EXPIRE_NEVER);
      }
    }
  },

  /**
   * Find all ids of items that have a given value for an annotation
   */
  _findAnnoItems: function BStore__findAnnoItems(anno, val) {
    return PlacesUtils.annotations.getItemsWithAnnotation(anno, {})
                      .filter(function(id) {
      return PlacesUtils.annotations.getItemAnnotation(id, anno) == val;
    });
  },

  /**
   * For the provided parent item, attach its children to it
   */
  _reparentOrphans: function _reparentOrphans(parentId) {
    // Find orphans and reunite with this folder parent
    let parentGUID = this.GUIDForId(parentId);
    let orphans = this._findAnnoItems(PARENT_ANNO, parentGUID);

    this._log.debug("Reparenting orphans " + orphans + " to " + parentId);
    orphans.forEach(function(orphan) {
      // Move the orphan to the parent and drop the missing parent annotation
      if (this._reparentItem(orphan, parentId)) {
        PlacesUtils.annotations.removeItemAnnotation(orphan, PARENT_ANNO);
      }
    }, this);
  },

  _reparentItem: function _reparentItem(itemId, parentId) {
    this._log.trace("Attempting to move item " + itemId + " to new parent " +
                    parentId);
    try {
      if (parentId > 0) {
        PlacesUtils.bookmarks.moveItem(itemId, parentId,
                                       PlacesUtils.bookmarks.DEFAULT_INDEX);
        return true;
      }
    } catch(ex) {
      this._log.debug("Failed to reparent item. " + Utils.exceptionStr(ex));
    }
    return false;
  },

  // Turn a record's nsINavBookmarksService constant and other attributes into
  // a granular type for comparison.
  _recordType: function _recordType(itemId) {
    let bms  = PlacesUtils.bookmarks;
    let type = bms.getItemType(itemId);

    switch (type) {
      case bms.TYPE_FOLDER:
        if (PlacesUtils.annotations
                       .itemHasAnnotation(itemId, PlacesUtils.LMANNO_FEEDURI)) {
          return "livemark";
        }
        return "folder";

      case bms.TYPE_BOOKMARK:
        let bmkUri = bms.getBookmarkURI(itemId).spec;
        if (bmkUri.indexOf("place:") == 0) {
          return "query";
        }
        return "bookmark";

      case bms.TYPE_SEPARATOR:
        return "separator";

      default:
        return null;
    }
  },

  create: function BStore_create(record) {
    // Default to unfiled if we don't have the parent yet.
    
    // Valid parent IDs are all positive integers. Other values -- undefined,
    // null, -1 -- all compare false for > 0, so this catches them all. We
    // don't just use <= without the !, because undefined and null compare
    // false for that, too!
    if (!(record._parent > 0)) {
      this._log.debug("Parent is " + record._parent + "; reparenting to unfiled.");
      record._parent = kSpecialIds.unfiled;
    }

    let newId;
    switch (record.type) {
    case "bookmark":
    case "query":
    case "microsummary": {
      let uri = Utils.makeURI(record.bmkUri);
      newId = PlacesUtils.bookmarks.insertBookmark(
        record._parent, uri, PlacesUtils.bookmarks.DEFAULT_INDEX, record.title);
      this._log.debug("created bookmark " + newId + " under " + record._parent
                      + " as " + record.title + " " + record.bmkUri);

      // Smart bookmark annotations are strings.
      if (record.queryId) {
        PlacesUtils.annotations.setItemAnnotation(
          newId, SMART_BOOKMARKS_ANNO, record.queryId, 0,
          PlacesUtils.annotations.EXPIRE_NEVER);
      }

      if (Array.isArray(record.tags)) {
        this._tagURI(uri, record.tags);
      }
      PlacesUtils.bookmarks.setKeywordForBookmark(newId, record.keyword);
      if (record.description) {
        PlacesUtils.annotations.setItemAnnotation(
          newId, DESCRIPTION_ANNO, record.description, 0,
          PlacesUtils.annotations.EXPIRE_NEVER);
      }

      if (record.loadInSidebar) {
        PlacesUtils.annotations.setItemAnnotation(
          newId, SIDEBAR_ANNO, true, 0,
          PlacesUtils.annotations.EXPIRE_NEVER);
      }

    } break;
    case "folder":
      newId = PlacesUtils.bookmarks.createFolder(
        record._parent, record.title, PlacesUtils.bookmarks.DEFAULT_INDEX);
      this._log.debug("created folder " + newId + " under " + record._parent
                      + " as " + record.title);

      if (record.description) {
        PlacesUtils.annotations.setItemAnnotation(
          newId, DESCRIPTION_ANNO, record.description, 0,
          PlacesUtils.annotations.EXPIRE_NEVER);
      }

      // record.children will be dealt with in _orderChildren.
      break;
    case "livemark":
      let siteURI = null;
      if (!record.feedUri) {
        this._log.debug("No feed URI: skipping livemark record " + record.id);
        return;
      }
      if (PlacesUtils.annotations
                     .itemHasAnnotation(record._parent, PlacesUtils.LMANNO_FEEDURI)) {
        this._log.debug("Invalid parent: skipping livemark record " + record.id);
        return;
      }

      if (record.siteUri != null)
        siteURI = Utils.makeURI(record.siteUri);

      // Until this engine can handle asynchronous error reporting, we need to
      // detect errors on creation synchronously.
      let spinningCb = Async.makeSpinningCallback();

      let livemarkObj = {title: record.title,
                         parentId: record._parent,
                         index: PlacesUtils.bookmarks.DEFAULT_INDEX,
                         feedURI: Utils.makeURI(record.feedUri),
                         siteURI: siteURI,
                         guid: record.id};
      PlacesUtils.livemarks.addLivemark(livemarkObj).then(
        aLivemark => { spinningCb(null, [Components.results.NS_OK, aLivemark]) },
        ex => {
          this._log.error("creating livemark failed: " + ex);
          spinningCb(null, [Components.results.NS_ERROR_UNEXPECTED, null])
        }
      );

      let [status, livemark] = spinningCb.wait();
      if (!Components.isSuccessCode(status)) {
        throw status;
      }

      this._log.debug("Created livemark " + livemark.id + " under " +
                      livemark.parentId + " as " + livemark.title +
                      ", " + livemark.siteURI.spec + ", " +
                      livemark.feedURI.spec + ", GUID " +
                      livemark.guid);
      break;
    case "separator":
      newId = PlacesUtils.bookmarks.insertSeparator(
        record._parent, PlacesUtils.bookmarks.DEFAULT_INDEX);
      this._log.debug("created separator " + newId + " under " + record._parent);
      break;
    case "item":
      this._log.debug(" -> got a generic places item.. do nothing?");
      return;
    default:
      this._log.error("_create: Unknown item type: " + record.type);
      return;
    }

    if (newId) {
      // Livemarks can set the GUID through the API, so there's no need to
      // do that here.
      this._log.trace("Setting GUID of new item " + newId + " to " + record.id);
      this._setGUID(newId, record.id);
    }
  },

  // Factored out of `remove` to avoid redundant DB queries when the Places ID
  // is already known.
  removeById: function removeById(itemId, guid) {
    let type = PlacesUtils.bookmarks.getItemType(itemId);

    switch (type) {
    case PlacesUtils.bookmarks.TYPE_BOOKMARK:
      this._log.debug("  -> removing bookmark " + guid);
      PlacesUtils.bookmarks.removeItem(itemId);
      break;
    case PlacesUtils.bookmarks.TYPE_FOLDER:
      this._log.debug("  -> removing folder " + guid);
      PlacesUtils.bookmarks.removeItem(itemId);
      break;
    case PlacesUtils.bookmarks.TYPE_SEPARATOR:
      this._log.debug("  -> removing separator " + guid);
      PlacesUtils.bookmarks.removeItem(itemId);
      break;
    default:
      this._log.error("remove: Unknown item type: " + type);
      break;
    }
  },

  remove: function BStore_remove(record) {
    if (kSpecialIds.isSpecialGUID(record.id)) {
      this._log.warn("Refusing to remove special folder " + record.id);
      return;
    }

    let itemId = this.idForGUID(record.id);
    if (itemId <= 0) {
      this._log.debug("Item " + record.id + " already removed");
      return;
    }
    this.removeById(itemId, record.id);
  },

  _taggableTypes: ["bookmark", "microsummary", "query"],
  isTaggable: function isTaggable(recordType) {
    return this._taggableTypes.indexOf(recordType) != -1;
  },

  update: function BStore_update(record) {
    let itemId = this.idForGUID(record.id);

    if (itemId <= 0) {
      this._log.debug("Skipping update for unknown item: " + record.id);
      return;
    }

    // Two items are the same type if they have the same ItemType in Places,
    // and also share some key characteristics (e.g., both being livemarks).
    // We figure this out by examining the item to find the equivalent granular
    // (string) type.
    // If they're not the same type, we can't just update attributes. Delete
    // then recreate the record instead.
    let localItemType    = this._recordType(itemId);
    let remoteRecordType = record.type;
    this._log.trace("Local type: " + localItemType + ". " +
                    "Remote type: " + remoteRecordType + ".");

    if (localItemType != remoteRecordType) {
      this._log.debug("Local record and remote record differ in type. " +
                      "Deleting and recreating.");
      this.removeById(itemId, record.id);
      this.create(record);
      return;
    }

    this._log.trace("Updating " + record.id + " (" + itemId + ")");

    // Move the bookmark to a new parent or new position if necessary
    if (record._parent > 0 &&
        PlacesUtils.bookmarks.getFolderIdForItem(itemId) != record._parent) {
      this._reparentItem(itemId, record._parent);
    }

    for (let [key, val] in Iterator(record.cleartext)) {
      switch (key) {
      case "title":
        PlacesUtils.bookmarks.setItemTitle(itemId, val);
        break;
      case "bmkUri":
        PlacesUtils.bookmarks.changeBookmarkURI(itemId, Utils.makeURI(val));
        break;
      case "tags":
        if (Array.isArray(val)) {
          if (this.isTaggable(remoteRecordType)) {
            this._tagID(itemId, val);
          } else {
            this._log.debug("Remote record type is invalid for tags: " + remoteRecordType);
          }
        }
        break;
      case "keyword":
        PlacesUtils.bookmarks.setKeywordForBookmark(itemId, val);
        break;
      case "description":
        if (val) {
          PlacesUtils.annotations.setItemAnnotation(
            itemId, DESCRIPTION_ANNO, val, 0,
            PlacesUtils.annotations.EXPIRE_NEVER);
        } else {
          PlacesUtils.annotations.removeItemAnnotation(itemId, DESCRIPTION_ANNO);
        }
        break;
      case "loadInSidebar":
        if (val) {
          PlacesUtils.annotations.setItemAnnotation(
            itemId, SIDEBAR_ANNO, true, 0,
            PlacesUtils.annotations.EXPIRE_NEVER);
        } else {
          PlacesUtils.annotations.removeItemAnnotation(itemId, SIDEBAR_ANNO);
        }
        break;
      case "queryId":
        PlacesUtils.annotations.setItemAnnotation(
          itemId, SMART_BOOKMARKS_ANNO, val, 0,
          PlacesUtils.annotations.EXPIRE_NEVER);
        break;
      }
    }
  },

  _orderChildren: function _orderChildren() {
    for (let [guid, children] in Iterator(this._childrenToOrder)) {
      // Reorder children according to the GUID list. Gracefully deal
      // with missing items, e.g. locally deleted.
      let delta = 0;
      let parent = null;
      for (let idx = 0; idx < children.length; idx++) {
        let itemid = this.idForGUID(children[idx]);
        if (itemid == -1) {
          delta += 1;
          this._log.trace("Could not locate record " + children[idx]);
          continue;
        }
        try {
          // This code path could be optimized by caching the parent earlier.
          // Doing so should take in count any edge case due to reparenting
          // or parent invalidations though.
          if (!parent) {
            parent = PlacesUtils.bookmarks.getFolderIdForItem(itemid);
          }
          PlacesUtils.bookmarks.moveItem(itemid, parent, idx - delta);
        } catch (ex) {
          this._log.debug("Could not move item " + children[idx] + ": " + ex);
        }
      }
    }
  },

  changeItemID: function BStore_changeItemID(oldID, newID) {
    this._log.debug("Changing GUID " + oldID + " to " + newID);

    // Make sure there's an item to change GUIDs
    let itemId = this.idForGUID(oldID);
    if (itemId <= 0)
      return;

    this._setGUID(itemId, newID);
  },

  _getNode: function BStore__getNode(folder) {
    let query = PlacesUtils.history.getNewQuery();
    query.setFolders([folder], 1);
    return PlacesUtils.history.executeQuery(
      query, PlacesUtils.history.getNewQueryOptions()).root;
  },

  _getTags: function BStore__getTags(uri) {
    try {
      if (typeof(uri) == "string")
        uri = Utils.makeURI(uri);
    } catch(e) {
      this._log.warn("Could not parse URI \"" + uri + "\": " + e);
    }
    return PlacesUtils.tagging.getTagsForURI(uri, {});
  },

  _getDescription: function BStore__getDescription(id) {
    try {
      return PlacesUtils.annotations.getItemAnnotation(id, DESCRIPTION_ANNO);
    } catch (e) {
      return null;
    }
  },

  _isLoadInSidebar: function BStore__isLoadInSidebar(id) {
    return PlacesUtils.annotations.itemHasAnnotation(id, SIDEBAR_ANNO);
  },

  get _childGUIDsStm() {
    return this._getStmt(
      "SELECT id AS item_id, guid " +
      "FROM moz_bookmarks " +
      "WHERE parent = :parent " +
      "ORDER BY position");
  },
  _childGUIDsCols: ["item_id", "guid"],

  _getChildGUIDsForId: function _getChildGUIDsForId(itemid) {
    let stmt = this._childGUIDsStm;
    stmt.params.parent = itemid;
    let rows = Async.querySpinningly(stmt, this._childGUIDsCols);
    return rows.map(function (row) {
      if (row.guid) {
        return row.guid;
      }
      // A GUID hasn't been assigned to this item yet, do this now.
      return this.GUIDForId(row.item_id);
    }, this);
  },

  // Create a record starting from the weave id (places guid)
  createRecord: function createRecord(id, collection) {
    let placeId = this.idForGUID(id);
    let record;
    if (placeId <= 0) { // deleted item
      record = new PlacesItem(collection, id);
      record.deleted = true;
      return record;
    }

    let parent = PlacesUtils.bookmarks.getFolderIdForItem(placeId);
    switch (PlacesUtils.bookmarks.getItemType(placeId)) {
    case PlacesUtils.bookmarks.TYPE_BOOKMARK:
      let bmkUri = PlacesUtils.bookmarks.getBookmarkURI(placeId).spec;
      if (bmkUri.indexOf("place:") == 0) {
        record = new BookmarkQuery(collection, id);

        // Get the actual tag name instead of the local itemId
        let folder = bmkUri.match(/[:&]folder=(\d+)/);
        try {
          // There might not be the tag yet when creating on a new client
          if (folder != null) {
            folder = folder[1];
            record.folderName = PlacesUtils.bookmarks.getItemTitle(folder);
            this._log.trace("query id: " + folder + " = " + record.folderName);
          }
        }
        catch(ex) {}
        
        // Persist the Smart Bookmark anno, if found.
        try {
          let anno = PlacesUtils.annotations.getItemAnnotation(placeId, SMART_BOOKMARKS_ANNO);
          if (anno != null) {
            this._log.trace("query anno: " + SMART_BOOKMARKS_ANNO +
                            " = " + anno);
            record.queryId = anno;
          }
        }
        catch(ex) {}
      }
      else {
        record = new Bookmark(collection, id);
      }
      record.title = PlacesUtils.bookmarks.getItemTitle(placeId);

      record.parentName = PlacesUtils.bookmarks.getItemTitle(parent);
      record.bmkUri = bmkUri;
      record.tags = this._getTags(record.bmkUri);
      record.keyword = PlacesUtils.bookmarks.getKeywordForBookmark(placeId);
      record.description = this._getDescription(placeId);
      record.loadInSidebar = this._isLoadInSidebar(placeId);
      break;

    case PlacesUtils.bookmarks.TYPE_FOLDER:
      if (PlacesUtils.annotations
                     .itemHasAnnotation(placeId, PlacesUtils.LMANNO_FEEDURI)) {
        record = new Livemark(collection, id);
        let as = PlacesUtils.annotations;
        record.feedUri = as.getItemAnnotation(placeId, PlacesUtils.LMANNO_FEEDURI);
        try {
          record.siteUri = as.getItemAnnotation(placeId, PlacesUtils.LMANNO_SITEURI);
        } catch (ex) {}
      } else {
        record = new BookmarkFolder(collection, id);
      }

      if (parent > 0)
        record.parentName = PlacesUtils.bookmarks.getItemTitle(parent);
      record.title = PlacesUtils.bookmarks.getItemTitle(placeId);
      record.description = this._getDescription(placeId);
      record.children = this._getChildGUIDsForId(placeId);
      break;

    case PlacesUtils.bookmarks.TYPE_SEPARATOR:
      record = new BookmarkSeparator(collection, id);
      if (parent > 0)
        record.parentName = PlacesUtils.bookmarks.getItemTitle(parent);
      // Create a positioning identifier for the separator, used by _mapDupe
      record.pos = PlacesUtils.bookmarks.getItemIndex(placeId);
      break;

    default:
      record = new PlacesItem(collection, id);
      this._log.warn("Unknown item type, cannot serialize: " +
                     PlacesUtils.bookmarks.getItemType(placeId));
    }

    record.parentid = this.GUIDForId(parent);
    record.sortindex = this._calculateIndex(record);

    return record;
  },

  _stmts: {},
  _getStmt: function(query) {
    if (query in this._stmts) {
      return this._stmts[query];
    }

    this._log.trace("Creating SQL statement: " + query);
    let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
                        .DBConnection;
    return this._stmts[query] = db.createAsyncStatement(query);
  },

  get _frecencyStm() {
    return this._getStmt(
        "SELECT frecency " +
        "FROM moz_places " +
        "WHERE url = :url " +
        "LIMIT 1");
  },
  _frecencyCols: ["frecency"],

  get _setGUIDStm() {
    return this._getStmt(
      "UPDATE moz_bookmarks " +
      "SET guid = :guid " +
      "WHERE id = :item_id");
  },

  // Some helper functions to handle GUIDs
  _setGUID: function _setGUID(id, guid) {
    if (!guid)
      guid = Utils.makeGUID();

    let stmt = this._setGUIDStm;
    stmt.params.guid = guid;
    stmt.params.item_id = id;
    Async.querySpinningly(stmt);
    PlacesUtils.invalidateCachedGuidFor(id);
    return guid;
  },

  get _guidForIdStm() {
    return this._getStmt(
      "SELECT guid " +
      "FROM moz_bookmarks " +
      "WHERE id = :item_id");
  },
  _guidForIdCols: ["guid"],

  GUIDForId: function GUIDForId(id) {
    let special = kSpecialIds.specialGUIDForId(id);
    if (special)
      return special;

    let stmt = this._guidForIdStm;
    stmt.params.item_id = id;

    // Use the existing GUID if it exists
    let result = Async.querySpinningly(stmt, this._guidForIdCols)[0];
    if (result && result.guid)
      return result.guid;

    // Give the uri a GUID if it doesn't have one
    return this._setGUID(id);
  },

  get _idForGUIDStm() {
    return this._getStmt(
      "SELECT id AS item_id " +
      "FROM moz_bookmarks " +
      "WHERE guid = :guid");
  },
  _idForGUIDCols: ["item_id"],

  // noCreate is provided as an optional argument to prevent the creation of
  // non-existent special records, such as "mobile".
  idForGUID: function idForGUID(guid, noCreate) {
    if (kSpecialIds.isSpecialGUID(guid))
      return kSpecialIds.specialIdForGUID(guid, !noCreate);

    let stmt = this._idForGUIDStm;
    // guid might be a String object rather than a string.
    stmt.params.guid = guid.toString();

    let results = Async.querySpinningly(stmt, this._idForGUIDCols);
    this._log.trace("Number of rows matching GUID " + guid + ": "
                    + results.length);
    
    // Here's the one we care about: the first.
    let result = results[0];
    
    if (!result)
      return -1;
    
    return result.item_id;
  },

  _calculateIndex: function _calculateIndex(record) {
    // Ensure folders have a very high sort index so they're not synced last.
    if (record.type == "folder")
      return FOLDER_SORTINDEX;

    // For anything directly under the toolbar, give it a boost of more than an
    // unvisited bookmark
    let index = 0;
    if (record.parentid == "toolbar")
      index += 150;

    // Add in the bookmark's frecency if we have something.
    if (record.bmkUri != null) {
      this._frecencyStm.params.url = record.bmkUri;
      let result = Async.querySpinningly(this._frecencyStm, this._frecencyCols);
      if (result.length)
        index += result[0].frecency;
    }

    return index;
  },

  _getChildren: function BStore_getChildren(guid, items) {
    let node = guid; // the recursion case
    if (typeof(node) == "string") { // callers will give us the guid as the first arg
      let nodeID = this.idForGUID(guid, true);
      if (!nodeID) {
        this._log.debug("No node for GUID " + guid + "; returning no children.");
        return items;
      }
      node = this._getNode(nodeID);
    }
    
    if (node.type == node.RESULT_TYPE_FOLDER) {
      node.QueryInterface(Ci.nsINavHistoryQueryResultNode);
      node.containerOpen = true;
      try {
        // Remember all the children GUIDs and recursively get more
        for (let i = 0; i < node.childCount; i++) {
          let child = node.getChild(i);
          items[this.GUIDForId(child.itemId)] = true;
          this._getChildren(child, items);
        }
      }
      finally {
        node.containerOpen = false;
      }
    }

    return items;
  },

  /**
   * Associates the URI of the item with the provided ID with the
   * provided array of tags.
   * If the provided ID does not identify an item with a URI,
   * returns immediately.
   */
  _tagID: function _tagID(itemID, tags) {
    if (!itemID || !tags) {
      return;
    }

    try {
      let u = PlacesUtils.bookmarks.getBookmarkURI(itemID);
      this._tagURI(u, tags);
    } catch (e) {
      this._log.warn("Got exception fetching URI for " + itemID + ": not tagging. " +
                     Utils.exceptionStr(e));

      // I guess it doesn't have a URI. Don't try to tag it.
      return;
    }
  },

  /**
   * Associate the provided URI with the provided array of tags.
   * If the provided URI is falsy, returns immediately.
   */
  _tagURI: function _tagURI(bookmarkURI, tags) {
    if (!bookmarkURI || !tags) {
      return;
    }

    // Filter out any null/undefined/empty tags.
    tags = tags.filter(t => t);

    // Temporarily tag a dummy URI to preserve tag ids when untagging.
    let dummyURI = Utils.makeURI("about:weave#BStore_tagURI");
    PlacesUtils.tagging.tagURI(dummyURI, tags);
    PlacesUtils.tagging.untagURI(bookmarkURI, null);
    PlacesUtils.tagging.tagURI(bookmarkURI, tags);
    PlacesUtils.tagging.untagURI(dummyURI, null);
  },

  getAllIDs: function BStore_getAllIDs() {
    let items = {"menu": true,
                 "toolbar": true,
                 "unfiled": true,
                };
    // We also want "mobile" but only if a local mobile folder already exists
    // (otherwise we'll later end up creating it, which we want to avoid until
    // we actually need it.)
    if (kSpecialIds.findMobileRoot(false)) {
      items["mobile"] = true;
    }
    for (let guid of kSpecialIds.guids) {
      if (guid != "places" && guid != "tags")
        this._getChildren(guid, items);
    }
    return items;
  },

  wipe: function BStore_wipe() {
    let cb = Async.makeSpinningCallback();
    Task.spawn(function() {
      // Save a backup before clearing out all bookmarks.
      yield PlacesBackups.create(null, true);
      for (let guid of kSpecialIds.guids)
        if (guid != "places") {
          let id = kSpecialIds.specialIdForGUID(guid);
          if (id)
            PlacesUtils.bookmarks.removeFolderChildren(id);
        }
      cb();
    });
    cb.wait();
  }
};

function BookmarksTracker(name, engine) {
  Tracker.call(this, name, engine);

  Svc.Obs.add("places-shutdown", this);
}
BookmarksTracker.prototype = {
  __proto__: Tracker.prototype,

  startTracking: function() {
    PlacesUtils.bookmarks.addObserver(this, true);
    Svc.Obs.add("bookmarks-restore-begin", this);
    Svc.Obs.add("bookmarks-restore-success", this);
    Svc.Obs.add("bookmarks-restore-failed", this);
  },

  stopTracking: function() {
    PlacesUtils.bookmarks.removeObserver(this);
    Svc.Obs.remove("bookmarks-restore-begin", this);
    Svc.Obs.remove("bookmarks-restore-success", this);
    Svc.Obs.remove("bookmarks-restore-failed", this);
  },

  observe: function observe(subject, topic, data) {
    Tracker.prototype.observe.call(this, subject, topic, data);

    switch (topic) {
      case "bookmarks-restore-begin":
        this._log.debug("Ignoring changes from importing bookmarks.");
        this.ignoreAll = true;
        break;
      case "bookmarks-restore-success":
        this._log.debug("Tracking all items on successful import.");
        this.ignoreAll = false;

        this._log.debug("Restore succeeded: wiping server and other clients.");
        this.engine.service.resetClient([this.name]);
        this.engine.service.wipeServer([this.name]);
        this.engine.service.clientsEngine.sendCommand("wipeEngine", [this.name]);
        break;
      case "bookmarks-restore-failed":
        this._log.debug("Tracking all items on failed import.");
        this.ignoreAll = false;
        break;
    }
  },

  QueryInterface: XPCOMUtils.generateQI([
    Ci.nsINavBookmarkObserver,
    Ci.nsINavBookmarkObserver_MOZILLA_1_9_1_ADDITIONS,
    Ci.nsISupportsWeakReference
  ]),

  /**
   * Add a bookmark GUID to be uploaded and bump up the sync score.
   *
   * @param itemGuid
   *        GUID of the bookmark to upload.
   */
  _add: function BMT__add(itemId, guid) {
    guid = kSpecialIds.specialGUIDForId(itemId) || guid;
    if (this.addChangedID(guid))
      this._upScore();
  },

  /* Every add/remove/change will trigger a sync for MULTI_DEVICE. */
  _upScore: function BMT__upScore() {
    this.score += SCORE_INCREMENT_XLARGE;
  },

  /**
   * Determine if a change should be ignored.
   *
   * @param itemId
   *        Item under consideration to ignore
   * @param folder (optional)
   *        Folder of the item being changed
   */
  _ignore: function BMT__ignore(itemId, folder, guid) {
    // Ignore unconditionally if the engine tells us to.
    if (this.ignoreAll)
      return true;

    // Get the folder id if we weren't given one.
    if (folder == null) {
      try {
        folder = PlacesUtils.bookmarks.getFolderIdForItem(itemId);
      } catch (ex) {
        this._log.debug("getFolderIdForItem(" + itemId +
                        ") threw; calling _ensureMobileQuery.");
        // I'm guessing that gFIFI can throw, and perhaps that's why
        // _ensureMobileQuery is here at all. Try not to call it.
        this._ensureMobileQuery();
        folder = PlacesUtils.bookmarks.getFolderIdForItem(itemId);
      }
    }

    // Ignore changes to tags (folders under the tags folder).
    let tags = kSpecialIds.tags;
    if (folder == tags)
      return true;

    // Ignore tag items (the actual instance of a tag for a bookmark).
    if (PlacesUtils.bookmarks.getFolderIdForItem(folder) == tags)
      return true;

    // Make sure to remove items that have the exclude annotation.
    if (PlacesUtils.annotations.itemHasAnnotation(itemId, EXCLUDEBACKUP_ANNO)) {
      this.removeChangedID(guid);
      return true;
    }

    return false;
  },

  onItemAdded: function BMT_onItemAdded(itemId, folder, index,
                                        itemType, uri, title, dateAdded,
                                        guid, parentGuid) {
    if (this._ignore(itemId, folder, guid))
      return;

    this._log.trace("onItemAdded: " + itemId);
    this._add(itemId, guid);
    this._add(folder, parentGuid);
  },

  onItemRemoved: function (itemId, parentId, index, type, uri,
                           guid, parentGuid) {
    if (this._ignore(itemId, parentId, guid)) {
      return;
    }

    this._log.trace("onItemRemoved: " + itemId);
    this._add(itemId, guid);
    this._add(parentId, parentGuid);
  },

  _ensureMobileQuery: function _ensureMobileQuery() {
    let find = val =>
      PlacesUtils.annotations.getItemsWithAnnotation(ORGANIZERQUERY_ANNO, {}).filter(
        id => PlacesUtils.annotations.getItemAnnotation(id, ORGANIZERQUERY_ANNO) == val
      );

    // Don't continue if the Library isn't ready
    let all = find(ALLBOOKMARKS_ANNO);
    if (all.length == 0)
      return;

    // Disable handling of notifications while changing the mobile query
    this.ignoreAll = true;

    let mobile = find(MOBILE_ANNO);
    let queryURI = Utils.makeURI("place:folder=" + kSpecialIds.mobile);
    let title = Str.sync.get("mobile.label");

    // Don't add OR remove the mobile bookmarks if there's nothing.
    if (PlacesUtils.bookmarks.getIdForItemAt(kSpecialIds.mobile, 0) == -1) {
      if (mobile.length != 0)
        PlacesUtils.bookmarks.removeItem(mobile[0]);
    }
    // Add the mobile bookmarks query if it doesn't exist
    else if (mobile.length == 0) {
      let query = PlacesUtils.bookmarks.insertBookmark(all[0], queryURI, -1, title);
      PlacesUtils.annotations.setItemAnnotation(query, ORGANIZERQUERY_ANNO, MOBILE_ANNO, 0,
                                  PlacesUtils.annotations.EXPIRE_NEVER);
      PlacesUtils.annotations.setItemAnnotation(query, EXCLUDEBACKUP_ANNO, 1, 0,
                                  PlacesUtils.annotations.EXPIRE_NEVER);
    }
    // Make sure the existing title is correct
    else if (PlacesUtils.bookmarks.getItemTitle(mobile[0]) != title) {
      PlacesUtils.bookmarks.setItemTitle(mobile[0], title);
    }

    this.ignoreAll = false;
  },

  // This method is oddly structured, but the idea is to return as quickly as
  // possible -- this handler gets called *every time* a bookmark changes, for
  // *each change*.
  onItemChanged: function BMT_onItemChanged(itemId, property, isAnno, value,
                                            lastModified, itemType, parentId,
                                            guid, parentGuid) {
    // Quicker checks first.
    if (this.ignoreAll)
      return;

    if (isAnno && (ANNOS_TO_TRACK.indexOf(property) == -1))
      // Ignore annotations except for the ones that we sync.
      return;

    // Ignore favicon changes to avoid unnecessary churn.
    if (property == "favicon")
      return;

    if (this._ignore(itemId, parentId, guid))
      return;

    this._log.trace("onItemChanged: " + itemId +
                    (", " + property + (isAnno? " (anno)" : "")) +
                    (value ? (" = \"" + value + "\"") : ""));
    this._add(itemId, guid);
  },

  onItemMoved: function BMT_onItemMoved(itemId, oldParent, oldIndex,
                                        newParent, newIndex, itemType,
                                        guid, oldParentGuid, newParentGuid) {
    if (this._ignore(itemId, newParent, guid))
      return;

    this._log.trace("onItemMoved: " + itemId);
    this._add(oldParent, oldParentGuid);
    if (oldParent != newParent) {
      this._add(itemId, guid);
      this._add(newParent, newParentGuid);
    }

    // Remove any position annotations now that the user moved the item
    PlacesUtils.annotations.removeItemAnnotation(itemId, PARENT_ANNO);
  },

  onBeginUpdateBatch: function () {},
  onEndUpdateBatch: function () {},
  onItemVisited: function () {}
};
