/* -*- 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() { 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() {}, /** * 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; // .bookmarkGuid and .pageGuid may either be null or an empty string. While // that should probably change, it's safer to use || here. this._lastRemoveOperationFingerprint = PlacesUtils.sha256( this._view.selectedNodes .map(n => n.bookmarkGuid || (n.pageGuid || n.uri) + n.time) .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" );