diff options
Diffstat (limited to 'toolkit/components/places/Bookmarks.jsm')
-rw-r--r-- | toolkit/components/places/Bookmarks.jsm | 1536 |
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 }); +} |