diff options
Diffstat (limited to 'services/cloudsync/CloudSyncBookmarks.jsm')
-rw-r--r-- | services/cloudsync/CloudSyncBookmarks.jsm | 795 |
1 files changed, 795 insertions, 0 deletions
diff --git a/services/cloudsync/CloudSyncBookmarks.jsm b/services/cloudsync/CloudSyncBookmarks.jsm new file mode 100644 index 000000000..bb2e48d59 --- /dev/null +++ b/services/cloudsync/CloudSyncBookmarks.jsm @@ -0,0 +1,795 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["Bookmarks"]; + +const Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://services-common/utils.js"); +Cu.import("resource://services-crypto/utils.js"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource:///modules/PlacesUIUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +Cu.import("resource://gre/modules/Promise.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/CloudSyncPlacesWrapper.jsm"); +Cu.import("resource://gre/modules/CloudSyncEventSource.jsm"); +Cu.import("resource://gre/modules/CloudSyncBookmarksFolderCache.jsm"); + +const ITEM_TYPES = [ + "NULL", + "BOOKMARK", + "FOLDER", + "SEPARATOR", + "DYNAMIC_CONTAINER", // no longer used by Places, but this ID should not be used for future item types +]; + +const CS_UNKNOWN = 0x1; +const CS_FOLDER = 0x1 << 1; +const CS_SEPARATOR = 0x1 << 2; +const CS_QUERY = 0x1 << 3; +const CS_LIVEMARK = 0x1 << 4; +const CS_BOOKMARK = 0x1 << 5; + +const EXCLUDE_BACKUP_ANNO = "places/excludeFromBackup"; + +const DATA_VERSION = 1; + +function asyncCallback(ctx, func, args) { + function invoke() { + func.apply(ctx, args); + } + CommonUtils.nextTick(invoke); +} + +var Record = function (params) { + this.id = params.guid; + this.parent = params.parent || null; + this.index = params.position; + this.title = params.title; + this.dateAdded = Math.floor(params.dateAdded/1000); + this.lastModified = Math.floor(params.lastModified/1000); + this.uri = params.url; + + let annos = params.annos || {}; + Object.defineProperty(this, "annos", { + get: function () { + return annos; + }, + enumerable: false + }); + + switch (params.type) { + case PlacesUtils.bookmarks.TYPE_FOLDER: + if (PlacesUtils.LMANNO_FEEDURI in annos) { + this.type = CS_LIVEMARK; + this.feed = annos[PlacesUtils.LMANNO_FEEDURI]; + this.site = annos[PlacesUtils.LMANNO_SITEURI]; + } else { + this.type = CS_FOLDER; + } + break; + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + if (this.uri.startsWith("place:")) { + this.type = CS_QUERY; + } else { + this.type = CS_BOOKMARK; + } + break; + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + this.type = CS_SEPARATOR; + break; + default: + this.type = CS_UNKNOWN; + } +}; + +Record.prototype = { + version: DATA_VERSION, +}; + +var Bookmarks = function () { + let createRootFolder = function (name) { + let ROOT_FOLDER_ANNO = "cloudsync/rootFolder/" + name; + let ROOT_SHORTCUT_ANNO = "cloudsync/rootShortcut/" + name; + + let deferred = Promise.defer(); + let placesRootId = PlacesUtils.placesRootId; + let rootFolderId; + let rootShortcutId; + + function createAdapterShortcut(result) { + rootFolderId = result; + let uri = "place:folder=" + rootFolderId; + return PlacesWrapper.insertBookmark(PlacesUIUtils.allBookmarksFolderId, uri, + PlacesUtils.bookmarks.DEFAULT_INDEX, name); + } + + function setRootFolderCloudSyncAnnotation(result) { + rootShortcutId = result; + return PlacesWrapper.setItemAnnotation(rootFolderId, ROOT_FOLDER_ANNO, + 1, 0, PlacesUtils.annotations.EXPIRE_NEVER); + } + + function setRootShortcutCloudSyncAnnotation() { + return PlacesWrapper.setItemAnnotation(rootShortcutId, ROOT_SHORTCUT_ANNO, + 1, 0, PlacesUtils.annotations.EXPIRE_NEVER); + } + + function setRootFolderExcludeFromBackupAnnotation() { + return PlacesWrapper.setItemAnnotation(rootFolderId, EXCLUDE_BACKUP_ANNO, + 1, 0, PlacesUtils.annotations.EXPIRE_NEVER); + } + + function finish() { + deferred.resolve(rootFolderId); + } + + Promise.resolve(PlacesUtils.bookmarks.createFolder(placesRootId, name, PlacesUtils.bookmarks.DEFAULT_INDEX)) + .then(createAdapterShortcut) + .then(setRootFolderCloudSyncAnnotation) + .then(setRootShortcutCloudSyncAnnotation) + .then(setRootFolderExcludeFromBackupAnnotation) + .then(finish, deferred.reject); + + return deferred.promise; + }; + + let getRootFolder = function (name) { + let ROOT_FOLDER_ANNO = "cloudsync/rootFolder/" + name; + let ROOT_SHORTCUT_ANNO = "cloudsync/rootShortcut/" + name; + let deferred = Promise.defer(); + + function checkRootFolder(folderIds) { + if (!folderIds.length) { + return createRootFolder(name); + } + return Promise.resolve(folderIds[0]); + } + + function createFolderObject(folderId) { + return new RootFolder(folderId, name); + } + + PlacesWrapper.getLocalIdsWithAnnotation(ROOT_FOLDER_ANNO) + .then(checkRootFolder, deferred.reject) + .then(createFolderObject) + .then(deferred.resolve, deferred.reject); + + return deferred.promise; + }; + + let deleteRootFolder = function (name) { + let ROOT_FOLDER_ANNO = "cloudsync/rootFolder/" + name; + let ROOT_SHORTCUT_ANNO = "cloudsync/rootShortcut/" + name; + + let deferred = Promise.defer(); + let placesRootId = PlacesUtils.placesRootId; + + function getRootShortcutId() { + return PlacesWrapper.getLocalIdsWithAnnotation(ROOT_SHORTCUT_ANNO); + } + + function deleteShortcut(shortcutIds) { + if (!shortcutIds.length) { + return Promise.resolve(); + } + return PlacesWrapper.removeItem(shortcutIds[0]); + } + + function getRootFolderId() { + return PlacesWrapper.getLocalIdsWithAnnotation(ROOT_FOLDER_ANNO); + } + + function deleteFolder(folderIds) { + let deleteFolderDeferred = Promise.defer(); + + if (!folderIds.length) { + return Promise.resolve(); + } + + let rootFolderId = folderIds[0]; + PlacesWrapper.removeFolderChildren(rootFolderId).then( + function () { + return PlacesWrapper.removeItem(rootFolderId); + } + ).then(deleteFolderDeferred.resolve, deleteFolderDeferred.reject); + + return deleteFolderDeferred.promise; + } + + getRootShortcutId().then(deleteShortcut) + .then(getRootFolderId) + .then(deleteFolder) + .then(deferred.resolve, deferred.reject); + + return deferred.promise; + }; + + /* PUBLIC API */ + this.getRootFolder = getRootFolder.bind(this); + this.deleteRootFolder = deleteRootFolder.bind(this); + +}; + +this.Bookmarks = Bookmarks; + +var RootFolder = function (rootId, rootName) { + let suspended = true; + let ignoreAll = false; + + let suspend = function () { + if (!suspended) { + PlacesUtils.bookmarks.removeObserver(observer); + suspended = true; + } + }.bind(this); + + let resume = function () { + if (suspended) { + PlacesUtils.bookmarks.addObserver(observer, false); + suspended = false; + } + }.bind(this); + + let eventTypes = [ + "add", + "remove", + "change", + "move", + ]; + + let eventSource = new EventSource(eventTypes, suspend, resume); + + let folderCache = new FolderCache; + folderCache.insert(rootId, null); + + let getCachedFolderIds = function (cache, roots) { + let nodes = [...roots]; + let results = []; + + while (nodes.length) { + let node = nodes.shift(); + results.push(node); + let children = cache.getChildren(node); + nodes = nodes.concat([...children]); + } + return results; + }; + + let getLocalItems = function () { + let deferred = Promise.defer(); + + let folders = getCachedFolderIds(folderCache, folderCache.getChildren(rootId)); + + function getFolders(ids) { + let types = [ + PlacesUtils.bookmarks.TYPE_FOLDER, + ]; + return PlacesWrapper.getItemsById(ids, types); + } + + function getContents(parents) { + parents.push(rootId); + let types = [ + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + ]; + return PlacesWrapper.getItemsByParentId(parents, types) + } + + function getParentGuids(results) { + results = Array.prototype.concat.apply([], results); + let promises = []; + results.map(function (result) { + let promise = PlacesWrapper.localIdToGuid(result.parent).then( + function (guidResult) { + result.parent = guidResult; + return Promise.resolve(result); + }, + Promise.reject.bind(Promise) + ); + promises.push(promise); + }); + return Promise.all(promises); + } + + function getAnnos(results) { + results = Array.prototype.concat.apply([], results); + let promises = []; + results.map(function (result) { + let promise = PlacesWrapper.getItemAnnotationsForLocalId(result.id).then( + function (annos) { + result.annos = annos; + return Promise.resolve(result); + }, + Promise.reject.bind(Promise) + ); + promises.push(promise); + }); + return Promise.all(promises); + } + + let promises = [ + getFolders(folders), + getContents(folders), + ]; + + Promise.all(promises) + .then(getParentGuids) + .then(getAnnos) + .then(function (results) { + results = results.map((result) => new Record(result)); + deferred.resolve(results); + }, + deferred.reject); + + return deferred.promise; + }; + + let getLocalItemsById = function (guids) { + let deferred = Promise.defer(); + + let types = [ + PlacesUtils.bookmarks.TYPE_BOOKMARK, + PlacesUtils.bookmarks.TYPE_FOLDER, + PlacesUtils.bookmarks.TYPE_SEPARATOR, + PlacesUtils.bookmarks.TYPE_DYNAMIC_CONTAINER, + ]; + + function getParentGuids(results) { + let promises = []; + results.map(function (result) { + let promise = PlacesWrapper.localIdToGuid(result.parent).then( + function (guidResult) { + result.parent = guidResult; + return Promise.resolve(result); + }, + Promise.reject.bind(Promise) + ); + promises.push(promise); + }); + return Promise.all(promises); + } + + PlacesWrapper.getItemsByGuid(guids, types) + .then(getParentGuids) + .then(function (results) { + results = results.map((result) => new Record(result)); + deferred.resolve(results); + }, + deferred.reject); + + return deferred.promise; + }; + + let _createItem = function (item) { + let deferred = Promise.defer(); + + function getFolderId() { + if (item.parent) { + return PlacesWrapper.guidToLocalId(item.parent); + } + return Promise.resolve(rootId); + } + + function create(folderId) { + let deferred = Promise.defer(); + + if (!folderId) { + folderId = rootId; + } + let index = item.hasOwnProperty("index") ? item.index : PlacesUtils.bookmarks.DEFAULT_INDEX; + + function complete(localId) { + folderCache.insert(localId, folderId); + deferred.resolve(localId); + } + + switch (item.type) { + case CS_BOOKMARK: + case CS_QUERY: + PlacesWrapper.insertBookmark(folderId, item.uri, index, item.title, item.id) + .then(complete, deferred.reject); + break; + case CS_FOLDER: + PlacesWrapper.createFolder(folderId, item.title, index, item.id) + .then(complete, deferred.reject); + break; + case CS_SEPARATOR: + PlacesWrapper.insertSeparator(folderId, index, item.id) + .then(complete, deferred.reject); + break; + case CS_LIVEMARK: + let livemark = { + title: item.title, + parentId: folderId, + index: item.index, + feedURI: item.feed, + siteURI: item.site, + guid: item.id, + }; + PlacesUtils.livemarks.addLivemark(livemark) + .then(complete, deferred.reject); + break; + default: + deferred.reject("invalid item type: " + item.type); + } + + return deferred.promise; + } + + getFolderId().then(create) + .then(deferred.resolve, deferred.reject); + + return deferred.promise; + }; + + let _deleteItem = function (item) { + let deferred = Promise.defer(); + + PlacesWrapper.guidToLocalId(item.id).then( + function (localId) { + folderCache.remove(localId); + return PlacesWrapper.removeItem(localId); + } + ).then(deferred.resolve, deferred.reject); + + return deferred.promise; + }; + + let _updateItem = function (item) { + let deferred = Promise.defer(); + + PlacesWrapper.guidToLocalId(item.id).then( + function (localId) { + let promises = []; + + if (item.hasOwnProperty("dateAdded")) { + promises.push(PlacesWrapper.setItemDateAdded(localId, item.dateAdded)); + } + + if (item.hasOwnProperty("lastModified")) { + promises.push(PlacesWrapper.setItemLastModified(localId, item.lastModified)); + } + + if ((CS_BOOKMARK | CS_FOLDER) & item.type && item.hasOwnProperty("title")) { + promises.push(PlacesWrapper.setItemTitle(localId, item.title)); + } + + if (CS_BOOKMARK & item.type && item.hasOwnProperty("uri")) { + promises.push(PlacesWrapper.changeBookmarkURI(localId, item.uri)); + } + + if (item.hasOwnProperty("parent")) { + let deferred = Promise.defer(); + PlacesWrapper.guidToLocalId(item.parent) + .then( + function (parent) { + let index = item.hasOwnProperty("index") ? item.index : PlacesUtils.bookmarks.DEFAULT_INDEX; + if (CS_FOLDER & item.type) { + folderCache.setParent(localId, parent); + } + return PlacesWrapper.moveItem(localId, parent, index); + } + ) + .then(deferred.resolve, deferred.reject); + promises.push(deferred.promise); + } + + if (item.hasOwnProperty("index") && !item.hasOwnProperty("parent")) { + promises.push(Task.spawn(function* () { + let localItem = (yield getLocalItemsById([item.id]))[0]; + let parent = yield PlacesWrapper.guidToLocalId(localItem.parent); + let index = item.index; + if (CS_FOLDER & item.type) { + folderCache.setParent(localId, parent); + } + yield PlacesWrapper.moveItem(localId, parent, index); + })); + } + + Promise.all(promises) + .then(deferred.resolve, deferred.reject); + } + ); + + return deferred.promise; + }; + + let mergeRemoteItems = function (items) { + ignoreAll = true; + let deferred = Promise.defer(); + + let newFolders = {}; + let newItems = []; + let updatedItems = []; + let deletedItems = []; + + let sortItems = function () { + let promises = []; + + let exists = function (item) { + let existsDeferred = Promise.defer(); + if (!item.id) { + Object.defineProperty(item, "__exists__", { + value: false, + enumerable: false + }); + existsDeferred.resolve(item); + } else { + PlacesWrapper.guidToLocalId(item.id).then( + function (localId) { + Object.defineProperty(item, "__exists__", { + value: localId ? true : false, + enumerable: false + }); + existsDeferred.resolve(item); + }, + existsDeferred.reject + ); + } + return existsDeferred.promise; + } + + let handleSortedItem = function (item) { + if (!item.__exists__ && !item.deleted) { + if (CS_FOLDER == item.type) { + newFolders[item.id] = item; + item._children = []; + } else { + newItems.push(item); + } + } else if (item.__exists__ && item.deleted) { + deletedItems.push(item); + } else if (item.__exists__) { + updatedItems.push(item); + } + } + + for (let item of items) { + if (!item || 'object' !== typeof(item)) { + continue; + } + + let promise = exists(item).then(handleSortedItem, Promise.reject.bind(Promise)); + promises.push(promise); + } + + return Promise.all(promises); + } + + let processNewFolders = function () { + let newFolderGuids = Object.keys(newFolders); + let newFolderRoots = []; + + for (let guid of newFolderGuids) { + let item = newFolders[guid]; + if (item.parent && newFolderGuids.indexOf(item.parent) >= 0) { + let parent = newFolders[item.parent]; + parent._children.push(item.id); + } else { + newFolderRoots.push(guid); + } + }; + + let promises = []; + for (let guid of newFolderRoots) { + let root = newFolders[guid]; + let promise = Promise.resolve(); + promise = promise.then( + function () { + return _createItem(root); + }, + Promise.reject.bind(Promise) + ); + let items = [].concat(root._children); + + while (items.length) { + let item = newFolders[items.shift()]; + items = items.concat(item._children); + promise = promise.then( + function () { + return _createItem(item); + }, + Promise.reject.bind(Promise) + ); + } + promises.push(promise); + } + + return Promise.all(promises); + } + + let processItems = function () { + let promises = []; + + for (let item of newItems) { + promises.push(_createItem(item)); + } + + for (let item of updatedItems) { + promises.push(_updateItem(item)); + } + + for (let item of deletedItems) { + _deleteItem(item); + } + + return Promise.all(promises); + } + + sortItems().then(processNewFolders) + .then(processItems) + .then(function () { + ignoreAll = false; + deferred.resolve(items); + }, + function (err) { + ignoreAll = false; + deferred.reject(err); + }); + + return deferred.promise; + }; + + let ignore = function (id, parent) { + if (ignoreAll) { + return true; + } + + if (rootId == parent || folderCache.has(parent)) { + return false; + } + + return true; + }; + + let handleItemAdded = function (id, parent, index, type, uri, title, dateAdded, guid, parentGuid) { + let deferred = Promise.defer(); + + if (PlacesUtils.bookmarks.TYPE_FOLDER == type) { + folderCache.insert(id, parent); + } + + eventSource.emit("add", guid); + deferred.resolve(); + + return deferred.promise; + }; + + let handleItemRemoved = function (id, parent, index, type, uri, guid, parentGuid) { + let deferred = Promise.defer(); + + if (PlacesUtils.bookmarks.TYPE_FOLDER == type) { + folderCache.remove(id); + } + + eventSource.emit("remove", guid); + deferred.resolve(); + + return deferred.promise; + }; + + let handleItemChanged = function (id, property, isAnnotation, newValue, lastModified, type, parent, guid, parentGuid) { + let deferred = Promise.defer(); + + eventSource.emit('change', guid); + deferred.resolve(); + + return deferred.promise; + }; + + let handleItemMoved = function (id, oldParent, oldIndex, newParent, newIndex, type, guid, oldParentGuid, newParentGuid) { + let deferred = Promise.defer(); + + function complete() { + eventSource.emit('move', guid); + deferred.resolve(); + } + + if (PlacesUtils.bookmarks.TYPE_FOLDER != type) { + complete(); + return deferred.promise; + } + + if (folderCache.has(oldParent) && folderCache.has(newParent)) { + // Folder move inside cloudSync root, so just update parents/children. + folderCache.setParent(id, newParent); + complete(); + } else if (!folderCache.has(oldParent)) { + // Folder moved in from ouside cloudSync root. + PlacesWrapper.updateCachedFolderIds(folderCache, newParent) + .then(complete, complete); + } else if (!folderCache.has(newParent)) { + // Folder moved out from inside cloudSync root. + PlacesWrapper.updateCachedFolderIds(folderCache, oldParent) + .then(complete, complete); + } + + return deferred.promise; + }; + + let observer = { + onBeginBatchUpdate: function () { + }, + + onEndBatchUpdate: function () { + }, + + onItemAdded: function (id, parent, index, type, uri, title, dateAdded, guid, parentGuid) { + if (ignore(id, parent)) { + return; + } + + asyncCallback(this, handleItemAdded, Array.prototype.slice.call(arguments)); + }, + + onItemRemoved: function (id, parent, index, type, uri, guid, parentGuid) { + if (ignore(id, parent)) { + return; + } + + asyncCallback(this, handleItemRemoved, Array.prototype.slice.call(arguments)); + }, + + onItemChanged: function (id, property, isAnnotation, newValue, lastModified, type, parent, guid, parentGuid) { + if (ignore(id, parent)) { + return; + } + + asyncCallback(this, handleItemChanged, Array.prototype.slice.call(arguments)); + }, + + onItemMoved: function (id, oldParent, oldIndex, newParent, newIndex, type, guid, oldParentGuid, newParentGuid) { + if (ignore(id, oldParent) && ignore(id, newParent)) { + return; + } + + asyncCallback(this, handleItemMoved, Array.prototype.slice.call(arguments)); + } + }; + + /* PUBLIC API */ + this.addEventListener = eventSource.addEventListener; + this.removeEventListener = eventSource.removeEventListener; + this.getLocalItems = getLocalItems.bind(this); + this.getLocalItemsById = getLocalItemsById.bind(this); + this.mergeRemoteItems = mergeRemoteItems.bind(this); + + let rootGuid = null; // resolved before becoming ready (below) + this.__defineGetter__("id", function () { + return rootGuid; + }); + this.__defineGetter__("name", function () { + return rootName; + }); + + let deferred = Promise.defer(); + let getGuidForRootFolder = function () { + return PlacesWrapper.localIdToGuid(rootId); + } + PlacesWrapper.updateCachedFolderIds(folderCache, rootId) + .then(getGuidForRootFolder, getGuidForRootFolder) + .then(function (guid) { + rootGuid = guid; + deferred.resolve(this); + }.bind(this), + deferred.reject); + return deferred.promise; +}; + +RootFolder.prototype = { + BOOKMARK: CS_BOOKMARK, + FOLDER: CS_FOLDER, + SEPARATOR: CS_SEPARATOR, + QUERY: CS_QUERY, + LIVEMARK: CS_LIVEMARK, +}; |