diff options
Diffstat (limited to 'toolkit/components/places/Bookmarks.sys.mjs')
-rw-r--r-- | toolkit/components/places/Bookmarks.sys.mjs | 3413 |
1 files changed, 3413 insertions, 0 deletions
diff --git a/toolkit/components/places/Bookmarks.sys.mjs b/toolkit/components/places/Bookmarks.sys.mjs new file mode 100644 index 0000000000..93342b40c0 --- /dev/null +++ b/toolkit/components/places/Bookmarks.sys.mjs @@ -0,0 +1,3413 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This 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. 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 bookmark-moved 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 + */ + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "NetUtil", + "resource://gre/modules/NetUtil.jsm" +); +ChromeUtils.defineESModuleGetters(lazy, { + PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +// This is an helper to temporarily cover the need to know the tags folder +// itemId until bug 424160 is fixed. This exists so that startup paths won't +// pay the price to initialize the bookmarks service just to fetch this value. +// If the method is already initing the bookmarks service for other reasons +// (most of the writing methods will invoke getObservers() already) it can +// directly use the PlacesUtils.tagsFolderId property. +var gTagsFolderId; +async function promiseTagsFolderId() { + if (gTagsFolderId) { + return gTagsFolderId; + } + let db = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await db.execute( + "SELECT id FROM moz_bookmarks WHERE guid = :guid", + { guid: Bookmarks.tagsGuid } + ); + return (gTagsFolderId = rows[0].getResultByName("id")); +} + +const MATCH_ANYWHERE_UNMODIFIED = + Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE_UNMODIFIED; +const BEHAVIOR_BOOKMARK = Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; + +export var Bookmarks = Object.freeze({ + /** + * Item's type constants. + * These should stay consistent with nsINavBookmarksService.idl + */ + TYPE_BOOKMARK: 1, + TYPE_FOLDER: 2, + TYPE_SEPARATOR: 3, + + /** + * Sync status constants, stored for each item. + */ + SYNC_STATUS: { + UNKNOWN: Ci.nsINavBookmarksService.SYNC_STATUS_UNKNOWN, + NEW: Ci.nsINavBookmarksService.SYNC_STATUS_NEW, + NORMAL: Ci.nsINavBookmarksService.SYNC_STATUS_NORMAL, + }, + + /** + * Default index used to append a bookmark-item at the end of a folder. + * This should stay consistent with nsINavBookmarksService.idl + */ + DEFAULT_INDEX: -1, + + /** + * Maximum length of a tag. + * Any tag above this length is rejected. + */ + MAX_TAG_LENGTH: 100, + + /** + * 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, + SYNC_REPARENT_REMOVED_FOLDER_CHILDREN: + Ci.nsINavBookmarksService.SOURCE_SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, + RESTORE: Ci.nsINavBookmarksService.SOURCE_RESTORE, + RESTORE_ON_STARTUP: Ci.nsINavBookmarksService.SOURCE_RESTORE_ON_STARTUP, + }, + + /** + * 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________", + + /** + * The GUIDs of the user content root folders that we support, for easy access + * as a set. + */ + userContentRoots: [ + "toolbar_____", + "menu________", + "unfiled_____", + "mobile______", + ], + + /** + * GUID associated with bookmarks that haven't been saved to the database yet. + */ + unsavedGuid: "new_________", + + /** + * GUIDs associated with virtual queries that are used for displaying bookmark + * folders in the left pane. + */ + virtualMenuGuid: "menu_______v", + virtualToolbarGuid: "toolbar____v", + virtualUnfiledGuid: "unfiled____v", + virtualMobileGuid: "mobile_____v", + + /** + * Checks if a guid is a virtual root. + * + * @param {String} guid The guid of the item to look for. + * @returns {Boolean} true if guid is a virtual root, false otherwise. + */ + isVirtualRootItem(guid) { + return ( + guid == lazy.PlacesUtils.bookmarks.virtualMenuGuid || + guid == lazy.PlacesUtils.bookmarks.virtualToolbarGuid || + guid == lazy.PlacesUtils.bookmarks.virtualUnfiledGuid || + guid == lazy.PlacesUtils.bookmarks.virtualMobileGuid + ); + }, + + /** + * Returns the title to use on the UI for a bookmark item. Root folders + * in the database don't store fully localised versions of the title. To + * get those this function should be called. + * + * Hence, this function should only be called if a root folder object is + * likely to be displayed to the user. + * + * @param {Object} info An object representing a bookmark-item. + * @returns {String} The correct string. + * @throws {Error} If the guid in PlacesUtils.bookmarks.userContentRoots is + * not supported. + */ + getLocalizedTitle(info) { + if (!lazy.PlacesUtils.bookmarks.userContentRoots.includes(info.guid)) { + return info.title; + } + + switch (info.guid) { + case lazy.PlacesUtils.bookmarks.toolbarGuid: + return lazy.PlacesUtils.getString("BookmarksToolbarFolderTitle"); + case lazy.PlacesUtils.bookmarks.menuGuid: + return lazy.PlacesUtils.getString("BookmarksMenuFolderTitle"); + case lazy.PlacesUtils.bookmarks.unfiledGuid: + return lazy.PlacesUtils.getString("OtherBookmarksFolderTitle"); + case lazy.PlacesUtils.bookmarks.mobileGuid: + return lazy.PlacesUtils.getString("MobileBookmarksFolderTitle"); + default: + throw new Error( + `Unsupported guid ${info.guid} passed to getLocalizedTitle!` + ); + } + }, + + /** + * 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) { + let now = new Date(); + let addedTime = (info && info.dateAdded) || now; + let modTime = addedTime; + if (addedTime > now) { + modTime = now; + } + let insertInfo = validateBookmarkObject("Bookmarks.jsm: insert", 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, + // Inserting into the root folder is not allowed unless it's testing. + validIf: b => + lazy.PlacesUtils.isInAutomation || b.parentGuid != this.rootGuid, + }, + title: { + defaultValue: "", + validIf: b => + b.type == this.TYPE_BOOKMARK || + b.type == this.TYPE_FOLDER || + b.title === "", + }, + dateAdded: { defaultValue: addedTime }, + lastModified: { + defaultValue: modTime, + validIf: b => + b.lastModified >= now || + (b.dateAdded && b.lastModified >= b.dateAdded), + }, + source: { defaultValue: this.SOURCES.DEFAULT }, + }); + + return (async () => { + // Ensure the parent exists. + let parent = await 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 = await insertBookmark(insertInfo, parent); + + // We need the itemId to notify, though once the switch to guids is + // complete we may stop using it. + let itemId = await lazy.PlacesUtils.promiseItemId(item.guid); + + // Pass tagging information for the observers to skip over these notifications when needed. + let isTagging = parent._parentId == lazy.PlacesUtils.tagsFolderId; + let isTagsFolder = parent._id == lazy.PlacesUtils.tagsFolderId; + let url = ""; + if (item.type == Bookmarks.TYPE_BOOKMARK) { + url = item.url.href; + } + + const notifications = [ + new PlacesBookmarkAddition({ + id: itemId, + url, + itemType: item.type, + parentId: parent._id, + index: item.index, + title: item.title, + dateAdded: item.dateAdded, + guid: item.guid, + parentGuid: item.parentGuid, + source: item.source, + isTagging: isTagging || isTagsFolder, + }), + ]; + + // If it's a tag, notify bookmark-tags-changed event to all bookmarks for this URL. + if (isTagging) { + for (let entry of await fetchBookmarksByURL(item, { + concurrent: true, + })) { + notifications.push( + new PlacesBookmarkTags({ + id: entry._id, + itemType: entry.type, + url, + guid: entry.guid, + parentGuid: entry.parentGuid, + tags: entry._tags, + lastModified: entry.lastModified, + source: item.source, + isTagging: false, + }) + ); + } + } + + PlacesObservers.notifyListeners(notifications); + + // Remove non-enumerable properties. + delete item.source; + return Object.assign({}, item); + })(); + }, + + /** + * Inserts a bookmark-tree into the existing bookmarks tree. + * + * All the specified folders and bookmarks will be inserted as new, even + * if duplicates. There's no merge support at this time. + * + * The input should be of the form: + * { + * guid: "<some-existing-guid-to-use-as-parent>", + * source: "<some valid source>", (optional) + * children: [ + * ... valid bookmark objects. + * ] + * } + * + * Children will be appended to any existing children of the parent + * that is specified. The source specified on the root of the tree + * will be used for all the items inserted. Any indices or custom parentGuids + * set on children will be ignored and overwritten. + * + * @param {Object} tree + * object representing a tree of bookmark items to insert. + * @param {Object} options [optional] + * object with properties representing options. Current options are: + * - fixupOrSkipInvalidEntries: makes the insert more lenient to + * mistakes in the input tree. Properties of an entry that are + * fixable will be corrected, otherwise the entry will be skipped. + * This is particularly convenient for import/restore operations, + * but should not be abused for common inserts, since it may hide + * bugs in the calling code. + * + * @return {Promise} resolved when the creation is complete. + * @resolves to an array of objects representing the created bookmark(s). + * @rejects if it's not possible to create the requested bookmark. + * @throws if the arguments are invalid. + */ + insertTree(tree, options) { + if (!tree || typeof tree != "object") { + throw new Error("Should be provided a valid tree object."); + } + if (!Array.isArray(tree.children) || !tree.children.length) { + throw new Error("Should have a non-zero number of children to insert."); + } + if (!lazy.PlacesUtils.isValidGuid(tree.guid)) { + throw new Error( + `The parent guid is not valid (${tree.guid} ${tree.title}).` + ); + } + if (tree.guid == this.rootGuid) { + throw new Error("Can't insert into the root."); + } + if (tree.guid == this.tagsGuid) { + throw new Error("Can't use insertTree to insert tags."); + } + if ( + tree.hasOwnProperty("source") && + !Object.values(this.SOURCES).includes(tree.source) + ) { + throw new Error("Can't use source value " + tree.source); + } + if (options && typeof options != "object") { + throw new Error("Options should be a valid object"); + } + let fixupOrSkipInvalidEntries = + options && !!options.fixupOrSkipInvalidEntries; + + // Serialize the tree into an array of items to insert into the db. + let insertInfos = []; + let urlsThatMightNeedPlaces = []; + + // We want to use the same 'last added' time for all the entries + // we import (so they won't differ by a few ms based on where + // they are in the tree, and so we don't needlessly construct + // multiple dates). + let fallbackLastAdded = new Date(); + + const { TYPE_BOOKMARK, TYPE_FOLDER, SOURCES } = this; + + // Reuse the 'source' property for all the entries. + let source = tree.source || SOURCES.DEFAULT; + + // This is recursive. + function appendInsertionInfoForInfoArray(infos, indexToUse, parentGuid) { + // We want to keep the index of items that will be inserted into the root + // NULL, and then use a subquery to select the right index, to avoid + // races where other consumers might add items between when we determine + // the index and when we insert. However, the validator does not allow + // NULL values in in the index, so we fake it while validating and then + // correct later. Keep track of whether we're doing this: + let shouldUseNullIndices = false; + if (indexToUse === null) { + shouldUseNullIndices = true; + indexToUse = 0; + } + + // When a folder gets an item added, its last modified date is updated + // to be equal to the date we added the item (if that date is newer). + // Because we're inserting a tree, we keep track of this date for the + // loop, updating it for inserted items as well as from any subfolders + // we insert. + let lastAddedForParent = new Date(0); + for (let info of infos) { + // Ensure to use the same date for dateAdded and lastModified, even if + // dateAdded may be imposed by the caller. + let time = (info && info.dateAdded) || fallbackLastAdded; + let insertInfo = { + guid: { defaultValue: lazy.PlacesUtils.history.makeGuid() }, + type: { defaultValue: TYPE_BOOKMARK }, + url: { + requiredIf: b => b.type == TYPE_BOOKMARK, + validIf: b => b.type == TYPE_BOOKMARK, + }, + parentGuid: { replaceWith: parentGuid }, // Set the correct parent guid. + title: { + defaultValue: "", + validIf: b => + b.type == TYPE_BOOKMARK || + b.type == TYPE_FOLDER || + b.title === "", + }, + 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), + }, + index: { replaceWith: indexToUse++ }, + source: { replaceWith: source }, + keyword: { validIf: b => b.type == TYPE_BOOKMARK }, + charset: { validIf: b => b.type == TYPE_BOOKMARK }, + postData: { validIf: b => b.type == TYPE_BOOKMARK }, + tags: { validIf: b => b.type == TYPE_BOOKMARK }, + children: { + validIf: b => b.type == TYPE_FOLDER && Array.isArray(b.children), + }, + }; + if (fixupOrSkipInvalidEntries) { + insertInfo.guid.fixup = b => + (b.guid = lazy.PlacesUtils.history.makeGuid()); + insertInfo.dateAdded.fixup = insertInfo.lastModified.fixup = b => + (b.lastModified = b.dateAdded = fallbackLastAdded); + } + try { + insertInfo = validateBookmarkObject( + "Bookmarks.jsm: insertTree", + info, + insertInfo + ); + } catch (ex) { + if (fixupOrSkipInvalidEntries) { + indexToUse--; + continue; + } else { + throw ex; + } + } + + if (shouldUseNullIndices) { + insertInfo.index = null; + } + // Store the URL if this is a bookmark, so we can ensure we create an + // entry in moz_places for it. + if (insertInfo.type == Bookmarks.TYPE_BOOKMARK) { + urlsThatMightNeedPlaces.push(insertInfo.url); + } + + insertInfos.push(insertInfo); + // Process any children. We have to use info.children here rather than + // insertInfo.children because validateBookmarkObject doesn't copy over + // the children ref, as the default bookmark validators object doesn't + // know about children. + if (info.children) { + // start children of this item off at index 0. + let childrenLastAdded = appendInsertionInfoForInfoArray( + info.children, + 0, + insertInfo.guid + ); + if (childrenLastAdded > insertInfo.lastModified) { + insertInfo.lastModified = childrenLastAdded; + } + if (childrenLastAdded > lastAddedForParent) { + lastAddedForParent = childrenLastAdded; + } + } + + // Ensure we track what time to update the parent to. + if (insertInfo.dateAdded > lastAddedForParent) { + lastAddedForParent = insertInfo.dateAdded; + } + } + return lastAddedForParent; + } + + // We want to validate synchronously, but we can't know the index at which + // we're inserting into the parent. We just use NULL instead, + // and the SQL query with which we insert will update it as necessary. + let lastAddedForParent = appendInsertionInfoForInfoArray( + tree.children, + null, + tree.guid + ); + + // appendInsertionInfoForInfoArray will remove invalid items and may leave + // us with nothing to insert, if so, just return early. + if (!insertInfos.length) { + return []; + } + + return (async function() { + let treeParent = await fetchBookmark({ guid: tree.guid }); + if (!treeParent) { + throw new Error("The parent you specified doesn't exist."); + } + + if (treeParent._parentId == lazy.PlacesUtils.tagsFolderId) { + throw new Error("Can't use insertTree to insert tags."); + } + + await insertBookmarkTree( + insertInfos, + source, + treeParent, + urlsThatMightNeedPlaces, + lastAddedForParent + ); + + // Now update the indices of root items in the objects we return. + // These may be wrong if someone else modified the table between + // when we fetched the parent and inserted our items, but the actual + // inserts will have been correct, and we don't want to query the DB + // again if we don't have to. bug 1347230 covers improving this. + let rootIndex = treeParent._childCount; + for (let insertInfo of insertInfos) { + if (insertInfo.parentGuid == tree.guid) { + insertInfo.index += rootIndex++; + } + } + // We need the itemIds to notify, though once the switch to guids is + // complete we may stop using them. + let itemIdMap = await lazy.PlacesUtils.promiseManyItemIds( + insertInfos.map(info => info.guid) + ); + + let notifications = []; + for (let i = 0; i < insertInfos.length; i++) { + let item = insertInfos[i]; + let itemId = itemIdMap.get(item.guid); + // For sub-folders, we need to make sure their children have the correct parent ids. + let parentId; + if (item.parentGuid === treeParent.guid) { + // This is a direct child of the tree parent, so we can use the + // existing parent's id. + parentId = treeParent._id; + } else { + // This is a parent folder that's been updated, so we need to + // use the new item id. + parentId = itemIdMap.get(item.parentGuid); + } + + let url = ""; + if (item.type == Bookmarks.TYPE_BOOKMARK) { + url = URL.isInstance(item.url) ? item.url.href : item.url; + } + + notifications.push( + new PlacesBookmarkAddition({ + id: itemId, + url, + itemType: item.type, + parentId, + index: item.index, + title: item.title, + dateAdded: item.dateAdded, + guid: item.guid, + parentGuid: item.parentGuid, + source: item.source, + isTagging: false, + }) + ); + + try { + await handleBookmarkItemSpecialData(itemId, item); + } catch (ex) { + // This is not critical, regardless the bookmark has been created + // and we should continue notifying the next ones. + console.error( + `An error occured while handling special bookmark data: ${ex}` + ); + } + + // Remove non-enumerable properties. + delete item.source; + + insertInfos[i] = Object.assign({}, item); + } + + if (notifications.length) { + PlacesObservers.notifyListeners(notifications); + } + + return insertInfos; + })(); + }, + + /** + * 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("Bookmarks.jsm: update", info, { + guid: { required: true }, + index: { + requiredIf: b => b.hasOwnProperty("parentGuid"), + validIf: b => b.index >= 0 || b.index == this.DEFAULT_INDEX, + }, + parentGuid: { validIf: b => b.parentGuid != this.rootGuid }, + 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 (async () => { + // Ensure the item exists. + let item = await 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"); + } + + // 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); + } + const now = new Date(); + let lastModifiedDefault = now; + // In the case where `dateAdded` is specified, but `lastModified` is not, + // we only update `lastModified` if it is older than the new `dateAdded`. + if (!("lastModified" in updateInfo) && "dateAdded" in updateInfo) { + lastModifiedDefault = new Date( + Math.max(item.lastModified, updateInfo.dateAdded) + ); + } + updateInfo = validateBookmarkObject("Bookmarks.jsm: update", updateInfo, { + url: { validIf: () => item.type == this.TYPE_BOOKMARK }, + title: { + validIf: () => + [this.TYPE_BOOKMARK, this.TYPE_FOLDER].includes(item.type), + }, + lastModified: { + defaultValue: lastModifiedDefault, + validIf: b => + b.lastModified >= now || + b.lastModified >= (b.dateAdded || item.dateAdded), + }, + dateAdded: { defaultValue: item.dateAdded }, + }); + + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: update", + async db => { + let parent; + if (updateInfo.hasOwnProperty("parentGuid")) { + if (lazy.PlacesUtils.isRootItem(item.guid)) { + throw new Error("It's not possible to move Places root folders."); + } + if (item.type == this.TYPE_FOLDER) { + // Make sure we are not moving a folder into itself or one of its + // descendants. + let rows = await 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 = await fetchBookmark({ guid: updateInfo.parentGuid }); + if (!parent) { + throw new Error("No bookmarks found for the provided parentGuid"); + } + } + + if (updateInfo.hasOwnProperty("index")) { + if (lazy.PlacesUtils.isRootItem(item.guid)) { + throw new Error("It's not possible to move Places root folders."); + } + // 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 = await 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 syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + info.source + ); + + let updatedItem = await db.executeTransaction(async function() { + let updatedItem = await updateBookmark( + db, + updateInfo, + item, + item.index, + parent, + syncChangeDelta + ); + if (parent) { + await setAncestorsLastModified( + db, + parent.guid, + updatedItem.lastModified, + syncChangeDelta + ); + } + return updatedItem; + }); + + if ( + item.type == this.TYPE_BOOKMARK && + item.url.href != updatedItem.url.href + ) { + // ...though we don't wait for the calculation. + updateFrecency(db, [item.url, updatedItem.url]).catch( + Cu.reportError + ); + } + + const notifications = []; + + // For lastModified, we only care about the original input, since we + // should not notify implicit lastModified changes. + if ( + (info.hasOwnProperty("lastModified") && + updateInfo.hasOwnProperty("lastModified") && + item.lastModified != updatedItem.lastModified) || + (info.hasOwnProperty("dateAdded") && + updateInfo.hasOwnProperty("dateAdded") && + item.dateAdded != updatedItem.dateAdded) + ) { + let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; + if (!isTagging) { + if (!parent) { + parent = await fetchBookmark({ guid: updatedItem.parentGuid }); + } + isTagging = parent.parentGuid === Bookmarks.tagsGuid; + } + + notifications.push( + new PlacesBookmarkTime({ + id: updatedItem._id, + itemType: updatedItem.type, + url: updatedItem.url?.href, + guid: updatedItem.guid, + parentGuid: updatedItem.parentGuid, + dateAdded: updatedItem.dateAdded, + lastModified: updatedItem.lastModified, + source: updatedItem.source, + isTagging, + }) + ); + } + + if (updateInfo.hasOwnProperty("title")) { + let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; + if (!isTagging) { + if (!parent) { + parent = await fetchBookmark({ guid: updatedItem.parentGuid }); + } + isTagging = parent.parentGuid === Bookmarks.tagsGuid; + } + + notifications.push( + new PlacesBookmarkTitle({ + id: updatedItem._id, + itemType: updatedItem.type, + url: updatedItem.url?.href, + guid: updatedItem.guid, + parentGuid: updatedItem.parentGuid, + title: updatedItem.title, + lastModified: updatedItem.lastModified, + source: updatedItem.source, + isTagging, + }) + ); + + // If we're updating a tag, we must notify all the tagged bookmarks + // about the change. + if (isTagging) { + for (let entry of await fetchBookmarksByTags( + { tags: [updatedItem.title] }, + { concurrent: true } + )) { + notifications.push( + new PlacesBookmarkTags({ + id: entry._id, + itemType: entry.type, + url: entry.url, + guid: entry.guid, + parentGuid: entry.parentGuid, + tags: entry._tags, + lastModified: entry.lastModified, + source: updatedItem.source, + isTagging: false, + }) + ); + } + } + } + if (updateInfo.hasOwnProperty("url")) { + await lazy.PlacesUtils.keywords.reassign( + item.url, + updatedItem.url, + updatedItem.source + ); + + let isTagging = updatedItem.parentGuid == Bookmarks.tagsGuid; + if (!isTagging) { + if (!parent) { + parent = await fetchBookmark({ guid: updatedItem.parentGuid }); + } + isTagging = parent.parentGuid === Bookmarks.tagsGuid; + } + + notifications.push( + new PlacesBookmarkUrl({ + id: updatedItem._id, + itemType: updatedItem.type, + url: updatedItem.url.href, + guid: updatedItem.guid, + parentGuid: updatedItem.parentGuid, + source: updatedItem.source, + isTagging, + lastModified: updatedItem.lastModified, + }) + ); + } + // If the item was moved, notify bookmark-moved. + if ( + item.parentGuid != updatedItem.parentGuid || + item.index != updatedItem.index + ) { + notifications.push( + new PlacesBookmarkMoved({ + id: updatedItem._id, + itemType: updatedItem.type, + url: updatedItem.url && updatedItem.url.href, + guid: updatedItem.guid, + parentGuid: updatedItem.parentGuid, + source: updatedItem.source, + index: updatedItem.index, + oldParentGuid: item.parentGuid, + oldIndex: item.index, + isTagging: + updatedItem.parentGuid === Bookmarks.tagsGuid || + parent.parentGuid === Bookmarks.tagsGuid, + }) + ); + } + + if (notifications.length) { + PlacesObservers.notifyListeners(notifications); + } + + // Remove non-enumerable properties. + delete updatedItem.source; + return Object.assign({}, updatedItem); + } + ); + })(); + }, + + /** + * Moves multiple bookmark-items to a specific folder. + * + * If you are only updating/moving a single bookmark, use update() instead. + * + * @param {Array} guids + * An array of GUIDs representing the bookmarks to move. + * @param {String} parentGuid + * Optional, the parent GUID to move the bookmarks to. + * @param {Integer} index + * The index to move the bookmarks to. If this is -1, the bookmarks + * will be appended to the folder. + * @param {Integer} source + * One of the Bookmarks.SOURCES.* options, representing the source of + * this change. + * + * @return {Promise} resolved when the move is complete. + * @resolves to an array of objects representing the moved bookmarks. + * @rejects if it's not possible to move the given bookmark(s). + * @throws if the arguments are invalid. + */ + moveToFolder(guids, parentGuid, index, source) { + if (!Array.isArray(guids) || guids.length < 1) { + throw new Error("guids should be an array of at least one item"); + } + if (!guids.every(guid => lazy.PlacesUtils.isValidGuid(guid))) { + throw new Error("Expected only valid GUIDs to be passed."); + } + if (parentGuid && !lazy.PlacesUtils.isValidGuid(parentGuid)) { + throw new Error("parentGuid should be a valid GUID"); + } + if (parentGuid == lazy.PlacesUtils.bookmarks.rootGuid) { + throw new Error("Cannot move bookmarks into root."); + } + if (typeof index != "number" || index < this.DEFAULT_INDEX) { + throw new Error( + `index should be a number greater than ${this.DEFAULT_INDEX}` + ); + } + + if (!source) { + source = this.SOURCES.DEFAULT; + } + + return (async () => { + let updateInfos = []; + let syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + source + ); + + await lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: moveToFolder", + async db => { + const lastModified = new Date(); + + let targetParentGuid = parentGuid || undefined; + + for (let guid of guids) { + // Ensure the item exists. + let existingItem = await fetchBookmark({ guid }, { db }); + if (!existingItem) { + throw new Error("No bookmarks found for the provided GUID"); + } + + if (parentGuid) { + // We're moving to a different folder. + if (existingItem.type == this.TYPE_FOLDER) { + // Make sure we are not moving a folder into itself or one of its + // descendants. + let rows = await 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: existingItem._id, type: this.TYPE_FOLDER } + ); + if ( + rows.map(r => r.getResultByName("guid")).includes(parentGuid) + ) { + throw new Error( + "Cannot insert a folder into itself or one of its descendants" + ); + } + } + } else if (!targetParentGuid) { + targetParentGuid = existingItem.parentGuid; + } else if (existingItem.parentGuid != targetParentGuid) { + throw new Error( + "All bookmarks should be in the same folder if no parent is specified" + ); + } + + updateInfos.push({ existingItem, currIndex: existingItem.index }); + } + + let newParent = await fetchBookmark( + { guid: targetParentGuid }, + { db } + ); + + if (newParent._grandParentId == lazy.PlacesUtils.tagsFolderId) { + throw new Error("Can't move to a tags folder"); + } + + let newParentChildCount = newParent._childCount; + + await db.executeTransaction(async () => { + // Now that we have all the existing items, we can do the actual updates. + for (let i = 0; i < updateInfos.length; i++) { + let info = updateInfos[i]; + if (index != this.DEFAULT_INDEX) { + // If we're dropping on the same folder, then we may need to adjust + // the index to insert at the correct place. + if (info.existingItem.parentGuid == newParent.guid) { + if (index > info.existingItem.index) { + // If we're dragging down, we need to go one lower to insert at + // the real point as moving the element changes the index of + // everything below by 1. + index--; + } else if (index == info.existingItem.index) { + // This isn't moving so we skip it, but copy the data so we have + // an easy way for the notifications to check. + info.updatedItem = { ...info.existingItem }; + continue; + } + } + } + + // Never let the index go higher than the max count of the folder. + if (index == this.DEFAULT_INDEX || index >= newParentChildCount) { + index = newParentChildCount; + + // If this is moving within the same folder, then we need to drop the + // index by one to compensate for "removing" it, then re-inserting. + if (info.existingItem.parentGuid == newParent.guid) { + index--; + } + } + + info.updatedItem = await updateBookmark( + db, + { lastModified, index }, + info.existingItem, + info.currIndex, + newParent, + syncChangeDelta + ); + info.newParent = newParent; + + // For items moving within the same folder, we have to keep track + // of their indexes. Otherwise we run the risk of not correctly + // updating the indexes of other items in the folder. + // This section simulates the database write in moveBookmark, which + // allows us to avoid re-reading the database. + if (info.existingItem.parentGuid == newParent.guid) { + let sign = index < info.currIndex ? 1 : -1; + for (let j = 0; j < updateInfos.length; j++) { + if (j == i) { + continue; + } + if ( + updateInfos[j].currIndex >= + Math.min(info.currIndex, index) && + updateInfos[j].currIndex <= Math.max(info.currIndex, index) + ) { + updateInfos[j].currIndex += sign; + } + } + } + info.currIndex = index; + + // We only bump the parent count if we're moving from a different folder. + if (info.existingItem.parentGuid != newParent.guid) { + newParentChildCount++; + } + index++; + } + + await setAncestorsLastModified( + db, + newParent.guid, + lastModified, + syncChangeDelta + ); + }); + } + ); + + const notifications = []; + + // Updates complete, time to notify everyone. + for (let { updatedItem, existingItem, newParent } of updateInfos) { + // If the item was moved, notify bookmark-moved. + // We use the updatedItem.index here, rather than currIndex, as the views + // need to know where we inserted the item as opposed to where it ended + // up. + if ( + existingItem.parentGuid != updatedItem.parentGuid || + existingItem.index != updatedItem.index + ) { + notifications.push( + new PlacesBookmarkMoved({ + id: updatedItem._id, + itemType: updatedItem.type, + url: existingItem.url, + guid: updatedItem.guid, + parentGuid: updatedItem.parentGuid, + source, + index: updatedItem.index, + oldParentGuid: existingItem.parentGuid, + oldIndex: existingItem.index, + isTagging: + updatedItem.parentGuid === Bookmarks.tagsGuid || + newParent.parentGuid === Bookmarks.tagsGuid, + }) + ); + } + // Remove non-enumerable properties. + delete updatedItem.source; + } + + if (notifications.length) { + PlacesObservers.notifyListeners(notifications); + } + + return updateInfos.map(updateInfo => + Object.assign({}, updateInfo.updatedItem) + ); + })(); + }, + + /** + * Removes one or more bookmark-items. + * + * @param guidOrInfo This may be: + * - The globally unique identifier of the item to remove + * - an object representing the item, as defined above + * - an array of objects representing the items to be removed + * @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} + * @resolves when the removal is complete + * @rejects if the provided guid doesn't match any existing bookmark. + * @throws if the arguments are invalid. + */ + remove(guidOrInfo, options = {}) { + let infos = guidOrInfo; + if (!infos) { + throw new Error("Input should be a valid object"); + } + if (!Array.isArray(guidOrInfo)) { + if (typeof guidOrInfo != "object") { + infos = [{ guid: guidOrInfo }]; + } else { + infos = [guidOrInfo]; + } + } + + if (!("source" in options)) { + options.source = Bookmarks.SOURCES.DEFAULT; + } + + let removeInfos = []; + for (let info of infos) { + // Disallow removing the root folders. + if ( + [ + Bookmarks.rootGuid, + Bookmarks.menuGuid, + Bookmarks.toolbarGuid, + Bookmarks.unfiledGuid, + Bookmarks.tagsGuid, + Bookmarks.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("Bookmarks.jsm: remove", info); + removeInfos.push(removeInfo); + } + + return (async function() { + let removeItems = []; + for (let info of removeInfos) { + // We must be able to remove a bookmark even if it has an invalid url. + // In that case the item won't have a url property. + let item = await fetchBookmark(info, { ignoreInvalidURLs: true }); + if (!item) { + throw new Error("No bookmarks found for the provided GUID."); + } + + removeItems.push(item); + } + + await removeBookmarks(removeItems, options); + + // Notify bookmark-removed to listeners. + let notifications = []; + + for (let item of removeItems) { + let isUntagging = item._grandParentId == lazy.PlacesUtils.tagsFolderId; + let url = ""; + if (item.type == Bookmarks.TYPE_BOOKMARK) { + url = item.hasOwnProperty("url") ? item.url.href : null; + } + + notifications.push( + new PlacesBookmarkRemoved({ + id: item._id, + url, + title: item.title, + itemType: item.type, + parentId: item._parentId, + index: item.index, + guid: item.guid, + parentGuid: item.parentGuid, + source: options.source, + isTagging: isUntagging, + isDescendantRemoval: false, + }) + ); + + if (isUntagging) { + for (let entry of await fetchBookmarksByURL(item, { + concurrent: true, + })) { + notifications.push( + new PlacesBookmarkTags({ + id: entry._id, + itemType: entry.type, + url, + guid: entry.guid, + parentGuid: entry.parentGuid, + tags: entry._tags, + lastModified: entry.lastModified, + source: options.source, + isTagging: false, + }) + ); + } + } + } + + PlacesObservers.notifyListeners(notifications); + })(); + }, + + /** + * 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(options = {}) { + if (!options.source) { + options.source = Bookmarks.SOURCES.DEFAULT; + } + + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: eraseEverything", + async function(db) { + let urls; + + await db.executeTransaction(async function() { + urls = await removeFoldersContents( + db, + Bookmarks.userContentRoots, + options + ); + const time = lazy.PlacesUtils.toPRTime(new Date()); + const syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + options.source + ); + for (let folderGuid of Bookmarks.userContentRoots) { + await db.executeCached( + `UPDATE moz_bookmarks SET lastModified = :time, + syncChangeCounter = syncChangeCounter + :syncChangeDelta + WHERE id IN (SELECT id FROM moz_bookmarks WHERE guid = :folderGuid ) + `, + { folderGuid, time, syncChangeDelta } + ); + } + + await lazy.PlacesSyncUtils.bookmarks.resetSyncMetadata( + db, + options.source + ); + }); + + // We don't wait for the frecency calculation. + if (urls && urls.length) { + await lazy.PlacesUtils.keywords.eraseEverything(); + updateFrecency(db, urls).catch(Cu.reportError); + } + } + ); + }, + + /** + * Returns a list of recently bookmarked items. + * Only includes actual bookmarks. Excludes folders, separators and queries. + * + * @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 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. + * - guidPrefix + * retrieves the most recent item with the specified guid prefix. + * To retrieve ALL of the bookmarks for that guid prefix, you must pass + * in an onResult callback, that will be invoked once for each bookmark. + * - tags + * Retrieves the most recent item with all the specified tags. + * The tags are matched in a case-insensitive way. + * To retrieve ALL of the bookmarks having these tags, pass in an + * onResult callback, that will be invoked once for each bookmark. + * Note, there can be multiple bookmarks for the same url, if you need + * unique tagged urls you can filter duplicates by accumulating in a Set. + * + * @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. + * @param options [optional] + * an optional object whose properties describe options for the fetch: + * - concurrent: fetches concurrently to any writes, returning results + * faster. On the negative side, it may return stale + * information missing the currently ongoing write. + * - includePath: additionally fetches the path for the bookmarks. + * This is a potentially expensive operation. When + * set to true, the path property is set on results + * containing an array of {title, guid} objects + * ordered from root to leaf. + * + * @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, options = {}) { + 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 }; + } else if (Object.keys(info).length == 1) { + // Just a faster code path. + if ( + !["url", "guid", "parentGuid", "index", "guidPrefix", "tags"].includes( + Object.keys(info)[0] + ) + ) { + throw new Error(`Unexpected number of conditions provided: 0`); + } + } else { + // 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"), + v => v.hasOwnProperty("guidPrefix"), + v => v.hasOwnProperty("tags"), + ].reduce((old, fn) => (old + fn(info)) | 0, 0); + if (conditionsCount != 1) { + throw new Error( + `Unexpected number of conditions provided: ${conditionsCount}` + ); + } + } + + // Create a new options object with just the support properties, because + // we may augment it and hand it down to other methods. + options = { + concurrent: !!options.concurrent, + includePath: !!options.includePath, + }; + + let behavior = {}; + if (info.hasOwnProperty("parentGuid") || info.hasOwnProperty("index")) { + behavior = { + parentGuid: { requiredIf: b => b.hasOwnProperty("index") }, + index: { + validIf: b => + (typeof b.index == "number" && b.index >= 0) || + b.index == this.DEFAULT_INDEX, + }, + }; + } + + // Even if we ignore any other unneeded property, we still validate any + // known property to reduce likelihood of hidden bugs. + let fetchInfo = validateBookmarkObject( + "Bookmarks.jsm: fetch", + info, + behavior + ); + + return (async function() { + let results; + if (fetchInfo.hasOwnProperty("url")) { + results = await fetchBookmarksByURL(fetchInfo, options); + } else if (fetchInfo.hasOwnProperty("guid")) { + results = await fetchBookmark(fetchInfo, options); + } else if (fetchInfo.hasOwnProperty("parentGuid")) { + if (fetchInfo.hasOwnProperty("index")) { + results = await fetchBookmarkByPosition(fetchInfo, options); + } else { + results = await fetchBookmarksByParentGUID(fetchInfo, options); + } + } else if (fetchInfo.hasOwnProperty("guidPrefix")) { + results = await fetchBookmarksByGUIDPrefix(fetchInfo, options); + } else if (fetchInfo.hasOwnProperty("tags")) { + results = await fetchBookmarksByTags(fetchInfo, options); + } + + if (!results) { + return null; + } + + if (!Array.isArray(results)) { + results = [results]; + } + // Remove non-enumerable properties. + results = results.map(r => Object.assign({}, r)); + + if (options.includePath) { + for (let result of results) { + let folderPath = await retrieveFullBookmarkPath(result.parentGuid); + if (folderPath) { + result.path = folderPath; + } + } + } + + // 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) { + console.error(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"); + }, + + /** + * Fetch all the existing tags, sorted alphabetically. + * @return {Promise} resolves to an array of objects representing tags, when + * fetching is complete. + * Each object looks like { + * name: the name of the tag, + * count: number of bookmarks with this tag + * } + */ + async fetchTags() { + // TODO: Once the tagging API is implemented in Bookmarks.jsm, we can cache + // the list of tags, instead of querying every time. + let db = await lazy.PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + ` + SELECT b.title AS name, count(*) AS count + FROM moz_bookmarks b + JOIN moz_bookmarks p ON b.parent = p.id + JOIN moz_bookmarks c ON c.parent = b.id + WHERE p.guid = :tagsGuid + GROUP BY name + ORDER BY name COLLATE nocase ASC + `, + { tagsGuid: this.tagsGuid } + ); + return rows.map(r => ({ + name: r.getResultByName("name"), + count: r.getResultByName("count"), + })); + }, + + /** + * 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, and the current child list is already in order with + * respect to orderedChildrenGuids, no change is made. Otherwise, the + * new items are appended but maintain their current order relative to + * eachother. + * @param {Object} [options={}] + * Additional options. Currently supports the following properties: + * - lastModified: The last modified time to use for the folder and + reordered children. Defaults to the current time. + * - 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 }; + info = validateBookmarkObject("Bookmarks.jsm: reorder", info, { + guid: { required: true }, + }); + + if (!Array.isArray(orderedChildrenGuids) || !orderedChildrenGuids.length) { + throw new Error("Must provide a sorted array of children GUIDs."); + } + try { + orderedChildrenGuids.forEach(lazy.PlacesUtils.BOOKMARK_VALIDATORS.guid); + } catch (ex) { + throw new Error("Invalid GUID found in the sorted children array."); + } + + options.source = + "source" in options + ? lazy.PlacesUtils.BOOKMARK_VALIDATORS.source(options.source) + : Bookmarks.SOURCES.DEFAULT; + options.lastModified = + "lastModified" in options + ? lazy.PlacesUtils.BOOKMARK_VALIDATORS.lastModified( + options.lastModified + ) + : new Date(); + + return (async () => { + let parent = await fetchBookmark(info); + if (!parent || parent.type != this.TYPE_FOLDER) { + throw new Error("No folder found for the provided GUID."); + } + if (parent._childCount == 0) { + return; + } + + let sortedChildren = await reorderChildren( + parent, + orderedChildrenGuids, + options + ); + + const notifications = []; + for (let child of sortedChildren) { + notifications.push( + new PlacesBookmarkMoved({ + id: child._id, + itemType: child.type, + url: child.url?.href, + guid: child.guid, + parentGuid: child.parentGuid, + source: options.source, + index: child.index, + oldParentGuid: child.parentGuid, + oldIndex: child._oldIndex, + isTagging: + child.parentGuid === Bookmarks.tagsGuid || + parent.parentGuid === Bookmarks.tagsGuid, + }) + ); + } + if (notifications.length) { + PlacesObservers.notifyListeners(notifications); + } + })(); + }, + + /** + * 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. + * + * Note also that this used to exclude separators but no longer does so. + * + * 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 }; + } + 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" || URL.isInstance(query.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 queryBookmarks(query); + }, +}); + +// Globals. + +// Update implementation. + +/** + * Updates a single bookmark in the database. This should be called from within + * a transaction. + * + * @param {Object} db The pre-existing database connection. + * @param {Object} info A bookmark-item structure with new properties. + * @param {Object} item A bookmark-item structure representing the existing bookmark. + * @param {Integer} oldIndex The index of the item in the old parent. + * @param {Object} newParent The new parent folder (note: this may be the same as) + * the existing folder. + * @param {Integer} syncChangeDelta The change delta to be applied. + */ +async function updateBookmark( + db, + info, + item, + oldIndex, + newParent, + syncChangeDelta +) { + let tuples = new Map(); + tuples.set("lastModified", { + value: lazy.PlacesUtils.toPRTime(info.lastModified), + }); + if (info.hasOwnProperty("title")) { + tuples.set("title", { + value: info.title, + fragment: `title = NULLIF(:title, '')`, + }); + } + if (info.hasOwnProperty("dateAdded")) { + tuples.set("dateAdded", { + value: lazy.PlacesUtils.toPRTime(info.dateAdded), + }); + } + + if (info.hasOwnProperty("url")) { + // Ensure a page exists in moz_places for this URL. + await lazy.PlacesUtils.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)", + }); + } + + let newIndex = info.hasOwnProperty("index") ? info.index : item.index; + if (newParent) { + // For simplicity, update the index regardless. + tuples.set("position", { value: newIndex }); + + // For moving within the same parent, we've already updated the indexes. + 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. + // Only the parent needs a sync change, which is handled in + // `setAncestorsLastModified`. + await db.executeCached( + `UPDATE moz_bookmarks + SET position = CASE WHEN :newIndex < :currIndex + THEN position + 1 + ELSE position - 1 + END + WHERE parent = :newParentId + AND position BETWEEN :lowIndex AND :highIndex + `, + { + newIndex, + currIndex: oldIndex, + newParentId: newParent._id, + lowIndex: Math.min(oldIndex, newIndex), + highIndex: Math.max(oldIndex, newIndex), + } + ); + } else { + // Moving across different containers. In this case, both parents and + // the child need sync changes. `setAncestorsLastModified`, below and in + // `update` and `moveToFolder`, handles the parents. The `needsSyncChange` + // check below handles the child. + tuples.set("parent", { value: newParent._id }); + await db.executeCached( + `UPDATE moz_bookmarks SET position = position - 1 + WHERE parent = :oldParentId + AND position >= :oldIndex + `, + { oldParentId: item._parentId, oldIndex } + ); + await db.executeCached( + `UPDATE moz_bookmarks SET position = position + 1 + WHERE parent = :newParentId + AND position >= :newIndex + `, + { newParentId: newParent._id, newIndex } + ); + + await setAncestorsLastModified( + db, + item.parentGuid, + info.lastModified, + syncChangeDelta + ); + } + } + + if (syncChangeDelta) { + // Sync stores child indices in the parent's record, so we only bump the + // item's counter if we're updating at least one more property in + // addition to the index, last modified time, and dateAdded. + let sizeThreshold = 1; + if (newIndex != oldIndex) { + ++sizeThreshold; + } + if (tuples.has("dateAdded")) { + ++sizeThreshold; + } + let needsSyncChange = tuples.size > sizeThreshold; + if (needsSyncChange) { + tuples.set("syncChangeDelta", { + value: syncChangeDelta, + fragment: "syncChangeCounter = syncChangeCounter + :syncChangeDelta", + }); + } + } + + let isTagging = item._grandParentId == lazy.PlacesUtils.tagsFolderId; + if (isTagging) { + // If we're updating a tag entry, bump the sync change counter for + // bookmarks with the tagged URL. + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + item.url, + syncChangeDelta + ); + if (info.hasOwnProperty("url")) { + // Changing the URL of a tag entry is equivalent to untagging the + // old URL and tagging the new one, so we bump the change counter + // for the new URL here. + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + info.url, + syncChangeDelta + ); + } + } + + let isChangingTagFolder = item._parentId == lazy.PlacesUtils.tagsFolderId; + if (isChangingTagFolder && syncChangeDelta) { + // If we're updating a tag folder (for example, changing a tag's title), + // bump the change counter for all tagged bookmarks. + await db.executeCached( + ` + UPDATE moz_bookmarks SET + syncChangeCounter = syncChangeCounter + :syncChangeDelta + WHERE type = :type AND + fk = (SELECT fk FROM moz_bookmarks WHERE parent = :parent) + `, + { syncChangeDelta, type: Bookmarks.TYPE_BOOKMARK, parent: item._id } + ); + } + + await 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: item.guid }, + [...tuples.entries()].reduce((p, c) => { + p[c[0]] = c[1].value; + return p; + }, {}) + ) + ); + + if (newParent) { + if (newParent.guid == item.parentGuid) { + // Mark all affected separators as changed + // Also bumps the change counter if the item itself is a separator + const startIndex = Math.min(newIndex, oldIndex); + await adjustSeparatorsSyncCounter( + db, + newParent._id, + startIndex, + syncChangeDelta + ); + } else { + // Mark all affected separators as changed + await adjustSeparatorsSyncCounter( + db, + item._parentId, + oldIndex, + syncChangeDelta + ); + await adjustSeparatorsSyncCounter( + db, + newParent._id, + newIndex, + syncChangeDelta + ); + } + // Remove the Sync orphan annotation from reparented items. We don't + // notify annotation observers about this because this is a temporary, + // internal anno that's only used by Sync. + await db.executeCached( + `DELETE FROM moz_items_annos + WHERE anno_attribute_id = (SELECT id FROM moz_anno_attributes + WHERE name = :orphanAnno) AND + item_id = :id`, + { + orphanAnno: lazy.PlacesSyncUtils.bookmarks.SYNC_PARENT_ANNO, + id: item._id, + } + ); + } + + // If the parent changed, update related non-enumerable properties. + let additionalParentInfo = {}; + if (newParent) { + additionalParentInfo.parentGuid = newParent.guid; + Object.defineProperty(additionalParentInfo, "_parentId", { + value: newParent._id, + enumerable: false, + }); + Object.defineProperty(additionalParentInfo, "_grandParentId", { + value: newParent._parentId, + enumerable: false, + }); + } + + return mergeIntoNewObject(item, info, additionalParentInfo); +} + +// Insert implementation. + +function insertBookmark(item, parent) { + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: insertBookmark", + 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. + let hasExistingGuid = item.hasOwnProperty("guid"); + if (!hasExistingGuid) { + item.guid = lazy.PlacesUtils.history.makeGuid(); + } + + let isTagging = parent._parentId == lazy.PlacesUtils.tagsFolderId; + + await db.executeTransaction(async 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`. + await lazy.PlacesUtils.maybeInsertPlace(db, item.url); + } + + // Adjust indices. + await db.executeCached( + `UPDATE moz_bookmarks SET position = position + 1 + WHERE parent = :parent + AND position >= :index + `, + { parent: parent._id, index: item.index } + ); + + let syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + item.source + ); + let syncStatus = lazy.PlacesSyncUtils.bookmarks.determineInitialSyncStatus( + item.source + ); + + // Insert the bookmark into the database. + await db.executeCached( + `INSERT INTO moz_bookmarks (fk, type, parent, position, title, + dateAdded, lastModified, guid, + syncChangeCounter, syncStatus) + VALUES (CASE WHEN :url ISNULL THEN NULL ELSE (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) END, + :type, :parent, :index, NULLIF(:title, ''), :date_added, + :last_modified, :guid, :syncChangeCounter, :syncStatus) + `, + { + url: item.hasOwnProperty("url") ? item.url.href : null, + type: item.type, + parent: parent._id, + index: item.index, + title: item.title, + date_added: lazy.PlacesUtils.toPRTime(item.dateAdded), + last_modified: lazy.PlacesUtils.toPRTime(item.lastModified), + guid: item.guid, + syncChangeCounter: syncChangeDelta, + syncStatus, + } + ); + + // Mark all affected separators as changed + await adjustSeparatorsSyncCounter( + db, + parent._id, + item.index + 1, + syncChangeDelta + ); + + if (hasExistingGuid) { + // Remove stale tombstones if we're reinserting an item. + await db.executeCached( + `DELETE FROM moz_bookmarks_deleted WHERE guid = :guid`, + { guid: item.guid } + ); + } + + if (isTagging) { + // New tag entry; bump the change counter for bookmarks with the + // tagged URL. + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + item.url, + syncChangeDelta + ); + } + + await setAncestorsLastModified( + db, + item.parentGuid, + item.dateAdded, + syncChangeDelta + ); + }); + + // If not a tag recalculate frecency... + if (item.type == Bookmarks.TYPE_BOOKMARK && !isTagging) { + // ...though we don't wait for the calculation. + updateFrecency(db, [item.url]).catch(Cu.reportError); + } + + return item; + } + ); +} + +function insertBookmarkTree(items, source, parent, urls, lastAddedForParent) { + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: insertBookmarkTree", + async function(db) { + await db.executeTransaction(async function transaction() { + await lazy.PlacesUtils.maybeInsertManyPlaces(db, urls); + + let syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + source + ); + let syncStatus = lazy.PlacesSyncUtils.bookmarks.determineInitialSyncStatus( + source + ); + + let rootId = parent._id; + + items = items.map(item => ({ + url: item.url && item.url.href, + type: item.type, + parentGuid: item.parentGuid, + index: item.index, + title: item.title, + date_added: lazy.PlacesUtils.toPRTime(item.dateAdded), + last_modified: lazy.PlacesUtils.toPRTime(item.lastModified), + guid: item.guid, + syncChangeCounter: syncChangeDelta, + syncStatus, + rootId, + })); + await db.executeCached( + `INSERT INTO moz_bookmarks (fk, type, parent, position, title, + dateAdded, lastModified, guid, + syncChangeCounter, syncStatus) + VALUES (CASE WHEN :url ISNULL THEN NULL ELSE (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) END, :type, + (SELECT id FROM moz_bookmarks WHERE guid = :parentGuid), + IFNULL(:index, (SELECT count(*) FROM moz_bookmarks WHERE parent = :rootId)), + NULLIF(:title, ''), :date_added, :last_modified, :guid, + :syncChangeCounter, :syncStatus)`, + items + ); + + // Remove stale tombstones for new items. + for (let chunk of lazy.PlacesUtils.chunkArray( + items, + db.variableLimit + )) { + await db.executeCached( + `DELETE FROM moz_bookmarks_deleted + WHERE guid IN (${lazy.PlacesUtils.sqlBindPlaceholders(chunk)})`, + chunk.map(item => item.guid) + ); + } + + await setAncestorsLastModified( + db, + parent.guid, + lastAddedForParent, + syncChangeDelta + ); + }); + + // We don't wait for the frecency calculation. + updateFrecency(db, urls).catch(Cu.reportError); + + return items; + } + ); +} + +/** + * Handles special data on a bookmark, e.g. annotations, keywords, tags, charsets, + * inserting the data into the appropriate place. + * + * @param {Integer} itemId The ID of the item within the bookmarks database. + * @param {Object} item The bookmark item with possible special data to be inserted. + */ +async function handleBookmarkItemSpecialData(itemId, item) { + if ("keyword" in item && item.keyword) { + try { + await lazy.PlacesUtils.keywords.insert({ + keyword: item.keyword, + url: item.url, + postData: item.postData, + source: item.source, + }); + } catch (ex) { + console.error( + `Failed to insert keyword "${item.keyword} for ${item.url}": ${ex}` + ); + } + } + if ("tags" in item) { + try { + lazy.PlacesUtils.tagging.tagURI( + lazy.NetUtil.newURI(item.url), + item.tags, + item.source + ); + } catch (ex) { + // Invalid tag child, skip it. + console.error( + `Unable to set tags "${item.tags.join(", ")}" for ${item.url}: ${ex}` + ); + } + } + if ("charset" in item && item.charset) { + try { + // UTF-8 is the default. If we are passed the value then set it to null, + // to ensure any charset is removed from the database. + let charset = item.charset; + if (item.charset.toLowerCase() == "utf-8") { + charset = null; + } + + await lazy.PlacesUtils.history.update({ + url: item.url, + annotations: new Map([[lazy.PlacesUtils.CHARSET_ANNO, charset]]), + }); + } catch (ex) { + console.error( + `Failed to set charset "${item.charset}" for ${item.url}: ${ex}` + ); + } + } +} + +// Query implementation. + +async function queryBookmarks(info) { + let queryParams = { + tags_folder: await promiseTagsFolderId(), + }; + // We're searching for bookmarks, so exclude tags. + let queryString = "WHERE b.parent <> :tags_folder"; + queryString += " AND 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, NULL) "; + queryParams.query = info.query; + queryParams.matchBehavior = MATCH_ANYWHERE_UNMODIFIED; + queryParams.searchBehavior = BEHAVIOR_BOOKMARK; + } + + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: queryBookmarks", + 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 = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, + IFNULL(b.title, '') AS title, h.url AS url, b.parent, p.parent, + NULL AS _id, + NULL AS _childCount, + NULL AS _grandParentId, + NULL AS _parentId, + NULL AS _syncStatus + 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); + } + ); +} + +/** + * Internal fetch implementation. + * @param {object} info + * The bookmark item to remove. + * @param {object} options + * An options object supporting the following properties: + * @param {object} [options.concurrent] + * Whether to use the concurrent read-only connection. + * @param {object} [options.db] + * A specific connection to be used. + * @param {object} [options.ignoreInvalidURLs] + * Whether invalid URLs should be ignored or throw an exception. + * + */ +async function fetchBookmark(info, options = {}) { + let query = async function(db) { + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS 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, b.syncStatus AS _syncStatus + 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, !!options.ignoreInvalidURLs)[0] + : null; + }; + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + if (options.db) { + return query(options.db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchBookmark", + query + ); +} + +async function fetchBookmarkByPosition(info, options = {}) { + let query = async function(db) { + let index = info.index == Bookmarks.DEFAULT_INDEX ? null : info.index; + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS 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, b.syncStatus AS _syncStatus + 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; + }; + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchBookmarkByPosition", + query + ); +} + +async function fetchBookmarksByTags(info, options = {}) { + let query = async function(db) { + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, + h.url AS url, b.id AS _id, b.parent AS _parentId, + NULL AS _childCount, + p.parent AS _grandParentId, b.syncStatus AS _syncStatus, + (SELECT group_concat(pp.title) + FROM moz_bookmarks bb + JOIN moz_bookmarks pp ON bb.parent = pp.id + JOIN moz_bookmarks gg ON pp.parent = gg.id AND gg.guid = ? + WHERE bb.fk = h.id) AS _tags + FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + JOIN moz_bookmarks g ON g.id = p.parent + JOIN moz_places h ON h.id = b.fk + WHERE g.guid <> ? AND b.fk IN ( + SELECT b2.fk FROM moz_bookmarks b2 + JOIN moz_bookmarks p2 ON p2.id = b2.parent + JOIN moz_bookmarks g2 ON g2.id = p2.parent + WHERE g2.guid = ? + AND lower(p2.title) IN ( + ${new Array(info.tags.length).fill("?").join(",")} + ) + GROUP BY b2.fk HAVING count(*) = ${info.tags.length} + ) + ORDER BY b.lastModified DESC + `, + [Bookmarks.tagsGuid, Bookmarks.tagsGuid, Bookmarks.tagsGuid].concat( + info.tags.map(t => t.toLowerCase()) + ) + ); + + return rows.length ? rowsToItemsArray(rows) : null; + }; + + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchBookmarksByTags", + query + ); +} + +async function fetchBookmarksByGUIDPrefix(info, options = {}) { + let query = async function(db) { + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, + h.url AS url, b.id AS _id, b.parent AS _parentId, + NULL AS _childCount, + p.parent AS _grandParentId, b.syncStatus AS _syncStatus + 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 LIKE :guidPrefix + ORDER BY b.lastModified DESC + `, + { guidPrefix: info.guidPrefix + "%" } + ); + + return rows.length ? rowsToItemsArray(rows) : null; + }; + + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchBookmarksByGUIDPrefix", + query + ); +} + +async function fetchBookmarksByURL(info, options = {}) { + let query = async function(db) { + let tagsFolderId = await promiseTagsFolderId(); + let rows = await 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, IFNULL(b.title, '') AS title, + h.url AS url, b.id AS _id, b.parent AS _parentId, + NULL AS _childCount, /* Unused for now */ + p.parent AS _grandParentId, b.syncStatus AS _syncStatus, + (SELECT group_concat(pp.title) + FROM moz_bookmarks bb + JOIN moz_bookmarks pp ON bb.parent = pp.id + JOIN moz_bookmarks gg ON pp.parent = gg.id AND gg.guid = :tagsGuid + WHERE bb.fk = h.id) AS _tags + FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + JOIN moz_places h ON h.id = b.fk + WHERE h.url_hash = hash(:url) AND h.url = :url + AND _grandParentId <> :tagsFolderId + ORDER BY b.lastModified DESC + `, + { url: info.url.href, tagsFolderId, tagsGuid: Bookmarks.tagsGuid } + ); + + return rows.length ? rowsToItemsArray(rows) : null; + }; + + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchBookmarksByURL", + query + ); +} + +async function fetchBookmarksByParentGUID(info, options = {}) { + let query = async function(db) { + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS title, + h.url AS url, + NULL AS _id, + NULL AS _parentId, + NULL AS _childCount, + NULL AS _grandParentId, + NULL AS _syncStatus + 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 rows.length ? rowsToItemsArray(rows) : null; + }; + + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchBookmarksByParentGUID", + query + ); +} + +function fetchRecentBookmarks(numberOfItems) { + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: fetchRecentBookmarks", + async function(db) { + let tagsFolderId = await promiseTagsFolderId(); + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, + IFNULL(b.title, '') AS title, h.url AS url, NULL AS _id, + NULL AS _parentId, NULL AS _childCount, NULL AS _grandParentId, + NULL AS _syncStatus + FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + JOIN moz_places h ON h.id = b.fk + WHERE p.parent <> :tagsFolderId + AND b.type = :type + AND url_hash NOT BETWEEN hash("place", "prefix_lo") + AND hash("place", "prefix_hi") + ORDER BY b.dateAdded DESC, b.ROWID DESC + LIMIT :numberOfItems + `, + { + tagsFolderId, + type: Bookmarks.TYPE_BOOKMARK, + numberOfItems, + } + ); + + return rows.length ? rowsToItemsArray(rows) : []; + } + ); +} + +async function fetchBookmarksByParent(db, info) { + let rows = await db.executeCached( + `SELECT b.guid, IFNULL(p.guid, '') AS parentGuid, b.position AS 'index', + b.dateAdded, b.lastModified, b.type, IFNULL(b.title, '') AS 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, b.syncStatus AS _syncStatus + 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 removeBookmarks(items, options) { + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: removeBookmarks", + async function(db) { + let urls = []; + + await db.executeTransaction(async function transaction() { + // We use a map for its de-duplication properties. + let parents = new Map(); + let syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + options.source + ); + + for (let item of items) { + parents.set(item.parentGuid, item._parentId); + + // 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."); + } + urls = urls.concat( + await removeFoldersContents(db, [item.guid], options) + ); + } + } + + for (let chunk of lazy.PlacesUtils.chunkArray( + items, + db.variableLimit + )) { + // 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. + await removeAnnotationsForItems(db, chunk); + + // Remove the bookmarks. + await db.executeCached( + `DELETE FROM moz_bookmarks + WHERE guid IN (${lazy.PlacesUtils.sqlBindPlaceholders(chunk)})`, + chunk.map(item => item.guid) + ); + } + + for (let [parentGuid, parentId] of parents.entries()) { + // Now recalculate the positions. + await db.executeCached( + `WITH positions(id, pos, seq) AS ( + SELECT id, position AS pos, + (row_number() OVER (ORDER BY position)) - 1 AS seq + FROM moz_bookmarks + WHERE parent = :parentId + ) + UPDATE moz_bookmarks + SET position = (SELECT seq FROM positions WHERE positions.id = moz_bookmarks.id) + WHERE id IN (SELECT id FROM positions WHERE seq <> pos) + `, + { parentId } + ); + + // Mark this parent as changed. + await setAncestorsLastModified( + db, + parentGuid, + new Date(), + syncChangeDelta + ); + } + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + // For the notifications, we may need to adjust indexes if there are more + // than one of the same item in the folder. This makes sure that we notify + // the index of the item when it was removed, rather than the original index. + for (let j = i + 1; j < items.length; j++) { + if ( + items[j]._parentId == item._parentId && + items[j].index > item.index + ) { + items[j].index--; + } + } + if (item._grandParentId == lazy.PlacesUtils.tagsFolderId) { + // If we're removing a tag entry, increment the change counter for all + // bookmarks with the tagged URL. + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + item.url, + syncChangeDelta + ); + } + + await adjustSeparatorsSyncCounter( + db, + item._parentId, + item.index, + syncChangeDelta + ); + } + + // Write tombstones for the removed items. + await insertTombstones(db, items, syncChangeDelta); + }); + + // Update the frecencies outside of the transaction, excluding tags, so that + // the updates can progress in the background. + urls = urls.concat( + items + .filter(item => { + let isUntagging = + item._grandParentId == lazy.PlacesUtils.tagsFolderId; + return !isUntagging && "url" in item; + }) + .map(item => item.url) + ); + + if (urls.length) { + await lazy.PlacesUtils.keywords.removeFromURLsIfNotBookmarked(urls); + updateFrecency(db, urls).catch(Cu.reportError); + } + } + ); +} + +// Reorder implementation. + +function reorderChildren(parent, orderedChildrenGuids, options) { + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: reorderChildren", + db => + db.executeTransaction(async function() { + // Fetch old indices for the notifications. + const oldIndices = new Map(); + ( + await db.executeCached( + `SELECT guid, position FROM moz_bookmarks WHERE parent = :parentId`, + { parentId: parent._id } + ) + ).forEach(r => + oldIndices.set( + r.getResultByName("guid"), + r.getResultByName("position") + ) + ); + // By the time the caller collects guids and the time reorder is invoked + // new bookmarks may appear, and the passed-in list becomes incomplete. + // To avoid unnecessary work then skip reorder if children are already + // in the requested sort order. + let lastIndex = 0, + needReorder = false; + for (let guid of orderedChildrenGuids) { + let requestedIndex = oldIndices.get(guid); + if (requestedIndex === undefined) { + // doesn't exist, just ignore it. + continue; + } + if (requestedIndex < lastIndex) { + needReorder = true; + break; + } + lastIndex = requestedIndex; + } + if (!needReorder) { + return []; + } + + const valuesFragment = orderedChildrenGuids + .map((g, i) => `("${g}", ${i})`) + .join(); + await db.execute( + `UPDATE moz_bookmarks + SET position = sorted.pos, + lastModified = :lastModified + FROM ( + WITH fixed(guid, pos) AS ( + VALUES ${valuesFragment} + ) + SELECT b.id, + row_number() OVER (ORDER BY CASE WHEN fixed.pos IS NULL THEN 1 ELSE 0 END ASC, fixed.pos ASC, position ASC) - 1 AS pos + FROM moz_bookmarks b + LEFT JOIN fixed ON b.guid = fixed.guid + WHERE parent = :parentId + ) AS sorted + WHERE sorted.id = moz_bookmarks.id`, + { + parentId: parent._id, + lastModified: lazy.PlacesUtils.toPRTime(options.lastModified), + } + ); + + let syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + options.source + ); + await setAncestorsLastModified( + db, + parent.guid, + options.lastModified, + syncChangeDelta + ); + // Fetch bookmarks for the notifications, adding _oldIndex to each. + return ( + await fetchBookmarksByParent(db, { + parentGuid: parent.guid, + }) + ).map(c => { + // We're not returning these objects to the caller, but just in case + // we'd decide to do it in the future, make sure this will be removed. + // See rowsToItemsArray() for additional details. + Object.defineProperty(c, "_oldIndex", { + value: oldIndices.get(c.guid) || 0, + enumerable: false, + configurable: true, + }); + return c; + }); + }) + ); +} + +// 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 {Array} rows + * the array of mozIStorageRow objects. + * @param {Boolean} ignoreInvalidURLs + * whether to ignore invalid urls (leaving the url property undefined) + * or throw. + * @return an array of bookmark objects. + */ +function rowsToItemsArray(rows, ignoreInvalidURLs = false) { + return rows.map(row => { + let item = {}; + for (let prop of ["guid", "index", "type", "title"]) { + item[prop] = row.getResultByName(prop); + } + for (let prop of ["dateAdded", "lastModified"]) { + let value = row.getResultByName(prop); + if (value) { + item[prop] = lazy.PlacesUtils.toDate(value); + } + } + let parentGuid = row.getResultByName("parentGuid"); + if (parentGuid) { + item.parentGuid = parentGuid; + } + let url = row.getResultByName("url"); + if (url) { + try { + item.url = new URL(url); + } catch (ex) { + if (!ignoreInvalidURLs) { + throw ex; + } + } + } + + // All the private properties below this point 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. + + for (let prop of [ + "_id", + "_parentId", + "_childCount", + "_grandParentId", + "_syncStatus", + ]) { + let val = row.getResultByName(prop); + if (val !== null) { + Object.defineProperty(item, prop, { + value: val, + enumerable: false, + configurable: true, + }); + } + } + + try { + let tags = row.getResultByName("_tags"); + Object.defineProperty(item, "_tags", { + value: tags + ? tags + .split(",") + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())) + : [], + enumerable: false, + configurable: true, + }); + } catch (ex) { + // `tags` not fetched, don't add it. + } + + return item; + }); +} + +function validateBookmarkObject(name, input, behavior) { + return lazy.PlacesUtils.validateItemProperties( + name, + lazy.PlacesUtils.BOOKMARK_VALIDATORS, + input, + behavior + ); +} + +/** + * Updates frecency for a list of URLs. + * + * @param db + * the Sqlite.sys.mjs connection handle. + * @param urls + * the array of URLs to update. + */ +var updateFrecency = async function(db, urls) { + let hrefs = urls.map(url => url.href); + // We just use the hashes, since updating a few additional urls won't hurt. + for (let chunk of lazy.PlacesUtils.chunkArray(hrefs, db.variableLimit)) { + await db.execute( + `UPDATE moz_places + SET hidden = (url_hash BETWEEN hash("place", "prefix_lo") AND hash("place", "prefix_hi")), + frecency = CALCULATE_FRECENCY(id) + WHERE url_hash IN (${lazy.PlacesUtils.sqlBindPlaceholders( + chunk, + "hash(", + ")" + )})`, + chunk + ); + } + + // Trigger frecency updates for all affected origins. + await db.executeCached(`DELETE FROM moz_updateoriginsupdate_temp`); + + PlacesObservers.notifyListeners([new PlacesRanking()]); +}; + +/** + * Removes any orphan annotation entries. + * + * @param db + * the Sqlite.sys.mjs connection handle. + */ +var removeOrphanAnnotations = async function(db) { + await 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) + ` + ); + await 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.sys.mjs connection handle. + * @param items + * The items for which to remove annotations. + */ +var removeAnnotationsForItems = async function(db, items) { + // Remove the annotations. + let itemIds = items.map(item => item._id); + await db.executeCached( + `DELETE FROM moz_items_annos + WHERE item_id IN (${lazy.PlacesUtils.sqlBindPlaceholders(itemIds)})`, + itemIds + ); + await 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.sys.mjs 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 = async function( + db, + folderGuid, + time, + syncChangeDelta +) { + await 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: lazy.PlacesUtils.toPRTime(time), + } + ); + + if (syncChangeDelta) { + // Flag the folder as having a change. + await db.executeCached( + ` + UPDATE moz_bookmarks SET + syncChangeCounter = syncChangeCounter + :syncChangeDelta + WHERE guid = :guid`, + { guid: folderGuid, syncChangeDelta } + ); + } +}; + +/** + * Remove all descendants of one or more bookmark folders. + * + * @param {Object} db + * the Sqlite.sys.mjs connection handle. + * @param {Array} folderGuids + * array of folder guids. + * @return {Array} + * An array of urls that will need to be updated for frecency. These + * are returned rather than updated immediately so that the caller + * can decide when they need to be updated - they do not need to + * stop this function from completing. + */ +var removeFoldersContents = async function(db, folderGuids, options) { + let syncChangeDelta = lazy.PlacesSyncUtils.bookmarks.determineSyncChangeDelta( + options.source + ); + + let itemsRemoved = []; + for (let folderGuid of folderGuids) { + let rows = await 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, IFNULL(b.title, '') AS title, + p.parent AS _grandParentId, NULL AS _childCount, + b.syncStatus AS _syncStatus + FROM descendants + /* The usage of CROSS JOIN is not random, it tells the optimizer + to retain the original rows order, so the hierarchy is respected */ + CROSS 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, true)); + + await 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 } + ); + } + + // Write tombstones for removed items. + await insertTombstones(db, itemsRemoved, syncChangeDelta); + + // Bump the change counter for all tagged bookmarks when removing tag + // folders. + await addSyncChangesForRemovedTagFolders(db, itemsRemoved, syncChangeDelta); + + // Cleanup orphans. + await removeOrphanAnnotations(db); + + // TODO (Bug 1087576): this may leave orphan tags behind. + + // 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 = Bookmarks.SOURCES.DEFAULT } = options; + let notifications = []; + for (let item of itemsRemoved.reverse()) { + let isUntagging = item._grandParentId == lazy.PlacesUtils.tagsFolderId; + let url = ""; + if (item.type == Bookmarks.TYPE_BOOKMARK) { + url = item.hasOwnProperty("url") ? item.url.href : null; + } + notifications.push( + new PlacesBookmarkRemoved({ + id: item._id, + url, + title: item.title, + parentId: item._parentId, + index: item.index, + itemType: item.type, + guid: item.guid, + parentGuid: item.parentGuid, + source, + isTagging: isUntagging, + isDescendantRemoval: !lazy.PlacesUtils.bookmarks.userContentRoots.includes( + item.parentGuid + ), + }) + ); + + if (isUntagging) { + for (let entry of await fetchBookmarksByURL(item, true)) { + notifications.push( + new PlacesBookmarkTags({ + id: entry._id, + itemType: entry.type, + url, + guid: entry.guid, + parentGuid: entry.parentGuid, + tags: entry._tags, + lastModified: entry.lastModified, + source, + isTagging: false, + }) + ); + } + } + } + + if (notifications.length) { + PlacesObservers.notifyListeners(notifications); + } + + return itemsRemoved.filter(item => "url" in item).map(item => item.url); +}; + +// Indicates whether we should write a tombstone for an item that has been +// uploaded to the server. We ignore "NEW" and "UNKNOWN" items: "NEW" items +// haven't been uploaded yet, and "UNKNOWN" items need a full reconciliation +// with the server. +function needsTombstone(item) { + return item._syncStatus == Bookmarks.SYNC_STATUS.NORMAL; +} + +// Inserts tombstones for removed synced items. +function insertTombstones(db, itemsRemoved, syncChangeDelta) { + if (!syncChangeDelta) { + return Promise.resolve(); + } + let syncedItems = itemsRemoved.filter(needsTombstone); + if (!syncedItems.length) { + return Promise.resolve(); + } + let dateRemoved = lazy.PlacesUtils.toPRTime(Date.now()); + let valuesTable = syncedItems + .map( + item => `( + ${JSON.stringify(item.guid)}, + ${dateRemoved} + )` + ) + .join(","); + return db.execute(` + INSERT INTO moz_bookmarks_deleted (guid, dateRemoved) + VALUES ${valuesTable}`); +} + +// Bumps the change counter for all bookmarks with URLs referenced in removed +// tag folders. +var addSyncChangesForRemovedTagFolders = async function( + db, + itemsRemoved, + syncChangeDelta +) { + if (!syncChangeDelta) { + return; + } + for (let item of itemsRemoved) { + let isUntagging = item._grandParentId == lazy.PlacesUtils.tagsFolderId; + if (isUntagging) { + await lazy.PlacesSyncUtils.bookmarks.addSyncChangesForBookmarksWithURL( + db, + item.url, + syncChangeDelta + ); + } + } +}; + +function adjustSeparatorsSyncCounter( + db, + parentId, + startIndex, + syncChangeDelta +) { + if (!syncChangeDelta) { + return Promise.resolve(); + } + + return db.executeCached( + ` + UPDATE moz_bookmarks + SET syncChangeCounter = syncChangeCounter + :delta + WHERE parent = :parent AND position >= :start_index + AND type = :item_type + `, + { + delta: syncChangeDelta, + parent: parentId, + start_index: startIndex, + item_type: Bookmarks.TYPE_SEPARATOR, + } + ); +} + +/** + * Return the full path, from parent to root folder, of a bookmark. + * + * @param guid + * The globally unique identifier of the item to determine the full + * bookmark path for. + * @param options [optional] + * an optional object whose properties describe options for the query: + * - concurrent: Queries concurrently to any writes, returning results + * faster. On the negative side, it may return stale + * information missing the currently ongoing write. + * - db: A specific connection to be used. + * @return {Promise} resolved when the query is complete. + * @resolves to an array of {guid, title} objects that represent the full path + * from parent to root for the passed in bookmark. + * @rejects if an error happens while querying. + */ +async function retrieveFullBookmarkPath(guid, options = {}) { + let query = async function(db) { + let rows = await db.executeCached( + `WITH RECURSIVE parents(guid, _id, _parent, title) AS + (SELECT guid, id AS _id, parent AS _parent, + IFNULL(title, '') AS title + FROM moz_bookmarks + WHERE guid = :pguid + UNION ALL + SELECT b.guid, b.id AS _id, b.parent AS _parent, + IFNULL(b.title, '') AS title + FROM moz_bookmarks b + INNER JOIN parents ON b.id=parents._parent) + SELECT * FROM parents WHERE guid != :rootGuid; + `, + { pguid: guid, rootGuid: lazy.PlacesUtils.bookmarks.rootGuid } + ); + + return rows.reverse().map(r => ({ + guid: r.getResultByName("guid"), + title: r.getResultByName("title"), + })); + }; + + if (options.concurrent) { + let db = await lazy.PlacesUtils.promiseDBConnection(); + return query(db); + } + return lazy.PlacesUtils.withConnectionWrapper( + "Bookmarks.jsm: retrieveFullBookmarkPath", + query + ); +} |