gecko-dev/services/sync/modules/engines/bookmarks.js

1531 lines
49 KiB
JavaScript

/* 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"];
const Cc = Components.classes;
const Ci = Components.interfaces;
const 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");
let 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 each (let guid in this.guids)
if (this.specialIdForGUID(guid, false) == id)
return guid;
return null;
},
get menu() PlacesUtils.bookmarksMenuFolderId,
get places() PlacesUtils.placesRootId,
get tags() PlacesUtils.tagsFolderId,
get toolbar() PlacesUtils.toolbarFolderId,
get unfiled() PlacesUtils.unfiledBookmarksFolderId,
get mobile() 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,
_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;
}
},
_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);
switch (PlacesUtils.bookmarks.getItemType(id)) {
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
key = "b" + PlacesUtils.bookmarks.getBookmarkURI(id).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) {
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 each (let [query, stmt] in Iterator(this._stmts)) {
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 each (let q in 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]) },
() => { spinningCb(null, [Components.results.NS_ERROR_UNEXPECTED, aLivemark]) }
);
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);
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(function(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};
for each (let guid in 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 each (let guid in 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 = function (val)
PlacesUtils.annotations.getItemsWithAnnotation(ORGANIZERQUERY_ANNO, {}).filter(
function (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 () {}
};