diff options
Diffstat (limited to 'browser/components/places/content/controller.js')
-rw-r--r-- | browser/components/places/content/controller.js | 1745 |
1 files changed, 1745 insertions, 0 deletions
diff --git a/browser/components/places/content/controller.js b/browser/components/places/content/controller.js new file mode 100644 index 0000000000..3c113137df --- /dev/null +++ b/browser/components/places/content/controller.js @@ -0,0 +1,1745 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +/* import-globals-from /browser/base/content/utilityOverlay.js */ +/* import-globals-from ./places.js */ + +/** + * Represents an insertion point within a container where we can insert + * items. + * + * @param {object} options an object containing the following properties: + * @param {string} options.parentGuid + * The unique identifier of the parent container + * @param {number} [options.index] + * The index within the container where to insert, defaults to appending + * @param {number} [options.orientation] + * The orientation of the insertion. NOTE: the adjustments to the + * insertion point to accommodate the orientation should be done by + * the person who constructs the IP, not the user. The orientation + * is provided for informational purposes only! Defaults to DROP_ON. + * @param {string} [options.tagName] + * The tag name if this IP is set to a tag, null otherwise. + * @param {*} [options.dropNearNode] + * When defined index will be calculated based on this node + */ +function PlacesInsertionPoint({ + parentGuid, + index = PlacesUtils.bookmarks.DEFAULT_INDEX, + orientation = Ci.nsITreeView.DROP_ON, + tagName = null, + dropNearNode = null, +}) { + this.guid = parentGuid; + this._index = index; + this.orientation = orientation; + this.tagName = tagName; + this.dropNearNode = dropNearNode; +} + +PlacesInsertionPoint.prototype = { + set index(val) { + this._index = val; + }, + + async getIndex() { + if (this.dropNearNode) { + // If dropNearNode is set up we must calculate the index of the item near + // which we will drop. + let index = ( + await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid) + ).index; + return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1; + } + return this._index; + }, + + get isTag() { + return typeof this.tagName == "string"; + }, +}; + +/** + * Places Controller + */ + +function PlacesController(aView) { + this._view = aView; + ChromeUtils.defineLazyGetter(this, "profileName", function () { + return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "forgetSiteClearByBaseDomain", + "places.forgetThisSite.clearByBaseDomain", + false + ); + ChromeUtils.defineESModuleGetters(this, { + ForgetAboutSite: "resource://gre/modules/ForgetAboutSite.sys.mjs", + }); +} + +PlacesController.prototype = { + /** + * The places view. + */ + _view: null, + + // This is used in certain views to disable user actions on the places tree + // views. This avoids accidental deletion/modification when the user is not + // actually organising the trees. + disableUserActions: false, + + QueryInterface: ChromeUtils.generateQI(["nsIClipboardOwner"]), + + // nsIClipboardOwner + LosingOwnership: function PC_LosingOwnership(aXferable) { + this.cutNodes = []; + }, + + terminate: function PC_terminate() { + this._releaseClipboardOwnership(); + }, + + supportsCommand: function PC_supportsCommand(aCommand) { + if (this.disableUserActions) { + return false; + } + // Non-Places specific commands that we also support + switch (aCommand) { + case "cmd_undo": + case "cmd_redo": + case "cmd_cut": + case "cmd_copy": + case "cmd_paste": + case "cmd_delete": + case "cmd_selectAll": + return true; + } + + // All other Places Commands are prefixed with "placesCmd_" ... this + // filters out other commands that we do _not_ support (see 329587). + const CMD_PREFIX = "placesCmd_"; + return aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX; + }, + + isCommandEnabled: function PC_isCommandEnabled(aCommand) { + // Determine whether or not nodes can be inserted. + let ip = this._view.insertionPoint; + let canInsert = ip && (aCommand.endsWith("_paste") || !ip.isTag); + + switch (aCommand) { + case "cmd_undo": + return PlacesTransactions.topUndoEntry != null; + case "cmd_redo": + return PlacesTransactions.topRedoEntry != null; + case "cmd_cut": + case "placesCmd_cut": + for (let node of this._view.selectedNodes) { + // If selection includes history nodes or tags-as-bookmark, disallow + // cutting. + if ( + node.itemId == -1 || + (node.parent && PlacesUtils.nodeIsTagQuery(node.parent)) + ) { + return false; + } + } + // Otherwise fall through the cmd_delete check. + case "cmd_delete": + case "placesCmd_delete": + case "placesCmd_deleteDataHost": + return this._hasRemovableSelection(); + case "cmd_copy": + case "placesCmd_copy": + case "placesCmd_showInFolder": + return this._view.hasSelection; + case "cmd_paste": + case "placesCmd_paste": + // If the clipboard contains a Places flavor it is definitely pasteable, + // otherwise we also allow pasting "text/plain" and "text/x-moz-url" data. + // We don't check if the data is valid here, because the clipboard may + // contain very large blobs that would largely slowdown commands updating. + // Of course later paste() should ignore any invalid data. + return ( + canInsert && + Services.clipboard.hasDataMatchingFlavors( + [ + ...PlacesUIUtils.PLACES_FLAVORS, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_PLAINTEXT, + ], + Ci.nsIClipboard.kGlobalClipboard + ) + ); + case "cmd_selectAll": + if (this._view.selType != "single") { + let rootNode = this._view.result.root; + if (rootNode.containerOpen && rootNode.childCount > 0) { + return true; + } + } + return false; + case "placesCmd_open": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": { + let selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.nodeIsURI(selectedNode); + } + case "placesCmd_new:folder": + return canInsert; + case "placesCmd_new:bookmark": + return canInsert; + case "placesCmd_new:separator": + return ( + canInsert && + !PlacesUtils.asQuery(this._view.result.root).queryOptions + .excludeItems && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + case "placesCmd_show:info": { + let selectedNode = this._view.selectedNode; + return ( + selectedNode && + !PlacesUtils.isRootItem( + PlacesUtils.getConcreteItemGuid(selectedNode) + ) && + (PlacesUtils.nodeIsTagQuery(selectedNode) || + PlacesUtils.nodeIsBookmark(selectedNode) || + (PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUtils.isQueryGeneratedFolder(selectedNode))) + ); + } + case "placesCmd_sortBy:name": { + let selectedNode = this._view.selectedNode; + return ( + selectedNode && + PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUIUtils.isFolderReadOnly(selectedNode) && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE + ); + } + case "placesCmd_createBookmark": { + return !this._view.selectedNodes.some( + node => !PlacesUtils.nodeIsURI(node) || node.itemId != -1 + ); + } + default: + return false; + } + }, + + doCommand: function PC_doCommand(aCommand) { + if (aCommand != "cmd_delete" && aCommand != "placesCmd_delete") { + // Clear out last removal fingerprint if any other commands arrives. + // This covers sequences like: remove, undo, remove, where the removal + // commands are not immediately adjacent. + this._lastRemoveOperationFingerprint = null; + } + switch (aCommand) { + case "cmd_undo": + PlacesTransactions.undo().catch(console.error); + break; + case "cmd_redo": + PlacesTransactions.redo().catch(console.error); + break; + case "cmd_cut": + case "placesCmd_cut": + this.cut(); + break; + case "cmd_copy": + case "placesCmd_copy": + this.copy(); + break; + case "cmd_paste": + case "placesCmd_paste": + this.paste().catch(console.error); + break; + case "cmd_delete": + case "placesCmd_delete": + this.remove("Remove Selection").catch(console.error); + break; + case "placesCmd_deleteDataHost": + this.forgetAboutThisSite().catch(console.error); + break; + case "cmd_selectAll": + this.selectAll(); + break; + case "placesCmd_open": + PlacesUIUtils.openNodeIn( + this._view.selectedNode, + "current", + this._view + ); + break; + case "placesCmd_open:window": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view); + break; + case "placesCmd_open:privatewindow": + PlacesUIUtils.openNodeIn( + this._view.selectedNode, + "window", + this._view, + true + ); + break; + case "placesCmd_open:tab": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view); + break; + case "placesCmd_new:folder": + this.newItem("folder").catch(console.error); + break; + case "placesCmd_new:bookmark": + this.newItem("bookmark").catch(console.error); + break; + case "placesCmd_new:separator": + this.newSeparator().catch(console.error); + break; + case "placesCmd_show:info": + this.showBookmarkPropertiesForSelection(); + break; + case "placesCmd_sortBy:name": + this.sortFolderByName().catch(console.error); + break; + case "placesCmd_createBookmark": { + const nodes = this._view.selectedNodes.map(node => { + return { + uri: Services.io.newURI(node.uri), + title: node.title, + }; + }); + PlacesUIUtils.showBookmarkPagesDialog( + nodes, + ["keyword", "location"], + window.top + ); + break; + } + case "placesCmd_showInFolder": + this.showInFolder(this._view.selectedNode.bookmarkGuid); + break; + } + }, + + onEvent: function PC_onEvent(eventName) {}, + + /** + * Determine whether or not the selection can be removed, either by the + * delete or cut operations based on whether or not any of its contents + * are non-removable. We don't need to worry about recursion here since it + * is a policy decision that a removable item not be placed inside a non- + * removable item. + * + * @returns {boolean} true if all nodes in the selection can be removed, + * false otherwise. + */ + _hasRemovableSelection() { + var ranges = this._view.removableSelectionRanges; + if (!ranges.length) { + return false; + } + + var root = this._view.result.root; + + for (var j = 0; j < ranges.length; j++) { + var nodes = ranges[j]; + for (var i = 0; i < nodes.length; ++i) { + // Disallow removing the view's root node + if (nodes[i] == root) { + return false; + } + + if (!PlacesUIUtils.canUserRemove(nodes[i])) { + return false; + } + } + } + + return true; + }, + + /** + * This helper can be used to avoid handling repeated remove operations. + * Clear this._lastRemoveOperationFingerprint if another operation happens. + * + * @returns {boolean} whether the removal is the same as the last one. + */ + _isRepeatedRemoveOperation() { + let lastRemoveOperationFingerprint = this._lastRemoveOperationFingerprint; + this._lastRemoveOperationFingerprint = PlacesUtils.sha256( + this._view.selectedNodes.map(n => n.guid ?? n.uri).join() + ); + return ( + lastRemoveOperationFingerprint == this._lastRemoveOperationFingerprint + ); + }, + + /** + * Gathers information about the selected nodes according to the following + * rules: + * "link" node is a URI + * "bookmark" node is a bookmark + * "tagChild" node is a child of a tag + * "folder" node is a folder + * "query" node is a query + * "separator" node is a separator line + * "host" node is a host + * + * @returns {Array} an array of objects corresponding the selected nodes. Each + * object has each of the properties above set if its corresponding + * node matches the rule. In addition, the annotations names for each + * node are set on its corresponding object as properties. + * Notes: + * 1) This can be slow, so don't call it anywhere performance critical! + */ + _buildSelectionMetadata() { + return this._view.selectedNodes.map(n => this._selectionMetadataForNode(n)); + }, + + _selectionMetadataForNode(node) { + let nodeData = {}; + // We don't use the nodeIs* methods here to avoid going through the type + // property way too often + switch (node.type) { + case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY: + nodeData.query = true; + if (node.parent) { + switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + nodeData.query_host = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + nodeData.query_day = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT: + nodeData.query_tag = true; + } + } + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT: + nodeData.folder = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: + nodeData.separator = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI: + nodeData.link = true; + if (PlacesUtils.nodeIsBookmark(node)) { + nodeData.link_bookmark = true; + var parentNode = node.parent; + if (parentNode && PlacesUtils.nodeIsTagQuery(parentNode)) { + nodeData.link_bookmark_tag = true; + } + } + break; + } + return nodeData; + }, + + /** + * Determines if a context-menu item should be shown + * + * @param {object} aMenuItem + * the context menu item + * @param {object} aMetaData + * meta data about the selection + * @returns {boolean} true if the conditions (see buildContextMenu) are satisfied + * and the item can be displayed, false otherwise. + */ + _shouldShowMenuItem(aMenuItem, aMetaData) { + if ( + aMenuItem.hasAttribute("hide-if-private-browsing") && + !PrivateBrowsingUtils.enabled + ) { + return false; + } + + if ( + aMenuItem.hasAttribute("hide-if-usercontext-disabled") && + !Services.prefs.getBoolPref("privacy.userContext.enabled", false) + ) { + return false; + } + + let selectiontype = + aMenuItem.getAttribute("selection-type") || "single|multiple"; + + var selectionTypes = selectiontype.split("|"); + if (selectionTypes.includes("any")) { + return true; + } + var count = aMetaData.length; + if (count > 1 && !selectionTypes.includes("multiple")) { + return false; + } + if (count == 1 && !selectionTypes.includes("single")) { + return false; + } + // If there is no selection and selectionType doesn't include `none` + // hide the item, otherwise try to use the root node to extract valid + // metadata to compare against. + if (count == 0) { + if (!selectionTypes.includes("none")) { + return false; + } + aMetaData = [this._selectionMetadataForNode(this._view.result.root)]; + } + + let attr = aMenuItem.getAttribute("hide-if-node-type"); + if (attr) { + let rules = attr.split("|"); + if (aMetaData.some(d => rules.some(r => r in d))) { + return false; + } + } + + attr = aMenuItem.getAttribute("hide-if-node-type-is-only"); + if (attr) { + let rules = attr.split("|"); + if (rules.some(r => aMetaData.every(d => r in d))) { + return false; + } + } + + attr = aMenuItem.getAttribute("node-type"); + if (!attr) { + return true; + } + + let anyMatched = false; + let rules = attr.split("|"); + for (let metaData of aMetaData) { + if (rules.some(r => r in metaData)) { + anyMatched = true; + } else { + return false; + } + } + return anyMatched; + }, + + /** + * Uses meta-data rules set as attributes on the menuitems, representing the + * current selection in the view (see `_buildSelectionMetadata`) and sets the + * visibility state for each menuitem according to the following rules: + * 1) The visibility state is unchanged if none of the attributes are set. + * 2) Attributes should not be set on menuseparators. + * 3) The boolean `ignore-item` attribute may be set when this code should + * not handle that menuitem. + * 4) The `selection-type` attribute may be set to: + * - `single` if it should be visible only when there is a single node + * selected + * - `multiple` if it should be visible only when multiple nodes are + * selected + * - `none` if it should be visible when there are no selected nodes + * - `any` if it should be visible for any kind of selection + * - a `|` separated combination of the above. + * 5) The `node-type` attribute may be set to values representing the + * type of the node triggering the context menu. The menuitem will be + * visible when one of the rules (separated by `|`) matches. + * In case of multiple selection, the menuitem is visible only if all of + * the selected nodes match one of the rule. + * 6) The `hide-if-node-type` accepts the same rules as `node-type`, but + * hides the menuitem if the nodes match at least one of the rules. + * It takes priority over `nodetype`. + * 7) The `hide-if-node-type-is-only` accepts the same rules as `node-type`, but + * hides the menuitem if any of the rules match all of the nodes. + * 8) The boolean `hide-if-no-insertion-point` attribute may be set to hide a + * menuitem when there's no insertion point. An insertion point represents + * a point in the view where a new item can be inserted. + * 9) The boolean `hide-if-private-browsing` attribute may be set to hide a + * menuitem in private browsing mode + * 10) The boolean `hide-if-single-click-opens` attribute may be set to hide a + * menuitem in views opening entries with a single click. + * + * @param {object} aPopup + * The menupopup to build children into. + * @returns {boolean} true if at least one item is visible, false otherwise. + */ + buildContextMenu(aPopup) { + var metadata = this._buildSelectionMetadata(); + var ip = this._view.insertionPoint; + var noIp = !ip || ip.isTag; + + var separator = null; + var visibleItemsBeforeSep = false; + var usableItemCount = 0; + for (var i = 0; i < aPopup.children.length; ++i) { + var item = aPopup.children[i]; + if (item.getAttribute("ignore-item") == "true") { + continue; + } + if (item.localName != "menuseparator") { + // We allow pasting into tag containers, so special case that. + let hideIfNoIP = + item.getAttribute("hide-if-no-insertion-point") == "true" && + noIp && + !(ip && ip.isTag && item.id == "placesContext_paste"); + let hideIfPrivate = + item.getAttribute("hide-if-private-browsing") == "true" && + PrivateBrowsingUtils.isWindowPrivate(window); + // Hide `Open` if the primary action on click is opening. + let hideIfSingleClickOpens = + item.getAttribute("hide-if-single-click-opens") == "true" && + !PlacesUIUtils.loadBookmarksInBackground && + !PlacesUIUtils.loadBookmarksInTabs && + this._view.singleClickOpens; + let hideIfNotSearch = + item.getAttribute("hide-if-not-search") == "true" && + (!this._view.selectedNode || + !this._view.selectedNode.parent || + !PlacesUtils.nodeIsQuery(this._view.selectedNode.parent)); + + let shouldHideItem = + hideIfNoIP || + hideIfPrivate || + hideIfSingleClickOpens || + hideIfNotSearch || + !this._shouldShowMenuItem(item, metadata); + item.hidden = shouldHideItem; + item.disabled = + shouldHideItem || item.getAttribute("start-disabled") == "true"; + + if (!item.hidden) { + visibleItemsBeforeSep = true; + usableItemCount++; + + // Show the separator above the menu-item if any + if (separator) { + separator.hidden = false; + separator = null; + } + } + } else { + // menuseparator + // Initially hide it. It will be unhidden if there will be at least one + // visible menu-item above and below it. + item.hidden = true; + + // We won't show the separator at all if no items are visible above it + if (visibleItemsBeforeSep) { + separator = item; + } + + // New separator, count again: + visibleItemsBeforeSep = false; + } + + if (item.id === "placesContext_deleteBookmark") { + document.l10n.setAttributes(item, "places-delete-bookmark", { + count: metadata.length, + }); + } + if (item.id === "placesContext_deleteFolder") { + document.l10n.setAttributes(item, "places-delete-folder", { + count: metadata.length, + }); + } + } + + // Set Open Folder/Links In Tabs or Open Bookmark item's enabled state if they're visible + if (usableItemCount > 0) { + let openContainerInTabsItem = document.getElementById( + "placesContext_openContainer:tabs" + ); + let openBookmarksItem = document.getElementById( + "placesContext_openBookmarkContainer:tabs" + ); + for (let menuItem of [openContainerInTabsItem, openBookmarksItem]) { + if (!menuItem.hidden) { + var containerToUse = + this._view.selectedNode || this._view.result.root; + if (PlacesUtils.nodeIsContainer(containerToUse)) { + if (!PlacesUtils.hasChildURIs(containerToUse)) { + menuItem.disabled = true; + // Ensure that we don't display the menu if nothing is enabled: + usableItemCount--; + } + } + } + } + } + + const deleteHistoryItem = document.getElementById( + "placesContext_delete_history" + ); + document.l10n.setAttributes(deleteHistoryItem, "places-delete-page", { + count: metadata.length, + }); + + const createBookmarkItem = document.getElementById( + "placesContext_createBookmark" + ); + document.l10n.setAttributes(createBookmarkItem, "places-create-bookmark", { + count: metadata.length, + }); + + return usableItemCount > 0; + }, + + /** + * Select all links in the current view. + */ + selectAll: function PC_selectAll() { + this._view.selectAll(); + }, + + /** + * Opens the bookmark properties for the selected URI Node. + */ + showBookmarkPropertiesForSelection() { + let node = this._view.selectedNode; + if (!node) { + return; + } + + PlacesUIUtils.showBookmarkDialog( + { action: "edit", node, hiddenRows: ["folderPicker"] }, + window.top + ); + }, + + /** + * Opens the links in the selected folder, or the selected links in new tabs. + * + * @param {object} aEvent + * The associated event. + */ + openSelectionInTabs: function PC_openLinksInTabs(aEvent) { + var node = this._view.selectedNode; + var nodes = this._view.selectedNodes; + // In the case of no selection, open the root node: + if (!node && !nodes.length) { + node = this._view.result.root; + } + PlacesUIUtils.openMultipleLinksInTabs( + node ? node : nodes, + aEvent, + this._view + ); + }, + + /** + * Shows the Add Bookmark UI for the current insertion point. + * + * @param {string} aType + * the type of the new item (bookmark/folder) + */ + async newItem(aType) { + let ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( + { + action: "add", + type: aType, + defaultInsertionPoint: ip, + hiddenRows: ["folderPicker"], + }, + window.top + ); + if (bookmarkGuid) { + this._view.selectItems([bookmarkGuid], false); + } + }, + + /** + * Create a new Bookmark separator somewhere. + */ + async newSeparator() { + var ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let index = await ip.getIndex(); + let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index }); + let guid = await txn.transact(); + // Select the new item. + this._view.selectItems([guid], false); + }, + + /** + * Sort the selected folder by name + */ + async sortFolderByName() { + let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode); + await PlacesTransactions.SortByName(guid).transact(); + }, + + /** + * Walk the list of folders we're removing in this delete operation, and + * see if the selected node specified is already implicitly being removed + * because it is a child of that folder. + * + * @param {object} node + * Node to check for containment. + * @param {Array} pastFolders + * List of folders the calling function has already traversed + * @returns {boolean} true if the node should be skipped, false otherwise. + */ + _shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) { + /** + * Determines if a node is contained by another node within a resultset. + * + * @param {object} parent + * The parent container to check for containment in + * @returns {boolean} true if node is a member of parent's children, false otherwise. + */ + function isNodeContainedBy(parent) { + var cursor = node.parent; + while (cursor) { + if (cursor == parent) { + return true; + } + cursor = cursor.parent; + } + return false; + } + + for (var j = 0; j < pastFolders.length; ++j) { + if (isNodeContainedBy(pastFolders[j])) { + return true; + } + } + return false; + }, + + /** + * Creates a set of transactions for the removal of a range of items. + * A range is an array of adjacent nodes in a view. + * + * @param {Array} range + * An array of nodes to remove. Should all be adjacent. + * @param {Array} transactions + * An array of transactions (returned) + * @param {Array} [removedFolders] + * An array of folder nodes that have already been removed. + * @returns {number} The total number of items affected. + */ + async _removeRange(range, transactions, removedFolders) { + if (!(transactions instanceof Array)) { + throw new Error("Must pass a transactions array"); + } + if (!removedFolders) { + removedFolders = []; + } + + let bmGuidsToRemove = []; + let totalItems = 0; + + for (var i = 0; i < range.length; ++i) { + var node = range[i]; + if (this._shouldSkipNode(node, removedFolders)) { + continue; + } + + totalItems++; + + if (PlacesUtils.nodeIsTagQuery(node.parent)) { + // This is a uri node inside a tag container. It needs a special + // untag transaction. + let tag = node.parent.title || ""; + if (!tag) { + // The parent may be the root node, that doesn't have a title. + tag = node.parent.query.tags[0]; + } + transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag })); + } else if ( + PlacesUtils.nodeIsTagQuery(node) && + node.parent && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT + ) { + // This is a tag container. + // Untag all URIs tagged with this tag only if the tag container is + // child of the "Tags" query in the library, in all other places we + // must only remove the query node. + let tag = node.title; + let urls = new Set(); + await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b => + urls.add(b.url) + ); + transactions.push( + PlacesTransactions.Untag({ tag, urls: Array.from(urls) }) + ); + } else if ( + PlacesUtils.nodeIsURI(node) && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + // This is a uri node inside an history query. + await PlacesUtils.history.remove(node.uri).catch(console.error); + // History deletes are not undoable, so we don't have a transaction. + } else if ( + node.itemId == -1 && + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + // This is a dynamically generated history query, like queries + // grouped by site, time or both. Dynamically generated queries don't + // have an itemId even if they are descendants of a bookmark. + await this._removeHistoryContainer(node).catch(console.error); + // History deletes are not undoable, so we don't have a transaction. + } else { + // This is a common bookmark item. + if (PlacesUtils.nodeIsFolder(node)) { + // If this is a folder we add it to our array of folders, used + // to skip nodes that are children of an already removed folder. + removedFolders.push(node); + } + bmGuidsToRemove.push(node.bookmarkGuid); + } + } + if (bmGuidsToRemove.length) { + transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove })); + } + return totalItems; + }, + + async _removeRowsFromBookmarks() { + let ranges = this._view.removableSelectionRanges; + let transactions = []; + let removedFolders = []; + let totalItems = 0; + + for (let range of ranges) { + totalItems += await this._removeRange( + range, + transactions, + removedFolders + ); + } + + if (transactions.length) { + await PlacesUIUtils.batchUpdatesForNode( + this._view.result, + totalItems, + async () => { + await PlacesTransactions.batch( + transactions, + "PlacesController::removeRowsFromBookmarks" + ); + } + ); + } + }, + + /** + * Removes the set of selected ranges from history, asynchronously. History + * deletes are not undoable. + */ + async _removeRowsFromHistory() { + let nodes = this._view.selectedNodes; + let URIs = new Set(); + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + if (PlacesUtils.nodeIsURI(node)) { + URIs.add(node.uri); + } else if ( + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + await this._removeHistoryContainer(node).catch(console.error); + } + } + + if (URIs.size) { + await PlacesUIUtils.batchUpdatesForNode( + this._view.result, + URIs.size, + async () => { + await PlacesUtils.history.remove([...URIs]); + } + ); + } + }, + + /** + * Removes history visits for an history container node. History deletes are + * not undoable. + * + * @param {object} aContainerNode + * The container node to remove. + */ + async _removeHistoryContainer(aContainerNode) { + if (PlacesUtils.nodeIsHost(aContainerNode)) { + // This is a site container. + // Check if it's the container for local files (don't be fooled by the + // bogus string name, this is "(local files)"). + let host = + "." + + (aContainerNode.title == PlacesUtils.getString("localhost") + ? "" + : aContainerNode.title); + // Will update faster if all children hidden before removing + aContainerNode.containerOpen = false; + await PlacesUtils.history.removeByFilter({ host }); + } else if (PlacesUtils.nodeIsDay(aContainerNode)) { + // This is a day container. + let query = aContainerNode.query; + let beginTime = query.beginTime; + let endTime = query.endTime; + if (!query || !beginTime || !endTime) { + throw new Error("A valid date container query should exist!"); + } + // Will update faster if all children hidden before removing + aContainerNode.containerOpen = false; + // We want to exclude beginTime from the removal because + // removePagesByTimeframe includes both extremes, while date containers + // exclude the lower extreme. So, if we would not exclude it, we would + // end up removing more history than requested. + await PlacesUtils.history.removeByFilter({ + beginDate: PlacesUtils.toDate(beginTime + 1000), + endDate: PlacesUtils.toDate(endTime), + }); + } + }, + + /** + * Removes the selection + */ + async remove() { + if (!this._hasRemovableSelection()) { + return; + } + + // Sometimes we get repeated remove operation requests, because the user is + // holding down the DEL key. Since removal operations are asynchronous + // that would cause duplicated remove transactions that perform badly, + // increase memory usage (duplicate data), and cause failures (trying to + // act on already removed nodes). + if (this._isRepeatedRemoveOperation()) { + return; + } + + var root = this._view.result.root; + + if (PlacesUtils.nodeIsFolder(root)) { + await this._removeRowsFromBookmarks(); + } else if (PlacesUtils.nodeIsQuery(root)) { + var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; + if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) { + await this._removeRowsFromBookmarks(); + } else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + await this._removeRowsFromHistory(); + } else { + throw new Error("Unknown query type"); + } + } else { + throw new Error("unexpected root"); + } + }, + + /** + * Fills a DataTransfer object with the content of the selection that can be + * dropped elsewhere. + * + * @param {object} aEvent + * The dragstart event. + */ + setDataTransfer: function PC_setDataTransfer(aEvent) { + let dt = aEvent.dataTransfer; + + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + + function addData(type, index) { + let wrapNode = PlacesUtils.wrapNode(node, type); + dt.mozSetDataAt(type, wrapNode, index); + } + + function addURIData(index) { + addData(PlacesUtils.TYPE_X_MOZ_URL, index); + addData(PlacesUtils.TYPE_PLAINTEXT, index); + addData(PlacesUtils.TYPE_HTML, index); + } + + try { + let nodes = this._view.draggableSelection; + for (let i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + + // This order is _important_! It controls how this and other + // applications select data to be inserted based on type. + addData(PlacesUtils.TYPE_X_MOZ_PLACE, i); + if (node.uri) { + addURIData(i); + } + } + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + get clipboardAction() { + let action = {}; + let actionOwner; + try { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION); + Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action); + [action, actionOwner] = action.value + .QueryInterface(Ci.nsISupportsString) + .data.split(","); + } catch (ex) { + // Paste from external sources don't have any associated action, just + // fallback to a copy action. + return "copy"; + } + // For cuts also check who inited the action, since cuts across different + // instances should instead be handled as copies (The sources are not + // available for this instance). + if (action == "cut" && actionOwner != this.profileName) { + action = "copy"; + } + + return action; + }, + + _releaseClipboardOwnership: function PC__releaseClipboardOwnership() { + if (this.cutNodes.length) { + // This clears the logical clipboard, doesn't remove data. + Services.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _clearClipboard: function PC__clearClipboard() { + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + xferable.addDataFlavor(TYPE); + xferable.setTransferData(TYPE, PlacesUtils.toISupportsString("")); + Services.clipboard.setData( + xferable, + null, + Ci.nsIClipboard.kGlobalClipboard + ); + }, + + _populateClipboard: function PC__populateClipboard(aNodes, aAction) { + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] }, + { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: PlacesUtils.TYPE_HTML, entries: [] }, + { type: PlacesUtils.TYPE_PLAINTEXT, entries: [] }, + ]; + + // Avoid handling descendants of a copied node, the transactions take care + // of them automatically. + let copiedFolders = []; + aNodes.forEach(function (node) { + if (this._shouldSkipNode(node, copiedFolders)) { + return; + } + if (PlacesUtils.nodeIsFolder(node)) { + copiedFolders.push(node); + } + + contents.forEach(function (content) { + content.entries.push(PlacesUtils.wrapNode(node, content.type)); + }); + }, this); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, PlacesUtils.toISupportsString(data)); + } + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + let hasData = false; + // This order matters here! It controls how this and other applications + // select data to be inserted based on type. + contents.forEach(function (content) { + if (content.entries.length) { + hasData = true; + let glue = + content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl; + addData(content.type, content.entries.join(glue)); + } + }); + + // Track the exected action in the xferable. This must be the last flavor + // since it's the least preferred one. + // Enqueue a unique instance identifier to distinguish operations across + // concurrent instances of the application. + addData( + PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, + aAction + "," + this.profileName + ); + + if (hasData) { + Services.clipboard.setData( + xferable, + aAction == "cut" ? this : null, + Ci.nsIClipboard.kGlobalClipboard + ); + } + }, + + _cutNodes: [], + get cutNodes() { + return this._cutNodes; + }, + set cutNodes(aNodes) { + let self = this; + function updateCutNodes(aValue) { + self._cutNodes.forEach(function (aNode) { + self._view.toggleCutNode(aNode, aValue); + }); + } + + updateCutNodes(false); + this._cutNodes = aNodes; + updateCutNodes(true); + }, + + /** + * Copy Bookmarks and Folders to the clipboard + */ + copy: function PC_copy() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + this._populateClipboard(this._view.selectedNodes, "copy"); + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + /** + * Cut Bookmarks and Folders to the clipboard + */ + cut: function PC_cut() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) { + result.suppressNotifications = true; + } + try { + this._populateClipboard(this._view.selectedNodes, "cut"); + this.cutNodes = this._view.selectedNodes; + } finally { + if (!didSuppressNotifications) { + result.suppressNotifications = false; + } + } + }, + + /** + * Paste Bookmarks and Folders from the clipboard + */ + async paste() { + // No reason to proceed if there isn't a valid insertion point. + let ip = this._view.insertionPoint; + if (!ip) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + let action = this.clipboardAction; + + let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + xferable.init(null); + // This order matters here! It controls the preferred flavors for this + // paste operation. + [ + PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_PLAINTEXT, + ].forEach(type => xferable.addDataFlavor(type)); + + Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + // Now get the clipboard contents, in the best available flavor. + let data = {}, + type = {}, + items = []; + try { + xferable.getAnyTransferData(type, data); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + type = type.value; + items = PlacesUtils.unwrapNodes(data, type); + } catch (ex) { + // No supported data exists or nodes unwrap failed, just bail out. + return; + } + + let doCopy = action == "copy"; + let itemsToSelect = await PlacesUIUtils.handleTransferItems( + items, + ip, + doCopy, + this._view + ); + + // Cut/past operations are not repeatable, so clear the clipboard. + if (action == "cut") { + this._clearClipboard(); + } + + if (itemsToSelect.length) { + this._view.selectItems(itemsToSelect, false); + } + }, + + /** + * Checks if we can insert into a container. + * + * @param {object} container + * The container were we are want to drop + * @returns {boolean} + */ + disallowInsertion(container) { + if (!container) { + throw new Error("empty container"); + } + // Allow dropping into Tag containers and editable folders. + return ( + !PlacesUtils.nodeIsTagQuery(container) && + (!PlacesUtils.nodeIsFolder(container) || + PlacesUIUtils.isFolderReadOnly(container)) + ); + }, + + /** + * Determines if a node can be moved. + * + * @param {object} node + * A nsINavHistoryResultNode node. + * @returns {boolean} True if the node can be moved, false otherwise. + */ + canMoveNode(node) { + // Only bookmark items are movable. + if (node.itemId == -1) { + return false; + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + let parentNode = node.parent; + if (!parentNode) { + return false; + } + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + if (PlacesUtils.nodeIsTagQuery(parentNode)) { + return false; + } + + return ( + (PlacesUtils.nodeIsFolder(parentNode) && + !PlacesUIUtils.isFolderReadOnly(parentNode)) || + PlacesUtils.nodeIsQuery(parentNode) + ); + }, + async forgetAboutThisSite() { + let host; + if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { + host = this._view.selectedNode.query.domain; + } else { + host = Services.io.newURI(this._view.selectedNode.uri).host; + } + let baseDomain; + try { + baseDomain = Services.eTLD.getBaseDomainFromHost(host); + } catch (e) { + // If there is no baseDomain we fall back to host + } + const [title, body, forget] = await document.l10n.formatValues([ + { id: "places-forget-about-this-site-confirmation-title" }, + { + id: "places-forget-about-this-site-confirmation-msg", + args: { hostOrBaseDomain: baseDomain ?? host }, + }, + { id: "places-forget-about-this-site-forget" }, + ]); + + const flags = + Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 + + Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1 + + Services.prompt.BUTTON_POS_1_DEFAULT; + + let bag = await Services.prompt.asyncConfirmEx( + window.browsingContext, + Services.prompt.MODAL_TYPE_INTERNAL_WINDOW, + title, + body, + flags, + forget, + null, + null, + null, + false + ); + if (bag.getProperty("buttonNumClicked") !== 0) { + return; + } + + if (this.forgetSiteClearByBaseDomain) { + await this.ForgetAboutSite.removeDataFromBaseDomain(host); + } else { + await this.ForgetAboutSite.removeDataFromDomain(host); + } + }, + + showInFolder(aBookmarkGuid) { + // Open containing folder in left pane/sidebar bookmark tree + let documentUrl = document.documentURI.toLowerCase(); + if (documentUrl.endsWith("browser.xhtml")) { + // We're in a menu or a panel. + window.SidebarUI._show("viewBookmarksSidebar").then(() => { + let theSidebar = document.getElementById("sidebar"); + theSidebar.contentDocument + .getElementById("bookmarks-view") + .selectItems([aBookmarkGuid]); + }); + } else if (documentUrl.includes("sidebar")) { + // We're in the sidebar - clear the search box first + let searchBox = document.getElementById("search-box"); + searchBox.value = ""; + searchBox.doCommand(); + + // And go to the node + this._view.selectItems([aBookmarkGuid], true); + } else { + // We're in the bookmark library/manager + PlacesUtils.bookmarks + .fetch(aBookmarkGuid, null, { includePath: true }) + .then(b => { + let containers = b.path.map(obj => { + return obj.guid; + }); + // selectLeftPane looks for literal "AllBookmarks" as a "built-in" + containers.splice(0, 0, "AllBookmarks"); + PlacesOrganizer.selectLeftPaneContainerByHierarchy(containers); + this._view.selectItems([aBookmarkGuid], false); + }); + } + }, +}; + +/** + * Handles drag and drop operations for views. Note that this is view agnostic! + * You should not use PlacesController._view within these methods, since + * the view that the item(s) have been dropped on was not necessarily active. + * Drop functions are passed the view that is being dropped on. + */ +var PlacesControllerDragHelper = { + /** + * For views using DOM nodes like toolbars, menus and panels, this is the DOM + * element currently being dragged over. For other views not handling DOM + * nodes, like trees, it is a Places result node instead. + */ + currentDropTarget: null, + + /** + * Determines if the mouse is currently being dragged over a child node of + * this menu. This is necessary so that the menu doesn't close while the + * mouse is dragging over one of its submenus + * + * @param {object} node + * The container node + * @returns {boolean} true if the user is dragging over a node within the hierarchy of + * the container, false otherwise. + */ + draggingOverChildNode: function PCDH_draggingOverChildNode(node) { + let currentNode = this.currentDropTarget; + while (currentNode) { + if (currentNode == node) { + return true; + } + currentNode = currentNode.parentNode; + } + return false; + }, + + /** + * @returns {object|null} The current active drag session. Returns null if there is none. + */ + getSession: function PCDH__getSession() { + return this.dragService.getCurrentSession(); + }, + + /** + * Extract the most relevant flavor from a list of flavors. + * + * @param {DOMStringList} flavors The flavors list. + * @returns {string} The most relevant flavor, or undefined. + */ + getMostRelevantFlavor(flavors) { + // The DnD API returns a DOMStringList, but tests may pass an Array. + flavors = Array.from(flavors); + return PlacesUIUtils.SUPPORTED_FLAVORS.find(f => flavors.includes(f)); + }, + + /** + * Determines whether or not the data currently being dragged can be dropped + * on a places view. + * + * @param {object} ip + * The insertion point where the items should be dropped. + * @param {object} dt + * The data transfer object. + * @returns {boolean} + */ + canDrop: function PCDH_canDrop(ip, dt) { + let dropCount = dt.mozItemCount; + + // Check every dragged item. + for (let i = 0; i < dropCount; i++) { + let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); + if (!flavor) { + return false; + } + + // Urls can be dropped on any insertionpoint. + // XXXmano: remember that this method is called for each dragover event! + // Thus we shouldn't use unwrapNodes here at all if possible. + // I think it would be OK to accept bogus data here (e.g. text which was + // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and + // will just case the actual drop to be a no-op), and only rule out valid + // expected cases, which are either unsupported flavors, or items which + // cannot be dropped in the current insertionpoint. The last case will + // likely force us to use unwrapNodes for the private data types of + // places. + if (flavor == TAB_DROP_TYPE) { + continue; + } + + let data = dt.mozGetDataAt(flavor, i); + let nodes; + try { + nodes = PlacesUtils.unwrapNodes(data, flavor); + } catch (e) { + return false; + } + + for (let dragged of nodes) { + // Only bookmarks and urls can be dropped into tag containers. + if ( + ip.isTag && + dragged.type != PlacesUtils.TYPE_X_MOZ_URL && + (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE || + (dragged.uri && dragged.uri.startsWith("place:"))) + ) { + return false; + } + + // Disallow dropping of a folder on itself or any of its descendants. + // This check is done to show an appropriate drop indicator, a stricter + // check is done later by the bookmarks API. + if ( + dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || + (dragged.uri && dragged.uri.startsWith("place:")) + ) { + let dragOverPlacesNode = this.currentDropTarget; + if (!(dragOverPlacesNode instanceof Ci.nsINavHistoryResultNode)) { + // If it's a DOM node, it should have a _placesNode expando, or it + // may be a static element in a places container, like the [empty] + // menuitem. + dragOverPlacesNode = + dragOverPlacesNode._placesNode ?? + dragOverPlacesNode.parentNode?._placesNode; + } + + // If we couldn't get a target Places result node then we can't check + // whether the drag is allowed, just let it go through. + if (dragOverPlacesNode) { + let guid = dragged.concreteGuid ?? dragged.itemGuid; + // Dragging over itself. + if (PlacesUtils.getConcreteItemGuid(dragOverPlacesNode) == guid) { + return false; + } + // Dragging over a descendant. + for (let ancestor of PlacesUtils.nodeAncestors( + dragOverPlacesNode + )) { + if (PlacesUtils.getConcreteItemGuid(ancestor) == guid) { + return false; + } + } + } + } + + // Disallow the dropping of multiple bookmarks if they include + // a javascript: bookmarklet + if ( + !flavor.startsWith("text/x-moz-place") && + (nodes.length > 1 || dropCount > 1) && + nodes.some(n => n.uri?.startsWith("javascript:")) + ) { + return false; + } + } + } + return true; + }, + + /** + * Handles the drop of one or more items onto a view. + * + * @param {object} insertionPoint The insertion point where the items should + * be dropped. + * @param {object} dt The dataTransfer information for the drop. + * @param {object} [view] The view or the tree element. This allows + * batching to take place. + */ + async onDrop(insertionPoint, dt, view) { + let doCopy = ["copy", "link"].includes(dt.dropEffect); + + let dropCount = dt.mozItemCount; + + // Following flavors may contain duplicated data. + let duplicable = new Map(); + duplicable.set(PlacesUtils.TYPE_PLAINTEXT, new Set()); + duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set()); + + // Collect all data from the DataTransfer before processing it, as the + // DataTransfer is only valid during the synchronous handling of the `drop` + // event handler callback. + let nodes = []; + let externalDrag = false; + for (let i = 0; i < dropCount; ++i) { + let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); + if (!flavor) { + return; + } + + let data = dt.mozGetDataAt(flavor, i); + if (duplicable.has(flavor)) { + let handled = duplicable.get(flavor); + if (handled.has(data)) { + continue; + } + handled.add(data); + } + + // Check that the drag/drop is not internal + if (i == 0 && !flavor.startsWith("text/x-moz-place")) { + externalDrag = true; + } + + if (flavor != TAB_DROP_TYPE) { + nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor)]; + } else if ( + XULElement.isInstance(data) && + data.localName == "tab" && + data.ownerGlobal.isChromeWindow + ) { + let uri = data.linkedBrowser.currentURI; + let spec = uri ? uri.spec : "about:blank"; + nodes.push({ + uri: spec, + title: data.label, + type: PlacesUtils.TYPE_X_MOZ_URL, + }); + } else { + throw new Error("bogus data was passed as a tab"); + } + } + + // If a multiple urls are being dropped from the urlbar or an external source, + // and they include javascript url, not bookmark any of them + if ( + externalDrag && + (nodes.length > 1 || dropCount > 1) && + nodes.some(n => n.uri?.startsWith("javascript:")) + ) { + throw new Error("Javascript bookmarklet passed with uris"); + } + + // If a single javascript url is being dropped from the urlbar or an external source, + // show the bookmark dialog as a speedbump protection against malicious cases. + if ( + nodes.length == 1 && + externalDrag && + nodes[0].uri?.startsWith("javascript") + ) { + let uri; + try { + uri = Services.io.newURI(nodes[0].uri); + } catch (ex) { + // Invalid uri, we skip this code and the entry will be discarded later. + } + + if (uri) { + let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( + { + action: "add", + type: "bookmark", + defaultInsertionPoint: insertionPoint, + hiddenRows: ["folderPicker"], + title: nodes[0].title, + uri, + }, + BrowserWindowTracker.getTopWindow() // `window` may be the Library. + ); + + if (bookmarkGuid && view) { + view.selectItems([bookmarkGuid], false); + } + + return; + } + } + + await PlacesUIUtils.handleTransferItems( + nodes, + insertionPoint, + doCopy, + view + ); + }, +}; + +XPCOMUtils.defineLazyServiceGetter( + PlacesControllerDragHelper, + "dragService", + "@mozilla.org/widget/dragservice;1", + "nsIDragService" +); |