summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/Bookmarks.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/Bookmarks.jsm')
-rw-r--r--toolkit/components/places/Bookmarks.jsm1536
1 files changed, 1536 insertions, 0 deletions
diff --git a/toolkit/components/places/Bookmarks.jsm b/toolkit/components/places/Bookmarks.jsm
new file mode 100644
index 000000000..835b4fc62
--- /dev/null
+++ b/toolkit/components/places/Bookmarks.jsm
@@ -0,0 +1,1536 @@
+/* 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 module provides an asynchronous API for managing bookmarks.
+ *
+ * Bookmarks are organized in a tree structure, and include URLs, folders and
+ * separators. Multiple bookmarks for the same URL are allowed.
+ *
+ * Note that if you are handling bookmarks operations in the UI, you should
+ * not use this API directly, but rather use PlacesTransactions.jsm, so that
+ * any operation is undo/redo-able.
+ *
+ * Each bookmark-item is represented by an object having the following
+ * properties:
+ *
+ * - guid (string)
+ * The globally unique identifier of the item.
+ * - parentGuid (string)
+ * The globally unique identifier of the folder containing the item.
+ * This will be an empty string for the Places root folder.
+ * - index (number)
+ * The 0-based position of the item in the parent folder.
+ * - dateAdded (Date)
+ * The time at which the item was added.
+ * - lastModified (Date)
+ * The time at which the item was last modified.
+ * - type (number)
+ * The item's type, either TYPE_BOOKMARK, TYPE_FOLDER or TYPE_SEPARATOR.
+ *
+ * The following properties are only valid for URLs or folders.
+ *
+ * - title (string)
+ * The item's title, if any. Empty titles and null titles are considered
+ * the same, and the property is unset on retrieval in such a case.
+ * Titles longer than DB_TITLE_LENGTH_MAX will be truncated.
+ *
+ * The following properties are only valid for URLs:
+ *
+ * - url (URL, href or nsIURI)
+ * The item's URL. Note that while input objects can contains either
+ * an URL object, an href string, or an nsIURI, output objects will always
+ * contain an URL object.
+ * An URL cannot be longer than DB_URL_LENGTH_MAX, methods will throw if a
+ * longer value is provided.
+ *
+ * Each successful operation notifies through the nsINavBookmarksObserver
+ * interface. To listen to such notifications you must register using
+ * nsINavBookmarksService addObserver and removeObserver methods.
+ * Note that bookmark addition or order changes won't notify onItemMoved for
+ * items that have their indexes changed.
+ * Similarly, lastModified changes not done explicitly (like changing another
+ * property) won't fire an onItemChanged notification for the lastModified
+ * property.
+ * @see nsINavBookmarkObserver
+ */
+
+this.EXPORTED_SYMBOLS = [ "Bookmarks" ];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+ "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
+ "resource://gre/modules/NetUtil.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+ "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
+ "resource://gre/modules/Sqlite.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesSyncUtils",
+ "resource://gre/modules/PlacesSyncUtils.jsm");
+
+const MATCH_ANYWHERE_UNMODIFIED = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED;
+const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK;
+
+var Bookmarks = Object.freeze({
+ /**
+ * Item's type constants.
+ * These should stay consistent with nsINavBookmarksService.idl
+ */
+ TYPE_BOOKMARK: 1,
+ TYPE_FOLDER: 2,
+ TYPE_SEPARATOR: 3,
+
+ /**
+ * Default index used to append a bookmark-item at the end of a folder.
+ * This should stay consistent with nsINavBookmarksService.idl
+ */
+ DEFAULT_INDEX: -1,
+
+ /**
+ * Bookmark change source constants, passed as optional properties and
+ * forwarded to observers. See nsINavBookmarksService.idl for an explanation.
+ */
+ SOURCES: {
+ DEFAULT: Ci.nsINavBookmarksService.SOURCE_DEFAULT,
+ SYNC: Ci.nsINavBookmarksService.SOURCE_SYNC,
+ IMPORT: Ci.nsINavBookmarksService.SOURCE_IMPORT,
+ IMPORT_REPLACE: Ci.nsINavBookmarksService.SOURCE_IMPORT_REPLACE,
+ },
+
+ /**
+ * Special GUIDs associated with bookmark roots.
+ * It's guaranteed that the roots will always have these guids.
+ */
+
+ rootGuid: "root________",
+ menuGuid: "menu________",
+ toolbarGuid: "toolbar_____",
+ unfiledGuid: "unfiled_____",
+ mobileGuid: "mobile______",
+
+ // With bug 424160, tags will stop being bookmarks, thus this root will
+ // be removed. Do not rely on this, rather use the tagging service API.
+ tagsGuid: "tags________",
+
+ /**
+ * Inserts a bookmark-item into the bookmarks tree.
+ *
+ * For creating a bookmark, the following set of properties is required:
+ * - type
+ * - parentGuid
+ * - url, only for bookmarked URLs
+ *
+ * If an index is not specified, it defaults to appending.
+ * It's also possible to pass a non-existent GUID to force creation of an
+ * item with the given GUID, but unless you have a very sound reason, such as
+ * an undo manager implementation or synchronization, don't do that.
+ *
+ * Note that any known properties that don't apply to the specific item type
+ * cause an exception.
+ *
+ * @param info
+ * object representing a bookmark-item.
+ *
+ * @return {Promise} resolved when the creation is complete.
+ * @resolves to an object representing the created bookmark.
+ * @rejects if it's not possible to create the requested bookmark.
+ * @throws if the arguments are invalid.
+ */
+ insert(info) {
+ // Ensure to use the same date for dateAdded and lastModified, even if
+ // dateAdded may be imposed by the caller.
+ let time = (info && info.dateAdded) || new Date();
+ let insertInfo = validateBookmarkObject(info,
+ { type: { defaultValue: this.TYPE_BOOKMARK }
+ , index: { defaultValue: this.DEFAULT_INDEX }
+ , url: { requiredIf: b => b.type == this.TYPE_BOOKMARK
+ , validIf: b => b.type == this.TYPE_BOOKMARK }
+ , parentGuid: { required: true }
+ , title: { validIf: b => [ this.TYPE_BOOKMARK
+ , this.TYPE_FOLDER ].includes(b.type) }
+ , dateAdded: { defaultValue: time
+ , validIf: b => !b.lastModified ||
+ b.dateAdded <= b.lastModified }
+ , lastModified: { defaultValue: time,
+ validIf: b => (!b.dateAdded && b.lastModified >= time) ||
+ (b.dateAdded && b.lastModified >= b.dateAdded) }
+ , source: { defaultValue: this.SOURCES.DEFAULT }
+ });
+
+ return Task.spawn(function* () {
+ // Ensure the parent exists.
+ let parent = yield fetchBookmark({ guid: insertInfo.parentGuid });
+ if (!parent)
+ throw new Error("parentGuid must be valid");
+
+ // Set index in the appending case.
+ if (insertInfo.index == this.DEFAULT_INDEX ||
+ insertInfo.index > parent._childCount) {
+ insertInfo.index = parent._childCount;
+ }
+
+ let item = yield insertBookmark(insertInfo, parent);
+
+ // Notify onItemAdded to listeners.
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // We need the itemId to notify, though once the switch to guids is
+ // complete we may stop using it.
+ let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+ let itemId = yield PlacesUtils.promiseItemId(item.guid);
+
+ // Pass tagging information for the observers to skip over these notifications when needed.
+ let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+ let isTagsFolder = parent._id == PlacesUtils.tagsFolderId;
+ notify(observers, "onItemAdded", [ itemId, parent._id, item.index,
+ item.type, uri, item.title || null,
+ PlacesUtils.toPRTime(item.dateAdded), item.guid,
+ item.parentGuid, item.source ],
+ { isTagging: isTagging || isTagsFolder });
+
+ // If it's a tag, notify OnItemChanged to all bookmarks for this URL.
+ if (isTagging) {
+ for (let entry of (yield fetchBookmarksByURL(item))) {
+ notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ PlacesUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "", item.source ]);
+ }
+ }
+
+ // Remove non-enumerable properties.
+ delete item.source;
+ return Object.assign({}, item);
+ }.bind(this));
+ },
+
+ /**
+ * Updates a bookmark-item.
+ *
+ * Only set the properties which should be changed (undefined properties
+ * won't be taken into account).
+ * Moreover, the item's type or dateAdded cannot be changed, since they are
+ * immutable after creation. Trying to change them will reject.
+ *
+ * Note that any known properties that don't apply to the specific item type
+ * cause an exception.
+ *
+ * @param info
+ * object representing a bookmark-item, as defined above.
+ *
+ * @return {Promise} resolved when the update is complete.
+ * @resolves to an object representing the updated bookmark.
+ * @rejects if it's not possible to update the given bookmark.
+ * @throws if the arguments are invalid.
+ */
+ update(info) {
+ // The info object is first validated here to ensure it's consistent, then
+ // it's compared to the existing item to remove any properties that don't
+ // need to be updated.
+ let updateInfo = validateBookmarkObject(info,
+ { guid: { required: true }
+ , index: { requiredIf: b => b.hasOwnProperty("parentGuid")
+ , validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX }
+ , source: { defaultValue: this.SOURCES.DEFAULT }
+ });
+
+ // There should be at last one more property in addition to guid and source.
+ if (Object.keys(updateInfo).length < 3)
+ throw new Error("Not enough properties to update");
+
+ return Task.spawn(function* () {
+ // Ensure the item exists.
+ let item = yield fetchBookmark(updateInfo);
+ if (!item)
+ throw new Error("No bookmarks found for the provided GUID");
+ if (updateInfo.hasOwnProperty("type") && updateInfo.type != item.type)
+ throw new Error("The bookmark type cannot be changed");
+ if (updateInfo.hasOwnProperty("dateAdded") &&
+ updateInfo.dateAdded.getTime() != item.dateAdded.getTime())
+ throw new Error("The bookmark dateAdded cannot be changed");
+
+ // Remove any property that will stay the same.
+ removeSameValueProperties(updateInfo, item);
+ // Check if anything should still be updated.
+ if (Object.keys(updateInfo).length < 3) {
+ // Remove non-enumerable properties.
+ return Object.assign({}, item);
+ }
+
+ updateInfo = validateBookmarkObject(updateInfo,
+ { url: { validIf: () => item.type == this.TYPE_BOOKMARK }
+ , title: { validIf: () => [ this.TYPE_BOOKMARK
+ , this.TYPE_FOLDER ].includes(item.type) }
+ , lastModified: { defaultValue: new Date()
+ , validIf: b => b.lastModified >= item.dateAdded }
+ });
+
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: update",
+ Task.async(function*(db) {
+ let parent;
+ if (updateInfo.hasOwnProperty("parentGuid")) {
+ if (item.type == this.TYPE_FOLDER) {
+ // Make sure we are not moving a folder into itself or one of its
+ // descendants.
+ let rows = yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ VALUES(:id)
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ WHERE type = :type
+ )
+ SELECT guid FROM moz_bookmarks
+ WHERE id IN descendants
+ `, { id: item._id, type: this.TYPE_FOLDER });
+ if (rows.map(r => r.getResultByName("guid")).includes(updateInfo.parentGuid))
+ throw new Error("Cannot insert a folder into itself or one of its descendants");
+ }
+
+ parent = yield fetchBookmark({ guid: updateInfo.parentGuid });
+ if (!parent)
+ throw new Error("No bookmarks found for the provided parentGuid");
+ }
+
+ if (updateInfo.hasOwnProperty("index")) {
+ // If at this point we don't have a parent yet, we are moving into
+ // the same container. Thus we know it exists.
+ if (!parent)
+ parent = yield fetchBookmark({ guid: item.parentGuid });
+
+ if (updateInfo.index >= parent._childCount ||
+ updateInfo.index == this.DEFAULT_INDEX) {
+ updateInfo.index = parent._childCount;
+
+ // Fix the index when moving within the same container.
+ if (parent.guid == item.parentGuid)
+ updateInfo.index--;
+ }
+ }
+
+ let updatedItem = yield updateBookmark(updateInfo, item, parent);
+
+ if (item.type == this.TYPE_BOOKMARK &&
+ item.url.href != updatedItem.url.href) {
+ // ...though we don't wait for the calculation.
+ updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ updateFrecency(db, [updatedItem.url]).then(null, Cu.reportError);
+ }
+
+ // Notify onItemChanged to listeners.
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // For lastModified, we only care about the original input, since we
+ // should not notify implciit lastModified changes.
+ if (info.hasOwnProperty("lastModified") &&
+ updateInfo.hasOwnProperty("lastModified") &&
+ item.lastModified != updatedItem.lastModified) {
+ notify(observers, "onItemChanged", [ updatedItem._id, "lastModified",
+ false,
+ `${PlacesUtils.toPRTime(updatedItem.lastModified)}`,
+ PlacesUtils.toPRTime(updatedItem.lastModified),
+ updatedItem.type,
+ updatedItem._parentId,
+ updatedItem.guid,
+ updatedItem.parentGuid, "",
+ updatedItem.source ]);
+ }
+ if (updateInfo.hasOwnProperty("title")) {
+ notify(observers, "onItemChanged", [ updatedItem._id, "title",
+ false, updatedItem.title,
+ PlacesUtils.toPRTime(updatedItem.lastModified),
+ updatedItem.type,
+ updatedItem._parentId,
+ updatedItem.guid,
+ updatedItem.parentGuid, "",
+ updatedItem.source ]);
+ }
+ if (updateInfo.hasOwnProperty("url")) {
+ notify(observers, "onItemChanged", [ updatedItem._id, "uri",
+ false, updatedItem.url.href,
+ PlacesUtils.toPRTime(updatedItem.lastModified),
+ updatedItem.type,
+ updatedItem._parentId,
+ updatedItem.guid,
+ updatedItem.parentGuid,
+ item.url.href,
+ updatedItem.source ]);
+ }
+ // If the item was moved, notify onItemMoved.
+ if (item.parentGuid != updatedItem.parentGuid ||
+ item.index != updatedItem.index) {
+ notify(observers, "onItemMoved", [ updatedItem._id, item._parentId,
+ item.index, updatedItem._parentId,
+ updatedItem.index, updatedItem.type,
+ updatedItem.guid, item.parentGuid,
+ updatedItem.parentGuid,
+ updatedItem.source ]);
+ }
+
+ // Remove non-enumerable properties.
+ delete updatedItem.source;
+ return Object.assign({}, updatedItem);
+ }.bind(this)));
+ }.bind(this));
+ },
+
+ /**
+ * Removes a bookmark-item.
+ *
+ * @param guidOrInfo
+ * The globally unique identifier of the item to remove, or an
+ * object representing it, as defined above.
+ * @param {Object} [options={}]
+ * Additional options that can be passed to the function.
+ * Currently supports the following properties:
+ * - preventRemovalOfNonEmptyFolders: Causes an exception to be
+ * thrown when attempting to remove a folder that is not empty.
+ * - source: The change source, forwarded to all bookmark observers.
+ * Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
+ *
+ * @return {Promise} resolved when the removal is complete.
+ * @resolves to an object representing the removed bookmark.
+ * @rejects if the provided guid doesn't match any existing bookmark.
+ * @throws if the arguments are invalid.
+ */
+ remove(guidOrInfo, options={}) {
+ let info = guidOrInfo;
+ if (!info)
+ throw new Error("Input should be a valid object");
+ if (typeof(guidOrInfo) != "object")
+ info = { guid: guidOrInfo };
+
+ // Disallow removing the root folders.
+ if ([this.rootGuid, this.menuGuid, this.toolbarGuid, this.unfiledGuid,
+ this.tagsGuid, this.mobileGuid].includes(info.guid)) {
+ throw new Error("It's not possible to remove Places root folders.");
+ }
+
+ // Even if we ignore any other unneeded property, we still validate any
+ // known property to reduce likelihood of hidden bugs.
+ let removeInfo = validateBookmarkObject(info);
+
+ return Task.spawn(function* () {
+ let item = yield fetchBookmark(removeInfo);
+ if (!item)
+ throw new Error("No bookmarks found for the provided GUID.");
+
+ item = yield removeBookmark(item, options);
+
+ // Notify onItemRemoved to listeners.
+ let { source = Bookmarks.SOURCES.DEFAULT } = options;
+ let observers = PlacesUtils.bookmarks.getObservers();
+ let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ notify(observers, "onItemRemoved", [ item._id, item._parentId, item.index,
+ item.type, uri, item.guid,
+ item.parentGuid,
+ source ],
+ { isTagging: isUntagging });
+
+ if (isUntagging) {
+ for (let entry of (yield fetchBookmarksByURL(item))) {
+ notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ PlacesUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "", source ]);
+ }
+ }
+
+ // Remove non-enumerable properties.
+ return Object.assign({}, item);
+ });
+ },
+
+ /**
+ * Removes ALL bookmarks, resetting the bookmarks storage to an empty tree.
+ *
+ * Note that roots are preserved, only their children will be removed.
+ *
+ * @param {Object} [options={}]
+ * Additional options. Currently supports the following properties:
+ * - source: The change source, forwarded to all bookmark observers.
+ * Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
+ *
+ * @return {Promise} resolved when the removal is complete.
+ * @resolves once the removal is complete.
+ */
+ eraseEverything: function(options={}) {
+ const folderGuids = [this.toolbarGuid, this.menuGuid, this.unfiledGuid,
+ this.mobileGuid];
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: eraseEverything",
+ db => db.executeTransaction(function* () {
+ yield removeFoldersContents(db, folderGuids, options);
+ const time = PlacesUtils.toPRTime(new Date());
+ for (let folderGuid of folderGuids) {
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET lastModified = :time
+ WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid )
+ `, { folderGuid, time });
+ }
+ })
+ );
+ },
+
+ /**
+ * Returns a list of recently bookmarked items.
+ *
+ * @param {integer} numberOfItems
+ * The maximum number of bookmark items to return.
+ *
+ * @return {Promise} resolved when the listing is complete.
+ * @resolves to an array of recent bookmark-items.
+ * @rejects if an error happens while querying.
+ */
+ getRecent(numberOfItems) {
+ if (numberOfItems === undefined) {
+ throw new Error("numberOfItems argument is required");
+ }
+ if (!typeof numberOfItems === 'number' || (numberOfItems % 1) !== 0) {
+ throw new Error("numberOfItems argument must be an integer");
+ }
+ if (numberOfItems <= 0) {
+ throw new Error("numberOfItems argument must be greater than zero");
+ }
+
+ return Task.spawn(function* () {
+ return yield fetchRecentBookmarks(numberOfItems);
+ });
+ },
+
+ /**
+ * Fetches information about a bookmark-item.
+ *
+ * REMARK: any successful call to this method resolves to a single
+ * bookmark-item (or null), even when multiple bookmarks may exist
+ * (e.g. fetching by url). If you wish to retrieve all of the
+ * bookmarks for a given match, use the callback instead.
+ *
+ * Input can be either a guid or an object with one, and only one, of these
+ * filtering properties set:
+ * - guid
+ * retrieves the item with the specified guid.
+ * - parentGuid and index
+ * retrieves the item by its position.
+ * - url
+ * retrieves the most recent bookmark having the given URL.
+ * To retrieve ALL of the bookmarks for that URL, you must pass in an
+ * onResult callback, that will be invoked once for each found bookmark.
+ *
+ * @param guidOrInfo
+ * The globally unique identifier of the item to fetch, or an
+ * object representing it, as defined above.
+ * @param onResult [optional]
+ * Callback invoked for each found bookmark.
+ *
+ * @return {Promise} resolved when the fetch is complete.
+ * @resolves to an object representing the found item, as described above, or
+ * an array of such objects. if no item is found, the returned
+ * promise is resolved to null.
+ * @rejects if an error happens while fetching.
+ * @throws if the arguments are invalid.
+ *
+ * @note Any unknown property in the info object is ignored. Known properties
+ * may be overwritten.
+ */
+ fetch(guidOrInfo, onResult=null) {
+ if (onResult && typeof onResult != "function")
+ throw new Error("onResult callback must be a valid function");
+ let info = guidOrInfo;
+ if (!info)
+ throw new Error("Input should be a valid object");
+ if (typeof(info) != "object")
+ info = { guid: guidOrInfo };
+
+ // Only one condition at a time can be provided.
+ let conditionsCount = [
+ v => v.hasOwnProperty("guid"),
+ v => v.hasOwnProperty("parentGuid") && v.hasOwnProperty("index"),
+ v => v.hasOwnProperty("url")
+ ].reduce((old, fn) => old + fn(info)|0, 0);
+ if (conditionsCount != 1)
+ throw new Error(`Unexpected number of conditions provided: ${conditionsCount}`);
+
+ // Even if we ignore any other unneeded property, we still validate any
+ // known property to reduce likelihood of hidden bugs.
+ let fetchInfo = validateBookmarkObject(info,
+ { parentGuid: { requiredIf: b => b.hasOwnProperty("index") }
+ , index: { requiredIf: b => b.hasOwnProperty("parentGuid")
+ , validIf: b => typeof(b.index) == "number" &&
+ b.index >= 0 || b.index == this.DEFAULT_INDEX }
+ });
+
+ return Task.spawn(function* () {
+ let results;
+ if (fetchInfo.hasOwnProperty("guid"))
+ results = yield fetchBookmark(fetchInfo);
+ else if (fetchInfo.hasOwnProperty("parentGuid") && fetchInfo.hasOwnProperty("index"))
+ results = yield fetchBookmarkByPosition(fetchInfo);
+ else if (fetchInfo.hasOwnProperty("url"))
+ results = yield fetchBookmarksByURL(fetchInfo);
+
+ if (!results)
+ return null;
+
+ if (!Array.isArray(results))
+ results = [results];
+ // Remove non-enumerable properties.
+ results = results.map(r => Object.assign({}, r));
+
+ // Ideally this should handle an incremental behavior and thus be invoked
+ // while we fetch. Though, the likelihood of 2 or more bookmarks for the
+ // same match is very low, so it's not worth the added code complication.
+ if (onResult) {
+ for (let result of results) {
+ try {
+ onResult(result);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ }
+
+ return results[0];
+ });
+ },
+
+ /**
+ * Retrieves an object representation of a bookmark-item, along with all of
+ * its descendants, if any.
+ *
+ * Each node in the tree is an object that extends the item representation
+ * described above with some additional properties:
+ *
+ * - [deprecated] id (number)
+ * the item's id. Defined only if aOptions.includeItemIds is set.
+ * - annos (array)
+ * the item's annotations. This is not set if there are no annotations
+ * set for the item.
+ *
+ * The root object of the tree also has the following properties set:
+ * - itemsCount (number, not enumerable)
+ * the number of items, including the root item itself, which are
+ * represented in the resolved object.
+ *
+ * Bookmarked URLs may also have the following properties:
+ * - tags (string)
+ * csv string of the bookmark's tags, if any.
+ * - charset (string)
+ * the last known charset of the bookmark, if any.
+ * - iconurl (URL)
+ * the bookmark's favicon URL, if any.
+ *
+ * Folders may also have the following properties:
+ * - children (array)
+ * the folder's children information, each of them having the same set of
+ * properties as above.
+ *
+ * @param [optional] guid
+ * the topmost item to be queried. If it's not passed, the Places
+ * root folder is queried: that is, you get a representation of the
+ * entire bookmarks hierarchy.
+ * @param [optional] options
+ * Options for customizing the query behavior, in the form of an
+ * object with any of the following properties:
+ * - excludeItemsCallback: a function for excluding items, along with
+ * their descendants. Given an item object (that has everything set
+ * apart its potential children data), it should return true if the
+ * item should be excluded. Once an item is excluded, the function
+ * isn't called for any of its descendants. This isn't called for
+ * the root item.
+ * WARNING: since the function may be called for each item, using
+ * this option can slow down the process significantly if the
+ * callback does anything that's not relatively trivial. It is
+ * highly recommended to avoid any synchronous I/O or DB queries.
+ * - includeItemIds: opt-in to include the deprecated id property.
+ * Use it if you must. It'll be removed once the switch to guids is
+ * complete.
+ *
+ * @return {Promise} resolved when the fetch is complete.
+ * @resolves to an object that represents either a single item or a
+ * bookmarks tree. if guid points to a non-existent item, the
+ * returned promise is resolved to null.
+ * @rejects if an error happens while fetching.
+ * @throws if the arguments are invalid.
+ */
+ // TODO must implement these methods yet:
+ // PlacesUtils.promiseBookmarksTree()
+ fetchTree(guid = "", options = {}) {
+ throw new Error("Not yet implemented");
+ },
+
+ /**
+ * Reorders contents of a folder based on a provided array of GUIDs.
+ *
+ * @param parentGuid
+ * The globally unique identifier of the folder whose contents should
+ * be reordered.
+ * @param orderedChildrenGuids
+ * Ordered array of the children's GUIDs. If this list contains
+ * non-existing entries they will be ignored. If the list is
+ * incomplete, missing entries will be appended.
+ * @param {Object} [options={}]
+ * Additional options. Currently supports the following properties:
+ * - source: The change source, forwarded to all bookmark observers.
+ * Defaults to nsINavBookmarksService::SOURCE_DEFAULT.
+ *
+ * @return {Promise} resolved when reordering is complete.
+ * @rejects if an error happens while reordering.
+ * @throws if the arguments are invalid.
+ */
+ reorder(parentGuid, orderedChildrenGuids, options={}) {
+ let info = { guid: parentGuid, source: this.SOURCES.DEFAULT };
+ info = validateBookmarkObject(info, { guid: { required: true } });
+
+ if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length)
+ throw new Error("Must provide a sorted array of children GUIDs.");
+ try {
+ orderedChildrenGuids.forEach(PlacesUtils.BOOKMARK_VALIDATORS.guid);
+ } catch (ex) {
+ throw new Error("Invalid GUID found in the sorted children array.");
+ }
+
+ return Task.spawn(function* () {
+ let parent = yield fetchBookmark(info);
+ if (!parent || parent.type != this.TYPE_FOLDER)
+ throw new Error("No folder found for the provided GUID.");
+
+ let sortedChildren = yield reorderChildren(parent, orderedChildrenGuids);
+
+ let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
+ let observers = PlacesUtils.bookmarks.getObservers();
+ // Note that child.index is the old index.
+ for (let i = 0; i < sortedChildren.length; ++i) {
+ let child = sortedChildren[i];
+ notify(observers, "onItemMoved", [ child._id, child._parentId,
+ child.index, child._parentId,
+ i, child.type,
+ child.guid, child.parentGuid,
+ child.parentGuid,
+ source ]);
+ }
+ }.bind(this));
+ },
+
+ /**
+ * Searches a list of bookmark-items by a search term, url or title.
+ *
+ * IMPORTANT:
+ * This is intended as an interim API for the web-extensions implementation.
+ * It will be removed as soon as we have a new querying API.
+ *
+ * If you just want to search bookmarks by URL, use .fetch() instead.
+ *
+ * @param query
+ * Either a string to use as search term, or an object
+ * containing any of these keys: query, title or url with the
+ * corresponding string to match as value.
+ * The url property can be either a string or an nsIURI.
+ *
+ * @return {Promise} resolved when the search is complete.
+ * @resolves to an array of found bookmark-items.
+ * @rejects if an error happens while searching.
+ * @throws if the arguments are invalid.
+ *
+ * @note Any unknown property in the query object is ignored.
+ * Known properties may be overwritten.
+ */
+ search(query) {
+ if (!query) {
+ throw new Error("Query object is required");
+ }
+ if (typeof query === "string") {
+ query = { query: query };
+ }
+ if (typeof query !== "object") {
+ throw new Error("Query must be an object or a string");
+ }
+ if (query.query && typeof query.query !== "string") {
+ throw new Error("Query option must be a string");
+ }
+ if (query.title && typeof query.title !== "string") {
+ throw new Error("Title option must be a string");
+ }
+
+ if (query.url) {
+ if (typeof query.url === "string" || (query.url instanceof URL)) {
+ query.url = new URL(query.url).href;
+ } else if (query.url instanceof Ci.nsIURI) {
+ query.url = query.url.spec;
+ } else {
+ throw new Error("Url option must be a string or a URL object");
+ }
+ }
+
+ return Task.spawn(function* () {
+ let results = yield queryBookmarks(query);
+
+ return results;
+ });
+ },
+});
+
+// Globals.
+
+/**
+ * Sends a bookmarks notification through the given observers.
+ *
+ * @param observers
+ * array of nsINavBookmarkObserver objects.
+ * @param notification
+ * the notification name.
+ * @param args
+ * array of arguments to pass to the notification.
+ * @param information
+ * Information about the notification, so we can filter based
+ * based on the observer's preferences.
+ */
+function notify(observers, notification, args, information = {}) {
+ for (let observer of observers) {
+ if (information.isTagging && observer.skipTags) {
+ continue;
+ }
+
+ if (information.isDescendantRemoval && observer.skipDescendantsOnItemRemoval) {
+ continue;
+ }
+
+ try {
+ observer[notification](...args);
+ } catch (ex) {}
+ }
+}
+
+// Update implementation.
+
+function updateBookmark(info, item, newParent) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ Task.async(function*(db) {
+
+ let tuples = new Map();
+ if (info.hasOwnProperty("lastModified"))
+ tuples.set("lastModified", { value: PlacesUtils.toPRTime(info.lastModified) });
+ if (info.hasOwnProperty("title"))
+ tuples.set("title", { value: info.title });
+
+ yield db.executeTransaction(function* () {
+ if (info.hasOwnProperty("url")) {
+ // Ensure a page exists in moz_places for this URL.
+ yield maybeInsertPlace(db, info.url);
+ // Update tuples for the update query.
+ tuples.set("url", { value: info.url.href
+ , fragment: "fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url)" });
+ }
+
+ if (newParent) {
+ // For simplicity, update the index regardless.
+ let newIndex = info.hasOwnProperty("index") ? info.index : item.index;
+ tuples.set("position", { value: newIndex });
+
+ if (newParent.guid == item.parentGuid) {
+ // Moving inside the original container.
+ // When moving "up", add 1 to each index in the interval.
+ // Otherwise when moving down, we subtract 1.
+ let sign = newIndex < item.index ? +1 : -1;
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :newParentId
+ AND position BETWEEN :lowIndex AND :highIndex
+ `, { sign: sign, newParentId: newParent._id,
+ lowIndex: Math.min(item.index, newIndex),
+ highIndex: Math.max(item.index, newIndex) });
+ } else {
+ // Moving across different containers.
+ tuples.set("parent", { value: newParent._id} );
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :oldParentId
+ AND position >= :oldIndex
+ `, { sign: -1, oldParentId: item._parentId, oldIndex: item.index });
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + :sign
+ WHERE parent = :newParentId
+ AND position >= :newIndex
+ `, { sign: +1, newParentId: newParent._id, newIndex: newIndex });
+
+ yield setAncestorsLastModified(db, item.parentGuid, info.lastModified);
+ }
+ yield setAncestorsLastModified(db, newParent.guid, info.lastModified);
+ }
+
+ yield db.executeCached(
+ `UPDATE moz_bookmarks
+ SET ${Array.from(tuples.keys()).map(v => tuples.get(v).fragment || `${v} = :${v}`).join(", ")}
+ WHERE guid = :guid
+ `, Object.assign({ guid: info.guid },
+ [...tuples.entries()].reduce((p, c) => { p[c[0]] = c[1].value; return p; }, {})));
+ });
+
+ // If the parent changed, update related non-enumerable properties.
+ let additionalParentInfo = {};
+ if (newParent) {
+ Object.defineProperty(additionalParentInfo, "_parentId",
+ { value: newParent._id, enumerable: false });
+ Object.defineProperty(additionalParentInfo, "_grandParentId",
+ { value: newParent._parentId, enumerable: false });
+ }
+
+ let updatedItem = mergeIntoNewObject(item, info, additionalParentInfo);
+
+ // Don't return an empty title to the caller.
+ if (updatedItem.hasOwnProperty("title") && updatedItem.title === null)
+ delete updatedItem.title;
+
+ return updatedItem;
+ }));
+}
+
+// Insert implementation.
+
+function insertBookmark(item, parent) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: insertBookmark",
+ Task.async(function*(db) {
+
+ // If a guid was not provided, generate one, so we won't need to fetch the
+ // bookmark just after having created it.
+ if (!item.hasOwnProperty("guid"))
+ item.guid = (yield db.executeCached("SELECT GENERATE_GUID() AS guid"))[0].getResultByName("guid");
+
+ yield db.executeTransaction(function* transaction() {
+ if (item.type == Bookmarks.TYPE_BOOKMARK) {
+ // Ensure a page exists in moz_places for this URL.
+ // The IGNORE conflict can trigger on `guid`.
+ yield maybeInsertPlace(db, item.url);
+ }
+
+ // Adjust indices.
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position + 1
+ WHERE parent = :parent
+ AND position >= :index
+ `, { parent: parent._id, index: item.index });
+
+ // Insert the bookmark into the database.
+ yield db.executeCached(
+ `INSERT INTO moz_bookmarks (fk, type, parent, position, title,
+ dateAdded, lastModified, guid)
+ VALUES ((SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url), :type, :parent,
+ :index, :title, :date_added, :last_modified, :guid)
+ `, { url: item.hasOwnProperty("url") ? item.url.href : "nonexistent",
+ type: item.type, parent: parent._id, index: item.index,
+ title: item.title, date_added: PlacesUtils.toPRTime(item.dateAdded),
+ last_modified: PlacesUtils.toPRTime(item.lastModified), guid: item.guid });
+
+ yield setAncestorsLastModified(db, item.parentGuid, item.dateAdded);
+ });
+
+ // If not a tag recalculate frecency...
+ let isTagging = parent._parentId == PlacesUtils.tagsFolderId;
+ if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) {
+ // ...though we don't wait for the calculation.
+ updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ }
+
+ // Don't return an empty title to the caller.
+ if (item.hasOwnProperty("title") && item.title === null)
+ delete item.title;
+
+ return item;
+ }));
+}
+
+// Query implementation.
+
+function queryBookmarks(info) {
+ let queryParams = {tags_folder: PlacesUtils.tagsFolderId};
+ // we're searching for bookmarks, so exclude tags
+ let queryString = "WHERE p.parent <> :tags_folder";
+
+ if (info.title) {
+ queryString += " AND b.title = :title";
+ queryParams.title = info.title;
+ }
+
+ if (info.url) {
+ queryString += " AND h.url_hash = hash(:url) AND h.url = :url";
+ queryParams.url = info.url;
+ }
+
+ if (info.query) {
+ queryString += " AND AUTOCOMPLETE_MATCH(:query, h.url, b.title, NULL, NULL, 1, 1, NULL, :matchBehavior, :searchBehavior) ";
+ queryParams.query = info.query;
+ queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED;
+ queryParams.searchBehavior = BEHAVIOR_BOOKMARK;
+ }
+
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: queryBookmarks",
+ Task.async(function*(db) {
+
+ // _id, _childCount, _grandParentId and _parentId fields
+ // are required to be in the result by the converting function
+ // hence setting them to NULL
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title,
+ h.url AS url, b.parent, p.parent,
+ NULL AS _id,
+ NULL AS _childCount,
+ NULL AS _grandParentId,
+ NULL AS _parentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ ${queryString}
+ `, queryParams);
+
+ return rowsToItemsArray(rows);
+ }));
+}
+
+
+// Fetch implementation.
+
+function fetchBookmark(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmark",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE b.guid = :guid
+ `, { guid: info.guid });
+
+ return rows.length ? rowsToItemsArray(rows)[0] : null;
+ }));
+}
+
+function fetchBookmarkByPosition(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarkByPosition",
+ Task.async(function*(db) {
+ let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index;
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.guid = :parentGuid
+ AND b.position = IFNULL(:index, (SELECT count(*) - 1
+ FROM moz_bookmarks
+ WHERE parent = p.id))
+ `, { parentGuid: info.parentGuid, index });
+
+ return rows.length ? rowsToItemsArray(rows)[0] : null;
+ }));
+}
+
+function fetchBookmarksByURL(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByURL",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `/* do not warn (bug no): not worth to add an index */
+ SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE h.url_hash = hash(:url) AND h.url = :url
+ AND _grandParentId <> :tags_folder
+ ORDER BY b.lastModified DESC
+ `, { url: info.url.href,
+ tags_folder: PlacesUtils.tagsFolderId });
+
+ return rows.length ? rowsToItemsArray(rows) : null;
+ }));
+}
+
+function fetchRecentBookmarks(numberOfItems) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchRecentBookmarks",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ NULL AS _id, NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.parent <> :tags_folder
+ ORDER BY b.dateAdded DESC, b.ROWID DESC
+ LIMIT :numberOfItems
+ `, { tags_folder: PlacesUtils.tagsFolderId, numberOfItems });
+
+ return rows.length ? rowsToItemsArray(rows) : [];
+ }));
+}
+
+function fetchBookmarksByParent(info) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: fetchBookmarksByParent",
+ Task.async(function*(db) {
+
+ let rows = yield db.executeCached(
+ `SELECT b.guid, IFNULL(p.guid, "") AS parentGuid, b.position AS 'index',
+ b.dateAdded, b.lastModified, b.type, b.title, h.url AS url,
+ b.id AS _id, b.parent AS _parentId,
+ (SELECT count(*) FROM moz_bookmarks WHERE parent = b.id) AS _childCount,
+ p.parent AS _grandParentId
+ FROM moz_bookmarks b
+ LEFT JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON h.id = b.fk
+ WHERE p.guid = :parentGuid
+ ORDER BY b.position ASC
+ `, { parentGuid: info.parentGuid });
+
+ return rowsToItemsArray(rows);
+ }));
+}
+
+// Remove implementation.
+
+function removeBookmark(item, options) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: removeBookmark",
+ Task.async(function*(db) {
+
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+
+ yield db.executeTransaction(function* transaction() {
+ // If it's a folder, remove its contents first.
+ if (item.type == Bookmarks.TYPE_FOLDER) {
+ if (options.preventRemovalOfNonEmptyFolders && item._childCount > 0) {
+ throw new Error("Cannot remove a non-empty folder.");
+ }
+ yield removeFoldersContents(db, [item.guid], options);
+ }
+
+ // Remove annotations first. If it's a tag, we can avoid paying that cost.
+ if (!isUntagging) {
+ // We don't go through the annotations service for this cause otherwise
+ // we'd get a pointless onItemChanged notification and it would also
+ // set lastModified to an unexpected value.
+ yield removeAnnotationsForItem(db, item._id);
+ }
+
+ // Remove the bookmark from the database.
+ yield db.executeCached(
+ `DELETE FROM moz_bookmarks WHERE guid = :guid`, { guid: item.guid });
+
+ // Fix indices in the parent.
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = position - 1 WHERE
+ parent = :parentId AND position > :index
+ `, { parentId: item._parentId, index: item.index });
+
+ yield setAncestorsLastModified(db, item.parentGuid, new Date());
+ });
+
+ // If not a tag recalculate frecency...
+ if (item.type == Bookmarks.TYPE_BOOKMARK && !isUntagging) {
+ // ...though we don't wait for the calculation.
+ updateFrecency(db, [item.url]).then(null, Cu.reportError);
+ }
+
+ return item;
+ }));
+}
+
+// Reorder implementation.
+
+function reorderChildren(parent, orderedChildrenGuids) {
+ return PlacesUtils.withConnectionWrapper("Bookmarks.jsm: updateBookmark",
+ db => db.executeTransaction(function* () {
+ // Select all of the direct children for the given parent.
+ let children = yield fetchBookmarksByParent({ parentGuid: parent.guid });
+ if (!children.length)
+ return undefined;
+
+ // Build a map of GUIDs to indices for fast lookups in the comparator
+ // function.
+ let guidIndices = new Map();
+ for (let i = 0; i < orderedChildrenGuids.length; ++i) {
+ let guid = orderedChildrenGuids[i];
+ guidIndices.set(guid, i);
+ }
+
+ // Reorder the children array according to the specified order, provided
+ // GUIDs come first, others are appended in somehow random order.
+ children.sort((a, b) => {
+ // This works provided fetchBookmarksByParent returns sorted children.
+ if (!guidIndices.has(a.guid) && !guidIndices.has(b.guid)) {
+ return 0;
+ }
+ if (!guidIndices.has(a.guid)) {
+ return 1;
+ }
+ if (!guidIndices.has(b.guid)) {
+ return -1;
+ }
+ return guidIndices.get(a.guid) < guidIndices.get(b.guid) ? -1 : 1;
+ });
+
+ // Update the bookmarks position now. If any unknown guid have been
+ // inserted meanwhile, its position will be set to -position, and we'll
+ // handle it later.
+ // To do the update in a single step, we build a VALUES (guid, position)
+ // table. We then use count() in the sorting table to avoid skipping values
+ // when no more existing GUIDs have been provided.
+ let valuesTable = children.map((child, i) => `("${child.guid}", ${i})`)
+ .join();
+ yield db.execute(
+ `WITH sorting(g, p) AS (
+ VALUES ${valuesTable}
+ )
+ UPDATE moz_bookmarks SET position = (
+ SELECT CASE count(*) WHEN 0 THEN -position
+ ELSE count(*) - 1
+ END
+ FROM sorting a
+ JOIN sorting b ON b.p <= a.p
+ WHERE a.g = guid
+ )
+ WHERE parent = :parentId
+ `, { parentId: parent._id});
+
+ // Update position of items that could have been inserted in the meanwhile.
+ // Since this can happen rarely and it's only done for schema coherence
+ // resonds, we won't notify about these changes.
+ yield db.executeCached(
+ `CREATE TEMP TRIGGER moz_bookmarks_reorder_trigger
+ AFTER UPDATE OF position ON moz_bookmarks
+ WHEN NEW.position = -1
+ BEGIN
+ UPDATE moz_bookmarks
+ SET position = (SELECT MAX(position) FROM moz_bookmarks
+ WHERE parent = NEW.parent) +
+ (SELECT count(*) FROM moz_bookmarks
+ WHERE parent = NEW.parent
+ AND position BETWEEN OLD.position AND -1)
+ WHERE guid = NEW.guid;
+ END
+ `);
+
+ yield db.executeCached(
+ `UPDATE moz_bookmarks SET position = -1 WHERE position < 0`);
+
+ yield db.executeCached(`DROP TRIGGER moz_bookmarks_reorder_trigger`);
+
+ return children;
+ }.bind(this))
+ );
+}
+
+// Helpers.
+
+/**
+ * Merges objects into a new object, included non-enumerable properties.
+ *
+ * @param sources
+ * source objects to merge.
+ * @return a new object including all properties from the source objects.
+ */
+function mergeIntoNewObject(...sources) {
+ let dest = {};
+ for (let src of sources) {
+ for (let prop of Object.getOwnPropertyNames(src)) {
+ Object.defineProperty(dest, prop, Object.getOwnPropertyDescriptor(src, prop));
+ }
+ }
+ return dest;
+}
+
+/**
+ * Remove properties that have the same value across two bookmark objects.
+ *
+ * @param dest
+ * destination bookmark object.
+ * @param src
+ * source bookmark object.
+ * @return a cleaned up bookmark object.
+ * @note "guid" is never removed.
+ */
+function removeSameValueProperties(dest, src) {
+ for (let prop in dest) {
+ let remove = false;
+ switch (prop) {
+ case "lastModified":
+ case "dateAdded":
+ remove = src.hasOwnProperty(prop) && dest[prop].getTime() == src[prop].getTime();
+ break;
+ case "url":
+ remove = src.hasOwnProperty(prop) && dest[prop].href == src[prop].href;
+ break;
+ default:
+ remove = dest[prop] == src[prop];
+ }
+ if (remove && prop != "guid")
+ delete dest[prop];
+ }
+}
+
+/**
+ * Convert an array of mozIStorageRow objects to an array of bookmark objects.
+ *
+ * @param rows
+ * the array of mozIStorageRow objects.
+ * @return an array of bookmark objects.
+ */
+function rowsToItemsArray(rows) {
+ return rows.map(row => {
+ let item = {};
+ for (let prop of ["guid", "index", "type"]) {
+ item[prop] = row.getResultByName(prop);
+ }
+ for (let prop of ["dateAdded", "lastModified"]) {
+ item[prop] = PlacesUtils.toDate(row.getResultByName(prop));
+ }
+ for (let prop of ["title", "parentGuid", "url" ]) {
+ let val = row.getResultByName(prop);
+ if (val)
+ item[prop] = prop === "url" ? new URL(val) : val;
+ }
+ for (let prop of ["_id", "_parentId", "_childCount", "_grandParentId"]) {
+ let val = row.getResultByName(prop);
+ if (val !== null) {
+ // These properties should not be returned to the API consumer, thus
+ // they are non-enumerable and removed through Object.assign just before
+ // the object is returned.
+ // Configurable is set to support mergeIntoNewObject overwrites.
+ Object.defineProperty(item, prop, { value: val, enumerable: false,
+ configurable: true });
+ }
+ }
+
+ return item;
+ });
+}
+
+function validateBookmarkObject(input, behavior) {
+ return PlacesUtils.validateItemProperties(
+ PlacesUtils.BOOKMARK_VALIDATORS, input, behavior);
+}
+
+/**
+ * Updates frecency for a list of URLs.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param urls
+ * the array of URLs to update.
+ */
+var updateFrecency = Task.async(function* (db, urls) {
+ // We just use the hashes, since updating a few additional urls won't hurt.
+ yield db.execute(
+ `UPDATE moz_places
+ SET frecency = NOTIFY_FRECENCY(
+ CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
+ ) WHERE url_hash IN ( ${urls.map(url => `hash("${url.href}")`).join(", ")} )
+ `);
+
+ yield db.execute(
+ `UPDATE moz_places
+ SET hidden = 0
+ WHERE url_hash IN ( ${urls.map(url => `hash(${JSON.stringify(url.href)})`).join(", ")} )
+ AND frecency <> 0
+ `);
+});
+
+/**
+ * Removes any orphan annotation entries.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ */
+var removeOrphanAnnotations = Task.async(function* (db) {
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE id IN (SELECT a.id from moz_items_annos a
+ LEFT JOIN moz_bookmarks b ON a.item_id = b.id
+ WHERE b.id ISNULL)
+ `);
+ yield db.executeCached(
+ `DELETE FROM moz_anno_attributes
+ WHERE id IN (SELECT n.id from moz_anno_attributes n
+ LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
+ LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
+ WHERE a1.id ISNULL AND a2.id ISNULL)
+ `);
+});
+
+/**
+ * Removes annotations for a given item.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param itemId
+ * internal id of the item for which to remove annotations.
+ */
+var removeAnnotationsForItem = Task.async(function* (db, itemId) {
+ yield db.executeCached(
+ `DELETE FROM moz_items_annos
+ WHERE item_id = :id
+ `, { id: itemId });
+ yield db.executeCached(
+ `DELETE FROM moz_anno_attributes
+ WHERE id IN (SELECT n.id from moz_anno_attributes n
+ LEFT JOIN moz_annos a1 ON a1.anno_attribute_id = n.id
+ LEFT JOIN moz_items_annos a2 ON a2.anno_attribute_id = n.id
+ WHERE a1.id ISNULL AND a2.id ISNULL)
+ `);
+});
+
+/**
+ * Updates lastModified for all the ancestors of a given folder GUID.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param folderGuid
+ * the GUID of the folder whose ancestors should be updated.
+ * @param time
+ * a Date object to use for the update.
+ *
+ * @note the folder itself is also updated.
+ */
+var setAncestorsLastModified = Task.async(function* (db, folderGuid, time) {
+ yield db.executeCached(
+ `WITH RECURSIVE
+ ancestors(aid) AS (
+ SELECT id FROM moz_bookmarks WHERE guid = :guid
+ UNION ALL
+ SELECT parent FROM moz_bookmarks
+ JOIN ancestors ON id = aid
+ WHERE type = :type
+ )
+ UPDATE moz_bookmarks SET lastModified = :time
+ WHERE id IN ancestors
+ `, { guid: folderGuid, type: Bookmarks.TYPE_FOLDER,
+ time: PlacesUtils.toPRTime(time) });
+});
+
+/**
+ * Remove all descendants of one or more bookmark folders.
+ *
+ * @param db
+ * the Sqlite.jsm connection handle.
+ * @param folderGuids
+ * array of folder guids.
+ */
+var removeFoldersContents =
+Task.async(function* (db, folderGuids, options) {
+ let itemsRemoved = [];
+ for (let folderGuid of folderGuids) {
+ let rows = yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :folderGuid
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ )
+ SELECT b.id AS _id, b.parent AS _parentId, b.position AS 'index',
+ b.type, url, b.guid, p.guid AS parentGuid, b.dateAdded,
+ b.lastModified, b.title, p.parent AS _grandParentId,
+ NULL AS _childCount
+ FROM descendants
+ JOIN moz_bookmarks b ON did = b.id
+ JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_places h ON b.fk = h.id`, { folderGuid });
+
+ itemsRemoved = itemsRemoved.concat(rowsToItemsArray(rows));
+
+ yield db.executeCached(
+ `WITH RECURSIVE
+ descendants(did) AS (
+ SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON b.parent = p.id
+ WHERE p.guid = :folderGuid
+ UNION ALL
+ SELECT id FROM moz_bookmarks
+ JOIN descendants ON parent = did
+ )
+ DELETE FROM moz_bookmarks WHERE id IN descendants`, { folderGuid });
+ }
+
+ // Cleanup orphans.
+ yield removeOrphanAnnotations(db);
+
+ // TODO (Bug 1087576): this may leave orphan tags behind.
+
+ let urls = itemsRemoved.filter(item => "url" in item).map(item => item.url);
+ updateFrecency(db, urls).then(null, Cu.reportError);
+
+ // Send onItemRemoved notifications to listeners.
+ // TODO (Bug 1087580): for the case of eraseEverything, this should send a
+ // single clear bookmarks notification rather than notifying for each
+ // bookmark.
+
+ // Notify listeners in reverse order to serve children before parents.
+ let { source = Ci.nsINavBookmarksService.SOURCE_DEFAULT } = options;
+ let observers = PlacesUtils.bookmarks.getObservers();
+ for (let item of itemsRemoved.reverse()) {
+ let uri = item.hasOwnProperty("url") ? PlacesUtils.toURI(item.url) : null;
+ notify(observers, "onItemRemoved", [ item._id, item._parentId,
+ item.index, item.type, uri,
+ item.guid, item.parentGuid,
+ source ],
+ // Notify observers that this item is being
+ // removed as a descendent.
+ { isDescendantRemoval: true });
+
+ let isUntagging = item._grandParentId == PlacesUtils.tagsFolderId;
+ if (isUntagging) {
+ for (let entry of (yield fetchBookmarksByURL(item))) {
+ notify(observers, "onItemChanged", [ entry._id, "tags", false, "",
+ PlacesUtils.toPRTime(entry.lastModified),
+ entry.type, entry._parentId,
+ entry.guid, entry.parentGuid,
+ "", source ]);
+ }
+ }
+ }
+});
+
+/**
+ * Tries to insert a new place if it doesn't exist yet.
+ * @param url
+ * A valid URL object.
+ * @return {Promise} resolved when the operation is complete.
+ */
+function maybeInsertPlace(db, url) {
+ // The IGNORE conflict can trigger on `guid`.
+ return db.executeCached(
+ `INSERT OR IGNORE INTO moz_places (url, url_hash, rev_host, hidden, frecency, guid)
+ VALUES (:url, hash(:url), :rev_host, 0, :frecency,
+ IFNULL((SELECT guid FROM moz_places WHERE url_hash = hash(:url) AND url = :url),
+ GENERATE_GUID()))
+ `, { url: url.href,
+ rev_host: PlacesUtils.getReversedHost(url),
+ frecency: url.protocol == "place:" ? 0 : -1 });
+}