controller.js (57222B)
1 /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ 2 /* This Source Code Form is subject to the terms of the Mozilla Public 3 * License, v. 2.0. If a copy of the MPL was not distributed with this 4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 5 6 ChromeUtils.defineESModuleGetters(this, { 7 PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs", 8 PlacesUIUtils: "moz-src:///browser/components/places/PlacesUIUtils.sys.mjs", 9 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", 10 }); 11 12 /* import-globals-from /browser/base/content/utilityOverlay.js */ 13 /* import-globals-from ./places.js */ 14 15 /** 16 * Represents an insertion point within a container where we can insert 17 * items. 18 * 19 * @param {object} options an object containing the following properties: 20 * @param {string} options.parentGuid 21 * The unique identifier of the parent container 22 * @param {number} [options.index] 23 * The index within the container where to insert, defaults to appending 24 * @param {number} [options.orientation] 25 * The orientation of the insertion. NOTE: the adjustments to the 26 * insertion point to accommodate the orientation should be done by 27 * the person who constructs the IP, not the user. The orientation 28 * is provided for informational purposes only! Defaults to DROP_ON. 29 * @param {string} [options.tagName] 30 * The tag name if this IP is set to a tag, null otherwise. 31 * @param {*} [options.dropNearNode] 32 * When defined index will be calculated based on this node 33 */ 34 function PlacesInsertionPoint({ 35 parentGuid, 36 index = PlacesUtils.bookmarks.DEFAULT_INDEX, 37 orientation = Ci.nsITreeView.DROP_ON, 38 tagName = null, 39 dropNearNode = null, 40 }) { 41 this.guid = parentGuid; 42 this._index = index; 43 this.orientation = orientation; 44 this.tagName = tagName; 45 this.dropNearNode = dropNearNode; 46 } 47 48 PlacesInsertionPoint.prototype = { 49 set index(val) { 50 this._index = val; 51 }, 52 53 async getIndex() { 54 if (this.dropNearNode) { 55 // If dropNearNode is set up we must calculate the index of the item near 56 // which we will drop. 57 let index = ( 58 await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid) 59 ).index; 60 return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1; 61 } 62 return this._index; 63 }, 64 65 get isTag() { 66 return typeof this.tagName == "string"; 67 }, 68 }; 69 70 /** 71 * Places Controller 72 */ 73 74 function PlacesController(aView) { 75 this._view = aView; 76 ChromeUtils.defineLazyGetter(this, "profileName", function () { 77 return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; 78 }); 79 80 ChromeUtils.defineESModuleGetters(this, { 81 ForgetAboutSite: "resource://gre/modules/ForgetAboutSite.sys.mjs", 82 }); 83 } 84 85 PlacesController.prototype = { 86 /** 87 * The places view. 88 */ 89 _view: null, 90 91 // This is used in certain views to disable user actions on the places tree 92 // views. This avoids accidental deletion/modification when the user is not 93 // actually organising the trees. 94 disableUserActions: false, 95 96 QueryInterface: ChromeUtils.generateQI(["nsIClipboardOwner"]), 97 98 // nsIClipboardOwner 99 LosingOwnership: function PC_LosingOwnership() { 100 this.cutNodes = []; 101 }, 102 103 terminate: function PC_terminate() { 104 this._releaseClipboardOwnership(); 105 }, 106 107 supportsCommand: function PC_supportsCommand(aCommand) { 108 if (this.disableUserActions) { 109 return false; 110 } 111 // Non-Places specific commands that we also support 112 switch (aCommand) { 113 case "cmd_undo": 114 case "cmd_redo": 115 case "cmd_cut": 116 case "cmd_copy": 117 case "cmd_paste": 118 case "cmd_delete": 119 case "cmd_selectAll": 120 return true; 121 } 122 123 // All other Places Commands are prefixed with "placesCmd_" ... this 124 // filters out other commands that we do _not_ support (see 329587). 125 const CMD_PREFIX = "placesCmd_"; 126 return aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX; 127 }, 128 129 isCommandEnabled: function PC_isCommandEnabled(aCommand) { 130 // Determine whether or not nodes can be inserted. 131 let ip = this._view.insertionPoint; 132 let canInsert = ip && (aCommand.endsWith("_paste") || !ip.isTag); 133 134 switch (aCommand) { 135 case "cmd_undo": 136 return PlacesTransactions.topUndoEntry != null; 137 case "cmd_redo": 138 return PlacesTransactions.topRedoEntry != null; 139 case "cmd_cut": 140 case "placesCmd_cut": 141 for (let node of this._view.selectedNodes) { 142 // If selection includes history nodes or tags-as-bookmark, disallow 143 // cutting. 144 if ( 145 node.itemId == -1 || 146 (node.parent && PlacesUtils.nodeIsTagQuery(node.parent)) 147 ) { 148 return false; 149 } 150 } 151 // Otherwise fall through the cmd_delete check. 152 case "cmd_delete": 153 case "placesCmd_delete": 154 case "placesCmd_deleteDataHost": 155 return this._hasRemovableSelection(); 156 case "cmd_copy": 157 case "placesCmd_copy": 158 case "placesCmd_showInFolder": 159 return this._view.hasSelection; 160 case "cmd_paste": 161 case "placesCmd_paste": 162 // If the clipboard contains a Places flavor it is definitely pasteable, 163 // otherwise we also allow pasting "text/plain" and "text/x-moz-url" data. 164 // We don't check if the data is valid here, because the clipboard may 165 // contain very large blobs that would largely slowdown commands updating. 166 // Of course later paste() should ignore any invalid data. 167 return ( 168 canInsert && 169 Services.clipboard.hasDataMatchingFlavors( 170 [ 171 ...PlacesUIUtils.PLACES_FLAVORS, 172 PlacesUtils.TYPE_X_MOZ_URL, 173 PlacesUtils.TYPE_PLAINTEXT, 174 ], 175 Ci.nsIClipboard.kGlobalClipboard 176 ) 177 ); 178 case "cmd_selectAll": 179 if (this._view.selType != "single") { 180 let rootNode = this._view.result.root; 181 if (rootNode.containerOpen && rootNode.childCount > 0) { 182 return true; 183 } 184 } 185 return false; 186 case "placesCmd_open": 187 case "placesCmd_open:window": 188 case "placesCmd_open:privatewindow": 189 case "placesCmd_open:tab": { 190 let selectedNode = this._view.selectedNode; 191 return selectedNode && PlacesUtils.nodeIsURI(selectedNode); 192 } 193 case "placesCmd_new:folder": 194 return canInsert; 195 case "placesCmd_new:bookmark": 196 return canInsert; 197 case "placesCmd_new:separator": 198 return ( 199 canInsert && 200 !PlacesUtils.asQuery(this._view.result.root).queryOptions 201 .excludeItems && 202 this._view.result.sortingMode == 203 Ci.nsINavHistoryQueryOptions.SORT_BY_NONE 204 ); 205 case "placesCmd_show:info": { 206 let selectedNode = this._view.selectedNode; 207 return ( 208 selectedNode && 209 !PlacesUtils.isRootItem( 210 PlacesUtils.getConcreteItemGuid(selectedNode) 211 ) && 212 (PlacesUtils.nodeIsTagQuery(selectedNode) || 213 PlacesUtils.nodeIsBookmark(selectedNode) || 214 (PlacesUtils.nodeIsFolderOrShortcut(selectedNode) && 215 !PlacesUtils.nodeIsQueryGeneratedFolder(selectedNode))) 216 ); 217 } 218 case "placesCmd_sortBy:name": { 219 let selectedNode = this._view.selectedNode; 220 return ( 221 selectedNode && 222 PlacesUtils.nodeIsFolderOrShortcut(selectedNode) && 223 !PlacesUIUtils.isFolderReadOnly(selectedNode) && 224 this._view.result.sortingMode == 225 Ci.nsINavHistoryQueryOptions.SORT_BY_NONE 226 ); 227 } 228 case "placesCmd_createBookmark": { 229 return !this._view.selectedNodes.some( 230 node => !PlacesUtils.nodeIsURI(node) || node.itemId != -1 231 ); 232 } 233 default: 234 return false; 235 } 236 }, 237 238 doCommand: function PC_doCommand(aCommand) { 239 if (aCommand != "cmd_delete" && aCommand != "placesCmd_delete") { 240 // Clear out last removal fingerprint if any other commands arrives. 241 // This covers sequences like: remove, undo, remove, where the removal 242 // commands are not immediately adjacent. 243 this._lastRemoveOperationFingerprint = null; 244 } 245 switch (aCommand) { 246 case "cmd_undo": 247 PlacesTransactions.undo().catch(console.error); 248 break; 249 case "cmd_redo": 250 PlacesTransactions.redo().catch(console.error); 251 break; 252 case "cmd_cut": 253 case "placesCmd_cut": 254 this.cut(); 255 break; 256 case "cmd_copy": 257 case "placesCmd_copy": 258 this.copy(); 259 break; 260 case "cmd_paste": 261 case "placesCmd_paste": 262 this.paste().catch(console.error); 263 break; 264 case "cmd_delete": 265 case "placesCmd_delete": 266 this.remove("Remove Selection").catch(console.error); 267 break; 268 case "placesCmd_deleteDataHost": 269 this.forgetAboutThisSite().catch(console.error); 270 break; 271 case "cmd_selectAll": 272 this.selectAll(); 273 break; 274 case "placesCmd_open": 275 PlacesUIUtils.openNodeIn( 276 this._view.selectedNode, 277 "current", 278 this._view 279 ); 280 break; 281 case "placesCmd_open:window": 282 PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view); 283 break; 284 case "placesCmd_open:privatewindow": 285 PlacesUIUtils.openNodeIn( 286 this._view.selectedNode, 287 "window", 288 this._view, 289 true 290 ); 291 break; 292 case "placesCmd_open:tab": 293 PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view); 294 break; 295 case "placesCmd_new:folder": 296 this.newItem("folder").catch(console.error); 297 break; 298 case "placesCmd_new:bookmark": 299 this.newItem("bookmark").catch(console.error); 300 break; 301 case "placesCmd_new:separator": 302 this.newSeparator().catch(console.error); 303 break; 304 case "placesCmd_show:info": 305 this.showBookmarkPropertiesForSelection(); 306 break; 307 case "placesCmd_sortBy:name": 308 this.sortFolderByName().catch(console.error); 309 break; 310 case "placesCmd_createBookmark": { 311 const nodes = this._view.selectedNodes.map(node => { 312 return { 313 uri: Services.io.newURI(node.uri), 314 title: node.title, 315 }; 316 }); 317 PlacesUIUtils.showBookmarkPagesDialog( 318 nodes, 319 ["keyword", "location"], 320 window.top 321 ); 322 break; 323 } 324 case "placesCmd_showInFolder": 325 this.showInFolder(this._view.selectedNode.bookmarkGuid); 326 break; 327 } 328 }, 329 330 onEvent: function PC_onEvent() {}, 331 332 /** 333 * Determine whether or not the selection can be removed, either by the 334 * delete or cut operations based on whether or not any of its contents 335 * are non-removable. We don't need to worry about recursion here since it 336 * is a policy decision that a removable item not be placed inside a non- 337 * removable item. 338 * 339 * @returns {boolean} true if all nodes in the selection can be removed, 340 * false otherwise. 341 */ 342 _hasRemovableSelection() { 343 var ranges = this._view.removableSelectionRanges; 344 if (!ranges.length) { 345 return false; 346 } 347 348 var root = this._view.result.root; 349 350 for (var j = 0; j < ranges.length; j++) { 351 var nodes = ranges[j]; 352 for (var i = 0; i < nodes.length; ++i) { 353 // Disallow removing the view's root node 354 if (nodes[i] == root) { 355 return false; 356 } 357 358 if (!PlacesUIUtils.canUserRemove(nodes[i])) { 359 return false; 360 } 361 } 362 } 363 364 return true; 365 }, 366 367 /** 368 * This helper can be used to avoid handling repeated remove operations. 369 * Clear this._lastRemoveOperationFingerprint if another operation happens. 370 * 371 * @returns {boolean} whether the removal is the same as the last one. 372 */ 373 _isRepeatedRemoveOperation() { 374 let lastRemoveOperationFingerprint = this._lastRemoveOperationFingerprint; 375 // .bookmarkGuid and .pageGuid may either be null or an empty string. While 376 // that should probably change, it's safer to use || here. 377 this._lastRemoveOperationFingerprint = PlacesUtils.sha256( 378 this._view.selectedNodes 379 .map(n => n.bookmarkGuid || (n.pageGuid || n.uri) + n.time) 380 .join() 381 ); 382 return ( 383 lastRemoveOperationFingerprint == this._lastRemoveOperationFingerprint 384 ); 385 }, 386 387 /** 388 * Gathers information about the selected nodes according to the following 389 * rules: 390 * "link" node is a URI 391 * "bookmark" node is a bookmark 392 * "tagChild" node is a child of a tag 393 * "folder" node is a folder 394 * "query" node is a query 395 * "separator" node is a separator line 396 * "host" node is a host 397 * 398 * @returns {Array} an array of objects corresponding the selected nodes. Each 399 * object has each of the properties above set if its corresponding 400 * node matches the rule. In addition, the annotations names for each 401 * node are set on its corresponding object as properties. 402 * Notes: 403 * 1) This can be slow, so don't call it anywhere performance critical! 404 */ 405 _buildSelectionMetadata() { 406 return this._view.selectedNodes.map(n => this._selectionMetadataForNode(n)); 407 }, 408 409 _selectionMetadataForNode(node) { 410 let nodeData = {}; 411 // We don't use the nodeIs* methods here to avoid going through the type 412 // property way too often 413 switch (node.type) { 414 case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY: 415 nodeData.query = true; 416 if (node.parent) { 417 switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) { 418 case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: 419 nodeData.query_host = true; 420 break; 421 case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: 422 case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: 423 nodeData.query_day = true; 424 break; 425 case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT: 426 nodeData.query_tag = true; 427 } 428 } 429 break; 430 case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: 431 case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT: 432 nodeData.folder = true; 433 break; 434 case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: 435 nodeData.separator = true; 436 break; 437 case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI: 438 nodeData.link = true; 439 if (PlacesUtils.nodeIsBookmark(node)) { 440 nodeData.link_bookmark = true; 441 var parentNode = node.parent; 442 if (parentNode && PlacesUtils.nodeIsTagQuery(parentNode)) { 443 nodeData.link_bookmark_tag = true; 444 } 445 } 446 break; 447 } 448 return nodeData; 449 }, 450 451 /** 452 * Determines if a context-menu item should be shown 453 * 454 * @param {object} aMenuItem 455 * the context menu item 456 * @param {object} aMetaData 457 * meta data about the selection 458 * @returns {boolean} true if the conditions (see buildContextMenu) are satisfied 459 * and the item can be displayed, false otherwise. 460 */ 461 _shouldShowMenuItem(aMenuItem, aMetaData) { 462 if (PlacesUIUtils.shouldHideOpenMenuItem(aMenuItem)) { 463 return false; 464 } 465 466 let selectiontype = 467 aMenuItem.getAttribute("selection-type") || "single|multiple"; 468 469 var selectionTypes = selectiontype.split("|"); 470 if (selectionTypes.includes("any")) { 471 return true; 472 } 473 var count = aMetaData.length; 474 if (count > 1 && !selectionTypes.includes("multiple")) { 475 return false; 476 } 477 if (count == 1 && !selectionTypes.includes("single")) { 478 return false; 479 } 480 // If there is no selection and selectionType doesn't include `none` 481 // hide the item, otherwise try to use the root node to extract valid 482 // metadata to compare against. 483 if (count == 0) { 484 if (!selectionTypes.includes("none")) { 485 return false; 486 } 487 aMetaData = [this._selectionMetadataForNode(this._view.result.root)]; 488 } 489 490 let attr = aMenuItem.getAttribute("hide-if-node-type"); 491 if (attr) { 492 let rules = attr.split("|"); 493 if (aMetaData.some(d => rules.some(r => r in d))) { 494 return false; 495 } 496 } 497 498 attr = aMenuItem.getAttribute("hide-if-node-type-is-only"); 499 if (attr) { 500 let rules = attr.split("|"); 501 if (rules.some(r => aMetaData.every(d => r in d))) { 502 return false; 503 } 504 } 505 506 attr = aMenuItem.getAttribute("node-type"); 507 if (!attr) { 508 return true; 509 } 510 511 let anyMatched = false; 512 let rules = attr.split("|"); 513 for (let metaData of aMetaData) { 514 if (rules.some(r => r in metaData)) { 515 anyMatched = true; 516 } else { 517 return false; 518 } 519 } 520 return anyMatched; 521 }, 522 523 /** 524 * Uses meta-data rules set as attributes on the menuitems, representing the 525 * current selection in the view (see `_buildSelectionMetadata`) and sets the 526 * visibility state for each menuitem according to the following rules: 527 * 1) The visibility state is unchanged if none of the attributes are set. 528 * 2) Attributes should not be set on menuseparators. 529 * 3) The boolean `ignore-item` attribute may be set when this code should 530 * not handle that menuitem. 531 * 4) The `selection-type` attribute may be set to: 532 * - `single` if it should be visible only when there is a single node 533 * selected 534 * - `multiple` if it should be visible only when multiple nodes are 535 * selected 536 * - `none` if it should be visible when there are no selected nodes 537 * - `any` if it should be visible for any kind of selection 538 * - a `|` separated combination of the above. 539 * 5) The `node-type` attribute may be set to values representing the 540 * type of the node triggering the context menu. The menuitem will be 541 * visible when one of the rules (separated by `|`) matches. 542 * In case of multiple selection, the menuitem is visible only if all of 543 * the selected nodes match one of the rule. 544 * 6) The `hide-if-node-type` accepts the same rules as `node-type`, but 545 * hides the menuitem if the nodes match at least one of the rules. 546 * It takes priority over `nodetype`. 547 * 7) The `hide-if-node-type-is-only` accepts the same rules as `node-type`, but 548 * hides the menuitem if any of the rules match all of the nodes. 549 * 8) The boolean `hide-if-no-insertion-point` attribute may be set to hide a 550 * menuitem when there's no insertion point. An insertion point represents 551 * a point in the view where a new item can be inserted. 552 * 9) The boolean `hide-if-private-browsing` attribute may be set to hide a 553 * menuitem in private browsing mode. 554 * 10) The boolean `hide-if-disabled-private-browsing` attribute may be set to 555 * hide a menuitem if private browsing is not enabled. 556 * 11) The boolean `hide-if-usercontext-disabled` attribute may be set to 557 * hide a menuitem if containers are disabled. 558 * 12) The boolean `hide-if-single-click-opens` attribute may be set to hide a 559 * menuitem in views opening entries with a single click. 560 * 561 * @param {object} aPopup 562 * The menupopup to build children into. 563 * @returns {boolean} true if at least one item is visible, false otherwise. 564 */ 565 buildContextMenu(aPopup) { 566 var metadata = this._buildSelectionMetadata(); 567 var ip = this._view.insertionPoint; 568 var noIp = !ip || ip.isTag; 569 570 var separator = null; 571 var visibleItemsBeforeSep = false; 572 var usableItemCount = 0; 573 for (var i = 0; i < aPopup.children.length; ++i) { 574 var item = aPopup.children[i]; 575 if (item.getAttribute("ignore-item") == "true") { 576 continue; 577 } 578 if (item.localName != "menuseparator") { 579 // We allow pasting into tag containers, so special case that. 580 let hideIfNoIP = 581 item.getAttribute("hide-if-no-insertion-point") == "true" && 582 noIp && 583 !(ip && ip.isTag && item.id == "placesContext_paste"); 584 // Hide `Open` if the primary action on click is opening. 585 let hideIfSingleClickOpens = 586 item.getAttribute("hide-if-single-click-opens") == "true" && 587 !PlacesUIUtils.loadBookmarksInBackground && 588 !PlacesUIUtils.loadBookmarksInTabs && 589 this._view.singleClickOpens; 590 let hideIfNotSearch = 591 item.getAttribute("hide-if-not-search") == "true" && 592 (!this._view.selectedNode || 593 !this._view.selectedNode.parent || 594 !PlacesUtils.nodeIsQuery(this._view.selectedNode.parent)); 595 596 let shouldHideItem = 597 hideIfNoIP || 598 hideIfSingleClickOpens || 599 hideIfNotSearch || 600 !this._shouldShowMenuItem(item, metadata); 601 item.hidden = shouldHideItem; 602 item.disabled = 603 shouldHideItem || item.getAttribute("start-disabled") == "true"; 604 605 if (!item.hidden) { 606 visibleItemsBeforeSep = true; 607 usableItemCount++; 608 609 // Show the separator above the menu-item if any 610 if (separator) { 611 separator.hidden = false; 612 separator = null; 613 } 614 } 615 } else { 616 // menuseparator 617 // Initially hide it. It will be unhidden if there will be at least one 618 // visible menu-item above and below it. 619 item.hidden = true; 620 621 // We won't show the separator at all if no items are visible above it 622 if (visibleItemsBeforeSep) { 623 separator = item; 624 } 625 626 // New separator, count again: 627 visibleItemsBeforeSep = false; 628 } 629 630 if (item.id === "placesContext_deleteBookmark") { 631 document.l10n.setAttributes(item, "places-delete-bookmark", { 632 count: metadata.length, 633 }); 634 } 635 if (item.id === "placesContext_deleteFolder") { 636 document.l10n.setAttributes(item, "places-delete-folder", { 637 count: metadata.length, 638 }); 639 } 640 } 641 642 // Set Open Folder/Links In Tabs or Open Bookmark item's enabled state if they're visible 643 if (usableItemCount > 0) { 644 let openContainerInTabsItem = document.getElementById( 645 "placesContext_openContainer:tabs" 646 ); 647 let openBookmarksItem = document.getElementById( 648 "placesContext_openBookmarkContainer:tabs" 649 ); 650 for (let menuItem of [openContainerInTabsItem, openBookmarksItem]) { 651 if (!menuItem.hidden) { 652 var containerToUse = 653 this._view.selectedNode || this._view.result.root; 654 if (PlacesUtils.nodeIsContainer(containerToUse)) { 655 if (!PlacesUtils.hasChildURIs(containerToUse)) { 656 menuItem.disabled = true; 657 // Ensure that we don't display the menu if nothing is enabled: 658 usableItemCount--; 659 } 660 } 661 } 662 } 663 } 664 665 const deleteHistoryItem = document.getElementById( 666 "placesContext_delete_history" 667 ); 668 document.l10n.setAttributes(deleteHistoryItem, "places-delete-page", { 669 count: metadata.length, 670 }); 671 672 const createBookmarkItem = document.getElementById( 673 "placesContext_createBookmark" 674 ); 675 document.l10n.setAttributes(createBookmarkItem, "places-create-bookmark", { 676 count: metadata.length, 677 }); 678 679 return usableItemCount > 0; 680 }, 681 682 /** 683 * Select all links in the current view. 684 */ 685 selectAll: function PC_selectAll() { 686 this._view.selectAll(); 687 }, 688 689 /** 690 * Opens the bookmark properties for the selected URI Node. 691 */ 692 showBookmarkPropertiesForSelection() { 693 let node = this._view.selectedNode; 694 if (!node) { 695 return; 696 } 697 698 PlacesUIUtils.showBookmarkDialog( 699 { action: "edit", node, hiddenRows: ["folderPicker"] }, 700 window.top 701 ); 702 }, 703 704 /** 705 * Opens the links in the selected folder, or the selected links in new tabs. 706 * 707 * @param {object} aEvent 708 * The associated event. 709 */ 710 openSelectionInTabs: function PC_openLinksInTabs(aEvent) { 711 var node = this._view.selectedNode; 712 var nodes = this._view.selectedNodes; 713 // In the case of no selection, open the root node: 714 if (!node && !nodes.length) { 715 node = this._view.result.root; 716 } 717 PlacesUIUtils.openMultipleLinksInTabs( 718 node ? node : nodes, 719 aEvent, 720 this._view 721 ); 722 }, 723 724 /** 725 * Shows the Add Bookmark UI for the current insertion point. 726 * 727 * @param {string} aType 728 * the type of the new item (bookmark/folder) 729 */ 730 async newItem(aType) { 731 let ip = this._view.insertionPoint; 732 if (!ip) { 733 throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); 734 } 735 736 let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( 737 { 738 action: "add", 739 type: aType, 740 defaultInsertionPoint: ip, 741 hiddenRows: ["folderPicker"], 742 }, 743 window.top 744 ); 745 if (bookmarkGuid) { 746 this._view.selectItems([bookmarkGuid], false); 747 } 748 }, 749 750 /** 751 * Create a new Bookmark separator somewhere. 752 */ 753 async newSeparator() { 754 var ip = this._view.insertionPoint; 755 if (!ip) { 756 throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); 757 } 758 759 let index = await ip.getIndex(); 760 let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index }); 761 let guid = await txn.transact(); 762 // Select the new item. 763 this._view.selectItems([guid], false); 764 }, 765 766 /** 767 * Sort the selected folder by name 768 */ 769 async sortFolderByName() { 770 let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode); 771 await PlacesTransactions.SortByName(guid).transact(); 772 }, 773 774 /** 775 * Walk the list of folders we're removing in this delete operation, and 776 * see if the selected node specified is already implicitly being removed 777 * because it is a child of that folder. 778 * 779 * @param {object} node 780 * Node to check for containment. 781 * @param {Array} pastFolders 782 * List of folders the calling function has already traversed 783 * @returns {boolean} true if the node should be skipped, false otherwise. 784 */ 785 _shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) { 786 /** 787 * Determines if a node is contained by another node within a resultset. 788 * 789 * @param {object} parent 790 * The parent container to check for containment in 791 * @returns {boolean} true if node is a member of parent's children, false otherwise. 792 */ 793 function isNodeContainedBy(parent) { 794 var cursor = node.parent; 795 while (cursor) { 796 if (cursor == parent) { 797 return true; 798 } 799 cursor = cursor.parent; 800 } 801 return false; 802 } 803 804 for (var j = 0; j < pastFolders.length; ++j) { 805 if (isNodeContainedBy(pastFolders[j])) { 806 return true; 807 } 808 } 809 return false; 810 }, 811 812 /** 813 * Creates a set of transactions for the removal of a range of items. 814 * A range is an array of adjacent nodes in a view. 815 * 816 * @param {Array} range 817 * An array of nodes to remove. Should all be adjacent. 818 * @param {Array} transactions 819 * An array of transactions (returned) 820 * @param {Array} [removedFolders] 821 * An array of folder nodes that have already been removed. 822 * @returns {number} The total number of items affected. 823 */ 824 async _removeRange(range, transactions, removedFolders) { 825 if (!(transactions instanceof Array)) { 826 throw new Error("Must pass a transactions array"); 827 } 828 if (!removedFolders) { 829 removedFolders = []; 830 } 831 832 let bmGuidsToRemove = []; 833 let totalItems = 0; 834 835 for (var i = 0; i < range.length; ++i) { 836 var node = range[i]; 837 if (this._shouldSkipNode(node, removedFolders)) { 838 continue; 839 } 840 841 totalItems++; 842 843 if (PlacesUtils.nodeIsTagQuery(node.parent)) { 844 // This is a uri node inside a tag container. It needs a special 845 // untag transaction. 846 let tag = node.parent.title || ""; 847 if (!tag) { 848 // The parent may be the root node, that doesn't have a title. 849 tag = node.parent.query.tags[0]; 850 } 851 transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag })); 852 } else if ( 853 PlacesUtils.nodeIsTagQuery(node) && 854 node.parent && 855 PlacesUtils.nodeIsQuery(node.parent) && 856 PlacesUtils.asQuery(node.parent).queryOptions.resultType == 857 Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAGS_ROOT 858 ) { 859 // This is a tag container. 860 // Untag all URIs tagged with this tag only if the tag container is 861 // child of the "Tags" query in the library, in all other places we 862 // must only remove the query node. 863 let tag = node.title; 864 let urls = new Set(); 865 await PlacesUtils.bookmarks.fetch({ tags: [tag] }, b => 866 urls.add(b.url) 867 ); 868 transactions.push( 869 PlacesTransactions.Untag({ tag, urls: Array.from(urls) }) 870 ); 871 } else if ( 872 PlacesUtils.nodeIsURI(node) && 873 PlacesUtils.nodeIsQuery(node.parent) && 874 PlacesUtils.asQuery(node.parent).queryOptions.queryType == 875 Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY 876 ) { 877 // This is a uri node inside an history query. 878 await PlacesUtils.history.remove(node.uri).catch(console.error); 879 // History deletes are not undoable, so we don't have a transaction. 880 } else if ( 881 node.itemId == -1 && 882 PlacesUtils.nodeIsQuery(node) && 883 PlacesUtils.asQuery(node).queryOptions.queryType == 884 Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY 885 ) { 886 // This is a dynamically generated history query, like queries 887 // grouped by site, time or both. Dynamically generated queries don't 888 // have an itemId even if they are descendants of a bookmark. 889 await this._removeHistoryContainer(node).catch(console.error); 890 // History deletes are not undoable, so we don't have a transaction. 891 } else { 892 // This is a common bookmark item. 893 if (PlacesUtils.nodeIsFolderOrShortcut(node)) { 894 // If this is a folder we add it to our array of folders, used 895 // to skip nodes that are children of an already removed folder. 896 removedFolders.push(node); 897 } 898 bmGuidsToRemove.push(node.bookmarkGuid); 899 } 900 } 901 if (bmGuidsToRemove.length) { 902 transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove })); 903 } 904 return totalItems; 905 }, 906 907 async _removeRowsFromBookmarks() { 908 let ranges = this._view.removableSelectionRanges; 909 let transactions = []; 910 let removedFolders = []; 911 let totalItems = 0; 912 913 for (let range of ranges) { 914 totalItems += await this._removeRange( 915 range, 916 transactions, 917 removedFolders 918 ); 919 } 920 921 if (transactions.length) { 922 await PlacesUIUtils.batchUpdatesForNode( 923 this._view.result, 924 totalItems, 925 async () => { 926 await PlacesTransactions.batch( 927 transactions, 928 "PlacesController::removeRowsFromBookmarks" 929 ); 930 } 931 ); 932 } 933 }, 934 935 /** 936 * Removes the set of selected ranges from history, asynchronously. History 937 * deletes are not undoable. 938 */ 939 async _removeRowsFromHistory() { 940 let nodes = this._view.selectedNodes; 941 let URIs = new Set(); 942 for (let i = 0; i < nodes.length; ++i) { 943 let node = nodes[i]; 944 if (PlacesUtils.nodeIsURI(node)) { 945 URIs.add(node.uri); 946 } else if ( 947 PlacesUtils.nodeIsQuery(node) && 948 PlacesUtils.asQuery(node).queryOptions.queryType == 949 Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY 950 ) { 951 await this._removeHistoryContainer(node).catch(console.error); 952 } 953 } 954 955 if (URIs.size) { 956 await PlacesUIUtils.batchUpdatesForNode( 957 this._view.result, 958 URIs.size, 959 async () => { 960 await PlacesUtils.history.remove([...URIs]); 961 } 962 ); 963 } 964 }, 965 966 /** 967 * Removes history visits for an history container node. History deletes are 968 * not undoable. 969 * 970 * @param {object} aContainerNode 971 * The container node to remove. 972 */ 973 async _removeHistoryContainer(aContainerNode) { 974 if (PlacesUtils.nodeIsHost(aContainerNode)) { 975 // This is a site container. 976 // Check if it's the container for local files (don't be fooled by the 977 // bogus string name, this is "(local files)"). 978 let host = 979 "." + 980 (aContainerNode.title == PlacesUtils.getString("localhost") 981 ? "" 982 : aContainerNode.title); 983 // Will update faster if all children hidden before removing 984 aContainerNode.containerOpen = false; 985 await PlacesUtils.history.removeByFilter({ host }); 986 } else if (PlacesUtils.nodeIsDay(aContainerNode)) { 987 // This is a day container. 988 let query = aContainerNode.query; 989 let beginTime = query.beginTime; 990 let endTime = query.endTime; 991 if (!query || !beginTime || !endTime) { 992 throw new Error("A valid date container query should exist!"); 993 } 994 // Will update faster if all children hidden before removing 995 aContainerNode.containerOpen = false; 996 // We want to exclude beginTime from the removal because 997 // removePagesByTimeframe includes both extremes, while date containers 998 // exclude the lower extreme. So, if we would not exclude it, we would 999 // end up removing more history than requested. 1000 await PlacesUtils.history.removeByFilter({ 1001 beginDate: PlacesUtils.toDate(beginTime + 1000), 1002 endDate: PlacesUtils.toDate(endTime), 1003 }); 1004 } 1005 }, 1006 1007 /** 1008 * Removes the selection 1009 */ 1010 async remove() { 1011 if (!this._hasRemovableSelection()) { 1012 return; 1013 } 1014 1015 // Sometimes we get repeated remove operation requests, because the user is 1016 // holding down the DEL key. Since removal operations are asynchronous 1017 // that would cause duplicated remove transactions that perform badly, 1018 // increase memory usage (duplicate data), and cause failures (trying to 1019 // act on already removed nodes). 1020 if (this._isRepeatedRemoveOperation()) { 1021 return; 1022 } 1023 1024 var root = this._view.result.root; 1025 1026 if (PlacesUtils.nodeIsFolderOrShortcut(root)) { 1027 await this._removeRowsFromBookmarks(); 1028 } else if (PlacesUtils.nodeIsQuery(root)) { 1029 var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; 1030 if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) { 1031 await this._removeRowsFromBookmarks(); 1032 } else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { 1033 await this._removeRowsFromHistory(); 1034 } else { 1035 throw new Error("Unknown query type"); 1036 } 1037 } else { 1038 throw new Error("unexpected root"); 1039 } 1040 }, 1041 1042 /** 1043 * Fills a DataTransfer object with the content of the selection that can be 1044 * dropped elsewhere. 1045 * 1046 * @param {object} aEvent 1047 * The dragstart event. 1048 */ 1049 setDataTransfer: function PC_setDataTransfer(aEvent) { 1050 let dt = aEvent.dataTransfer; 1051 1052 let result = this._view.result; 1053 let didSuppressNotifications = result.suppressNotifications; 1054 if (!didSuppressNotifications) { 1055 result.suppressNotifications = true; 1056 } 1057 1058 function addData(type, index) { 1059 let wrapNode = PlacesUtils.wrapNode(node, type); 1060 dt.mozSetDataAt(type, wrapNode, index); 1061 } 1062 1063 function addURIData(index) { 1064 addData(PlacesUtils.TYPE_X_MOZ_URL, index); 1065 addData(PlacesUtils.TYPE_PLAINTEXT, index); 1066 addData(PlacesUtils.TYPE_HTML, index); 1067 } 1068 1069 try { 1070 let nodes = this._view.draggableSelection; 1071 for (let i = 0; i < nodes.length; ++i) { 1072 var node = nodes[i]; 1073 1074 // This order is _important_! It controls how this and other 1075 // applications select data to be inserted based on type. 1076 addData(PlacesUtils.TYPE_X_MOZ_PLACE, i); 1077 if (node.uri) { 1078 addURIData(i); 1079 } 1080 } 1081 } finally { 1082 if (!didSuppressNotifications) { 1083 result.suppressNotifications = false; 1084 } 1085 } 1086 }, 1087 1088 get clipboardAction() { 1089 let action = {}; 1090 let actionOwner; 1091 try { 1092 let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( 1093 Ci.nsITransferable 1094 ); 1095 xferable.init(null); 1096 xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION); 1097 Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); 1098 xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action); 1099 [action, actionOwner] = action.value 1100 .QueryInterface(Ci.nsISupportsString) 1101 .data.split(","); 1102 } catch (ex) { 1103 // Paste from external sources don't have any associated action, just 1104 // fallback to a copy action. 1105 return "copy"; 1106 } 1107 // For cuts also check who inited the action, since cuts across different 1108 // instances should instead be handled as copies (The sources are not 1109 // available for this instance). 1110 if (action == "cut" && actionOwner != this.profileName) { 1111 action = "copy"; 1112 } 1113 1114 return action; 1115 }, 1116 1117 _releaseClipboardOwnership: function PC__releaseClipboardOwnership() { 1118 if (this.cutNodes.length) { 1119 // This clears the logical clipboard, doesn't remove data. 1120 Services.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard); 1121 } 1122 }, 1123 1124 _clearClipboard: function PC__clearClipboard() { 1125 let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( 1126 Ci.nsITransferable 1127 ); 1128 xferable.init(null); 1129 // Empty transferables may cause crashes, so just add an unknown type. 1130 const TYPE = "text/x-moz-place-empty"; 1131 xferable.addDataFlavor(TYPE); 1132 xferable.setTransferData(TYPE, PlacesUtils.toISupportsString("")); 1133 Services.clipboard.setData( 1134 xferable, 1135 null, 1136 Ci.nsIClipboard.kGlobalClipboard 1137 ); 1138 }, 1139 1140 _populateClipboard: function PC__populateClipboard(aNodes, aAction) { 1141 // This order is _important_! It controls how this and other applications 1142 // select data to be inserted based on type. 1143 let contents = [ 1144 { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] }, 1145 { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, 1146 { type: PlacesUtils.TYPE_HTML, entries: [] }, 1147 { type: PlacesUtils.TYPE_PLAINTEXT, entries: [] }, 1148 ]; 1149 1150 // Avoid handling descendants of a copied node, the transactions take care 1151 // of them automatically. 1152 let copiedFolders = []; 1153 aNodes.forEach(function (node) { 1154 if (this._shouldSkipNode(node, copiedFolders)) { 1155 return; 1156 } 1157 if (PlacesUtils.nodeIsFolderOrShortcut(node)) { 1158 copiedFolders.push(node); 1159 } 1160 1161 contents.forEach(function (content) { 1162 content.entries.push(PlacesUtils.wrapNode(node, content.type)); 1163 }); 1164 }, this); 1165 1166 function addData(type, data) { 1167 xferable.addDataFlavor(type); 1168 xferable.setTransferData(type, PlacesUtils.toISupportsString(data)); 1169 } 1170 1171 let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( 1172 Ci.nsITransferable 1173 ); 1174 xferable.init(null); 1175 let hasData = false; 1176 // This order matters here! It controls how this and other applications 1177 // select data to be inserted based on type. 1178 contents.forEach(function (content) { 1179 if (content.entries.length) { 1180 hasData = true; 1181 let glue = 1182 content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl; 1183 addData(content.type, content.entries.join(glue)); 1184 } 1185 }); 1186 1187 // Track the exected action in the xferable. This must be the last flavor 1188 // since it's the least preferred one. 1189 // Enqueue a unique instance identifier to distinguish operations across 1190 // concurrent instances of the application. 1191 addData( 1192 PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, 1193 aAction + "," + this.profileName 1194 ); 1195 1196 if (hasData) { 1197 Services.clipboard.setData( 1198 xferable, 1199 aAction == "cut" ? this : null, 1200 Ci.nsIClipboard.kGlobalClipboard 1201 ); 1202 } 1203 }, 1204 1205 _cutNodes: [], 1206 get cutNodes() { 1207 return this._cutNodes; 1208 }, 1209 set cutNodes(aNodes) { 1210 let self = this; 1211 function updateCutNodes(aValue) { 1212 self._cutNodes.forEach(function (aNode) { 1213 self._view.toggleCutNode(aNode, aValue); 1214 }); 1215 } 1216 1217 updateCutNodes(false); 1218 this._cutNodes = aNodes; 1219 updateCutNodes(true); 1220 }, 1221 1222 /** 1223 * Copy Bookmarks and Folders to the clipboard 1224 */ 1225 copy: function PC_copy() { 1226 let result = this._view.result; 1227 let didSuppressNotifications = result.suppressNotifications; 1228 if (!didSuppressNotifications) { 1229 result.suppressNotifications = true; 1230 } 1231 try { 1232 this._populateClipboard(this._view.selectedNodes, "copy"); 1233 } finally { 1234 if (!didSuppressNotifications) { 1235 result.suppressNotifications = false; 1236 } 1237 } 1238 }, 1239 1240 /** 1241 * Cut Bookmarks and Folders to the clipboard 1242 */ 1243 cut: function PC_cut() { 1244 let result = this._view.result; 1245 let didSuppressNotifications = result.suppressNotifications; 1246 if (!didSuppressNotifications) { 1247 result.suppressNotifications = true; 1248 } 1249 try { 1250 this._populateClipboard(this._view.selectedNodes, "cut"); 1251 this.cutNodes = this._view.selectedNodes; 1252 } finally { 1253 if (!didSuppressNotifications) { 1254 result.suppressNotifications = false; 1255 } 1256 } 1257 }, 1258 1259 /** 1260 * Paste Bookmarks and Folders from the clipboard 1261 */ 1262 async paste() { 1263 // No reason to proceed if there isn't a valid insertion point. 1264 let ip = this._view.insertionPoint; 1265 if (!ip) { 1266 throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); 1267 } 1268 1269 let action = this.clipboardAction; 1270 1271 let xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( 1272 Ci.nsITransferable 1273 ); 1274 xferable.init(null); 1275 // This order matters here! It controls the preferred flavors for this 1276 // paste operation. 1277 [ 1278 PlacesUtils.TYPE_X_MOZ_PLACE, 1279 PlacesUtils.TYPE_X_MOZ_URL, 1280 "application/x-torbrowser-opaque", 1281 PlacesUtils.TYPE_PLAINTEXT, 1282 ].forEach(type => xferable.addDataFlavor(type)); 1283 1284 Services.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); 1285 1286 // Now get the clipboard contents, in the best available flavor. 1287 let validNodes, invalidNodes; 1288 1289 try { 1290 let data = {}, 1291 type = {}; 1292 xferable.getAnyTransferData(type, data); 1293 ({ validNodes, invalidNodes } = PlacesUtils.unwrapNodes( 1294 data.value.QueryInterface(Ci.nsISupportsString).data, 1295 type.value 1296 )); 1297 } catch (ex) { 1298 // No supported data exists, just bail out. 1299 return; 1300 } 1301 1302 let doCopy = action == "copy"; 1303 let itemsToSelect = await PlacesUIUtils.handleTransferItems( 1304 validNodes, 1305 ip, 1306 doCopy, 1307 this._view 1308 ); 1309 1310 // Cut/past operations are not repeatable, so clear the clipboard. 1311 if (action == "cut") { 1312 this._clearClipboard(); 1313 } 1314 1315 if (itemsToSelect.length) { 1316 this._view.selectItems(itemsToSelect, false); 1317 } 1318 1319 if (invalidNodes.length) { 1320 let [title, body] = PlacesUIUtils.promptLocalization.formatValuesSync([ 1321 "places-bookmarks-paste-error-title", 1322 "places-bookmarks-paste-error-message-header", 1323 ]); 1324 1325 const MAX_URI_LENGTH = 100; 1326 const MAX_URI_COUNT = 20; 1327 1328 let invalidUrlList = invalidNodes 1329 .slice(0, MAX_URI_COUNT) 1330 .map(item => { 1331 let encodedUri = encodeURI(item.uri); 1332 if (encodedUri.length > MAX_URI_LENGTH) { 1333 encodedUri = encodedUri.slice(0, MAX_URI_LENGTH) + "…"; 1334 } 1335 return "\n • " + encodedUri; 1336 }) 1337 .join(""); 1338 1339 if (invalidNodes.length > MAX_URI_COUNT) { 1340 invalidUrlList += "\n • …"; 1341 } 1342 1343 body = `${body}${invalidUrlList}`; 1344 Services.prompt.alert(window, title, body); 1345 } 1346 }, 1347 1348 /** 1349 * Checks if we can insert into a container. 1350 * 1351 * @param {object} container 1352 * The container were we are want to drop 1353 * @returns {boolean} 1354 */ 1355 disallowInsertion(container) { 1356 if (!container) { 1357 throw new Error("empty container"); 1358 } 1359 // Allow dropping into Tag containers and editable folders. 1360 return ( 1361 !PlacesUtils.nodeIsTagQuery(container) && 1362 (!PlacesUtils.nodeIsFolderOrShortcut(container) || 1363 PlacesUIUtils.isFolderReadOnly(container)) 1364 ); 1365 }, 1366 1367 /** 1368 * Determines if a node can be moved. 1369 * 1370 * @param {object} node 1371 * A nsINavHistoryResultNode node. 1372 * @returns {boolean} True if the node can be moved, false otherwise. 1373 */ 1374 canMoveNode(node) { 1375 // Only bookmark items are movable. 1376 if (node.itemId == -1) { 1377 return false; 1378 } 1379 1380 // Once tags and bookmarked are divorced, the tag-query check should be 1381 // removed. 1382 let parentNode = node.parent; 1383 if (!parentNode) { 1384 return false; 1385 } 1386 1387 // Once tags and bookmarked are divorced, the tag-query check should be 1388 // removed. 1389 if (PlacesUtils.nodeIsTagQuery(parentNode)) { 1390 return false; 1391 } 1392 1393 return ( 1394 (PlacesUtils.nodeIsFolderOrShortcut(parentNode) && 1395 !PlacesUIUtils.isFolderReadOnly(parentNode)) || 1396 PlacesUtils.nodeIsQuery(parentNode) 1397 ); 1398 }, 1399 async forgetAboutThisSite() { 1400 let host; 1401 if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { 1402 host = this._view.selectedNode.query.domain; 1403 } else { 1404 host = Services.io.newURI(this._view.selectedNode.uri).host; 1405 } 1406 let baseDomain; 1407 try { 1408 baseDomain = Services.eTLD.getBaseDomainFromHost(host); 1409 } catch (e) { 1410 // If there is no baseDomain we fall back to host 1411 } 1412 let params = { host, hostOrBaseDomain: baseDomain ?? host }; 1413 if (window.gDialogBox) { 1414 await window.gDialogBox.open( 1415 "chrome://browser/content/places/clearDataForSite.xhtml", 1416 params 1417 ); 1418 } else { 1419 await window.openDialog( 1420 "chrome://browser/content/places/clearDataForSite.xhtml", 1421 null, 1422 "modal,centerscreen", 1423 params 1424 ); 1425 } 1426 }, 1427 1428 showInFolder(aBookmarkGuid) { 1429 // Open containing folder in left pane/sidebar bookmark tree 1430 let documentUrl = document.documentURI.toLowerCase(); 1431 if (documentUrl.endsWith("browser.xhtml")) { 1432 // We're in a menu or a panel. 1433 window.SidebarController._show("viewBookmarksSidebar").then(() => { 1434 let theSidebar = document.getElementById("sidebar"); 1435 theSidebar.contentDocument 1436 .getElementById("bookmarks-view") 1437 .selectItems([aBookmarkGuid]); 1438 }); 1439 } else if (documentUrl.includes("sidebar")) { 1440 // We're in the sidebar - clear the search box first 1441 let searchBox = document.getElementById("search-box"); 1442 searchBox.clear(); 1443 1444 // And go to the node 1445 this._view.selectItems([aBookmarkGuid], true); 1446 } else { 1447 // We're in the bookmark library/manager 1448 PlacesUtils.bookmarks 1449 .fetch(aBookmarkGuid, null, { includePath: true }) 1450 .then(b => { 1451 let containers = b.path.map(obj => { 1452 return obj.guid; 1453 }); 1454 // selectLeftPane looks for literal "AllBookmarks" as a "built-in" 1455 containers.splice(0, 0, "AllBookmarks"); 1456 PlacesOrganizer.selectLeftPaneContainerByHierarchy(containers); 1457 this._view.selectItems([aBookmarkGuid], false); 1458 }); 1459 } 1460 }, 1461 }; 1462 1463 /** 1464 * Handles drag and drop operations for views. Note that this is view agnostic! 1465 * You should not use PlacesController._view within these methods, since 1466 * the view that the item(s) have been dropped on was not necessarily active. 1467 * Drop functions are passed the view that is being dropped on. 1468 */ 1469 var PlacesControllerDragHelper = { 1470 /** 1471 * For views using DOM nodes like toolbars, menus and panels, this is the DOM 1472 * element currently being dragged over. For other views not handling DOM 1473 * nodes, like trees, it is a Places result node instead. 1474 */ 1475 currentDropTarget: null, 1476 1477 /** 1478 * Determines if the mouse is currently being dragged over a child node of 1479 * this menu. This is necessary so that the menu doesn't close while the 1480 * mouse is dragging over one of its submenus 1481 * 1482 * @param {object} node 1483 * The container node 1484 * @returns {boolean} true if the user is dragging over a node within the hierarchy of 1485 * the container, false otherwise. 1486 */ 1487 draggingOverChildNode: function PCDH_draggingOverChildNode(node) { 1488 let currentNode = this.currentDropTarget; 1489 while (currentNode) { 1490 if (currentNode == node) { 1491 return true; 1492 } 1493 currentNode = currentNode.parentNode; 1494 } 1495 return false; 1496 }, 1497 1498 /** 1499 * @returns {object|null} The current active drag session for the window. 1500 * Returns null if there is none. 1501 */ 1502 getSession: function PCDH__getSession() { 1503 return this.dragService.getCurrentSession(window); 1504 }, 1505 1506 /** 1507 * Extract the most relevant flavor from a list of flavors. 1508 * 1509 * @param {DOMStringList} flavors The flavors list. 1510 * @returns {string} The most relevant flavor, or undefined. 1511 */ 1512 getMostRelevantFlavor(flavors) { 1513 // The DnD API returns a DOMStringList, but tests may pass an Array. 1514 flavors = Array.from(flavors); 1515 return PlacesUIUtils.SUPPORTED_FLAVORS.find(f => flavors.includes(f)); 1516 }, 1517 1518 /** 1519 * Determines whether or not the data currently being dragged can be dropped 1520 * on a places view. 1521 * 1522 * @param {object} ip 1523 * The insertion point where the items should be dropped. 1524 * @param {object} dt 1525 * The data transfer object. 1526 * @returns {boolean} 1527 */ 1528 canDrop: function PCDH_canDrop(ip, dt) { 1529 let dropCount = dt.mozItemCount; 1530 1531 // Check every dragged item. 1532 for (let i = 0; i < dropCount; i++) { 1533 let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); 1534 if (!flavor) { 1535 return false; 1536 } 1537 1538 // Urls can be dropped on any insertionpoint. 1539 // XXXmano: remember that this method is called for each dragover event! 1540 // Thus we shouldn't use unwrapNodes here at all if possible. 1541 // I think it would be OK to accept bogus data here (e.g. text which was 1542 // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and 1543 // will just case the actual drop to be a no-op), and only rule out valid 1544 // expected cases, which are either unsupported flavors, or items which 1545 // cannot be dropped in the current insertionpoint. The last case will 1546 // likely force us to use unwrapNodes for the private data types of 1547 // places. 1548 if (flavor == TAB_DROP_TYPE) { 1549 continue; 1550 } 1551 1552 let data = dt.mozGetDataAt(flavor, i); 1553 let validNodes; 1554 try { 1555 ({ validNodes } = PlacesUtils.unwrapNodes(data, flavor)); 1556 } catch (e) { 1557 return false; 1558 } 1559 1560 for (let dragged of validNodes) { 1561 // Only bookmarks and urls can be dropped into tag containers. 1562 if ( 1563 ip.isTag && 1564 dragged.type != PlacesUtils.TYPE_X_MOZ_URL && 1565 (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE || 1566 (dragged.uri && dragged.uri.startsWith("place:"))) 1567 ) { 1568 return false; 1569 } 1570 1571 // Disallow dropping of a folder on itself or any of its descendants. 1572 // This check is done to show an appropriate drop indicator, a stricter 1573 // check is done later by the bookmarks API. 1574 if ( 1575 dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || 1576 (dragged.uri && dragged.uri.startsWith("place:")) 1577 ) { 1578 let dragOverPlacesNode = this.currentDropTarget; 1579 if (!(dragOverPlacesNode instanceof Ci.nsINavHistoryResultNode)) { 1580 // If it's a DOM node, it should have a _placesNode expando, or it 1581 // may be a static element in a places container, like the [empty] 1582 // menuitem. 1583 dragOverPlacesNode = 1584 dragOverPlacesNode._placesNode ?? 1585 dragOverPlacesNode.parentNode?._placesNode; 1586 } 1587 1588 // If we couldn't get a target Places result node then we can't check 1589 // whether the drag is allowed, just let it go through. 1590 if (dragOverPlacesNode) { 1591 let guid = dragged.concreteGuid ?? dragged.itemGuid; 1592 // Dragging over itself. 1593 if (PlacesUtils.getConcreteItemGuid(dragOverPlacesNode) == guid) { 1594 return false; 1595 } 1596 // Dragging over a descendant. 1597 for (let ancestor of PlacesUtils.nodeAncestors( 1598 dragOverPlacesNode 1599 )) { 1600 if (PlacesUtils.getConcreteItemGuid(ancestor) == guid) { 1601 return false; 1602 } 1603 } 1604 } 1605 } 1606 1607 // Disallow the dropping of multiple bookmarks if they include 1608 // a javascript: bookmarklet 1609 if ( 1610 !flavor.startsWith("text/x-moz-place") && 1611 (validNodes.length > 1 || dropCount > 1) && 1612 validNodes.some(n => n.uri?.startsWith("javascript:")) 1613 ) { 1614 return false; 1615 } 1616 } 1617 } 1618 return true; 1619 }, 1620 1621 /** 1622 * Handles the drop of one or more items onto a view. 1623 * 1624 * @param {object} insertionPoint The insertion point where the items should 1625 * be dropped. 1626 * @param {object} dt The dataTransfer information for the drop. 1627 * @param {object} [view] The view or the tree element. This allows 1628 * batching to take place. 1629 */ 1630 async onDrop(insertionPoint, dt, view) { 1631 let doCopy = ["copy", "link"].includes(dt.dropEffect); 1632 1633 let dropCount = dt.mozItemCount; 1634 1635 // Following flavors may contain duplicated data. 1636 let duplicable = new Map(); 1637 duplicable.set(PlacesUtils.TYPE_PLAINTEXT, new Set()); 1638 duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set()); 1639 1640 // Collect all data from the DataTransfer before processing it, as the 1641 // DataTransfer is only valid during the synchronous handling of the `drop` 1642 // event handler callback. 1643 let nodes = []; 1644 let externalDrag = false; 1645 for (let i = 0; i < dropCount; ++i) { 1646 let flavor = this.getMostRelevantFlavor(dt.mozTypesAt(i)); 1647 if (!flavor) { 1648 return; 1649 } 1650 1651 let data = dt.mozGetDataAt(flavor, i); 1652 if (duplicable.has(flavor)) { 1653 let handled = duplicable.get(flavor); 1654 if (handled.has(data)) { 1655 continue; 1656 } 1657 handled.add(data); 1658 } 1659 1660 // Check that the drag/drop is not internal 1661 if (i == 0 && !flavor.startsWith("text/x-moz-place")) { 1662 externalDrag = true; 1663 } 1664 1665 if (flavor != TAB_DROP_TYPE) { 1666 nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor).validNodes]; 1667 } else if ( 1668 XULElement.isInstance(data) && 1669 data.localName == "tab" && 1670 data.ownerGlobal.isChromeWindow 1671 ) { 1672 let uri = data.linkedBrowser.currentURI; 1673 let spec = uri ? uri.spec : "about:blank"; 1674 nodes.push({ 1675 uri: spec, 1676 title: data.label, 1677 type: PlacesUtils.TYPE_X_MOZ_URL, 1678 }); 1679 } else { 1680 throw new Error("bogus data was passed as a tab"); 1681 } 1682 } 1683 1684 // If a multiple urls are being dropped from the urlbar or an external source, 1685 // and they include javascript url, not bookmark any of them 1686 if ( 1687 externalDrag && 1688 (nodes.length > 1 || dropCount > 1) && 1689 nodes.some(n => n.uri?.startsWith("javascript:")) 1690 ) { 1691 throw new Error("Javascript bookmarklet passed with uris"); 1692 } 1693 1694 // If a single javascript url is being dropped from the urlbar or an external source, 1695 // show the bookmark dialog as a speedbump protection against malicious cases. 1696 if ( 1697 nodes.length == 1 && 1698 externalDrag && 1699 nodes[0].uri?.startsWith("javascript") 1700 ) { 1701 let uri; 1702 try { 1703 uri = Services.io.newURI(nodes[0].uri); 1704 } catch (ex) { 1705 // Invalid uri, we skip this code and the entry will be discarded later. 1706 } 1707 1708 if (uri) { 1709 let bookmarkGuid = await PlacesUIUtils.showBookmarkDialog( 1710 { 1711 action: "add", 1712 type: "bookmark", 1713 defaultInsertionPoint: insertionPoint, 1714 hiddenRows: ["folderPicker"], 1715 title: nodes[0].title, 1716 uri, 1717 }, 1718 BrowserWindowTracker.getTopWindow() // `window` may be the Library. 1719 ); 1720 1721 if (bookmarkGuid && view) { 1722 view.selectItems([bookmarkGuid], false); 1723 } 1724 1725 return; 1726 } 1727 } 1728 1729 await PlacesUIUtils.handleTransferItems( 1730 nodes, 1731 insertionPoint, 1732 doCopy, 1733 view 1734 ); 1735 }, 1736 }; 1737 1738 XPCOMUtils.defineLazyServiceGetter( 1739 PlacesControllerDragHelper, 1740 "dragService", 1741 "@mozilla.org/widget/dragservice;1", 1742 Ci.nsIDragService 1743 );