places.js (54082B)
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 /* import-globals-from editBookmark.js */ 7 /* import-globals-from /toolkit/content/contentAreaUtils.js */ 8 /* import-globals-from /browser/components/downloads/content/allDownloadsView.js */ 9 10 /* Shared Places Import - change other consumers if you change this: */ 11 var { XPCOMUtils } = ChromeUtils.importESModule( 12 "resource://gre/modules/XPCOMUtils.sys.mjs" 13 ); 14 ChromeUtils.defineESModuleGetters(this, { 15 BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", 16 MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", 17 PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", 18 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 19 DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", 20 DownloadsTorWarning: 21 "moz-src:///browser/components/downloads/DownloadsTorWarning.sys.mjs", 22 }); 23 XPCOMUtils.defineLazyScriptGetter( 24 this, 25 "PlacesTreeView", 26 "chrome://browser/content/places/treeView.js" 27 ); 28 XPCOMUtils.defineLazyScriptGetter( 29 this, 30 ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], 31 "chrome://browser/content/places/controller.js" 32 ); 33 /* End Shared Places Import */ 34 35 var { AppConstants } = ChromeUtils.importESModule( 36 "resource://gre/modules/AppConstants.sys.mjs" 37 ); 38 39 const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4"; 40 41 const SORTBY_L10N_IDS = new Map([ 42 ["title", "places-view-sortby-name"], 43 ["url", "places-view-sortby-url"], 44 ["date", "places-view-sortby-date"], 45 ["visitCount", "places-view-sortby-visit-count"], 46 ["dateAdded", "places-view-sortby-date-added"], 47 ["lastModified", "places-view-sortby-last-modified"], 48 ["tags", "places-view-sortby-tags"], 49 ]); 50 51 var PlacesOrganizer = { 52 _places: null, 53 54 _initFolderTree() { 55 this._places.place = `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY}&excludeItems=1&expandQueries=0`; 56 }, 57 58 /** 59 * Selects a left pane built-in item. 60 * 61 * @param {string} item The built-in item to select, may be one of (case sensitive): 62 * AllBookmarks, BookmarksMenu, BookmarksToolbar, 63 * History, Downloads, Tags, UnfiledBookmarks. 64 */ 65 selectLeftPaneBuiltIn(item) { 66 switch (item) { 67 case "AllBookmarks": 68 this._places.selectItems([PlacesUtils.virtualAllBookmarksGuid]); 69 PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; 70 break; 71 case "History": 72 this._places.selectItems([PlacesUtils.virtualHistoryGuid]); 73 PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; 74 break; 75 case "Downloads": 76 this._places.selectItems([PlacesUtils.virtualDownloadsGuid]); 77 break; 78 case "Tags": 79 this._places.selectItems([PlacesUtils.virtualTagsGuid]); 80 break; 81 case "BookmarksMenu": 82 this.selectLeftPaneContainerByHierarchy([ 83 PlacesUtils.virtualAllBookmarksGuid, 84 PlacesUtils.bookmarks.virtualMenuGuid, 85 ]); 86 break; 87 case "BookmarksToolbar": 88 this.selectLeftPaneContainerByHierarchy([ 89 PlacesUtils.virtualAllBookmarksGuid, 90 PlacesUtils.bookmarks.virtualToolbarGuid, 91 ]); 92 break; 93 case "UnfiledBookmarks": 94 this.selectLeftPaneContainerByHierarchy([ 95 PlacesUtils.virtualAllBookmarksGuid, 96 PlacesUtils.bookmarks.virtualUnfiledGuid, 97 ]); 98 break; 99 default: 100 throw new Error( 101 `Unrecognized item ${item} passed to selectLeftPaneRootItem` 102 ); 103 } 104 }, 105 106 /** 107 * Opens a given hierarchy in the left pane, stopping at the last reachable 108 * container. Note: item ids should be considered deprecated. 109 * 110 * @param {Array | string | number} aHierarchy 111 * A single container or an array of containers, sorted from 112 * the outmost to the innermost in the hierarchy. Each 113 * container may be either an item id, a Places URI string, 114 * or a named query, like: 115 * "BookmarksMenu", "BookmarksToolbar", "UnfiledBookmarks", "AllBookmarks". 116 */ 117 selectLeftPaneContainerByHierarchy(aHierarchy) { 118 if (!aHierarchy) { 119 throw new Error("Containers hierarchy not specified"); 120 } 121 let hierarchy = [].concat(aHierarchy); 122 let selectWasSuppressed = 123 this._places.view.selection.selectEventsSuppressed; 124 if (!selectWasSuppressed) { 125 this._places.view.selection.selectEventsSuppressed = true; 126 } 127 try { 128 for (let container of hierarchy) { 129 if (typeof container != "string") { 130 throw new Error("Invalid container type found: " + container); 131 } 132 133 try { 134 this.selectLeftPaneBuiltIn(container); 135 } catch (ex) { 136 if (container.substr(0, 6) == "place:") { 137 this._places.selectPlaceURI(container); 138 } else { 139 // Must be a guid. 140 this._places.selectItems([container], false); 141 } 142 } 143 PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; 144 } 145 } finally { 146 if (!selectWasSuppressed) { 147 this._places.view.selection.selectEventsSuppressed = false; 148 } 149 } 150 }, 151 152 init: function PO_init() { 153 // Register the downloads view. 154 const DOWNLOADS_QUERY = 155 "place:transition=" + 156 Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + 157 "&sort=" + 158 Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; 159 160 const torWarning = new DownloadsTorWarning( 161 document.getElementById("placesDownloadsTorWarning"), 162 true, 163 () => { 164 document 165 .getElementById("downloadsListBox") 166 .focus({ preventFocusRing: true }); 167 } 168 ); 169 torWarning.activate(); 170 window.addEventListener("unload", () => { 171 torWarning.deactivate(); 172 }); 173 174 ContentArea.setContentViewForQueryString( 175 DOWNLOADS_QUERY, 176 () => 177 new DownloadsPlacesView( 178 document.getElementById("downloadsListBox"), 179 false 180 ), 181 { 182 showDetailsPane: false, 183 toolbarSet: 184 "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter", 185 } 186 ); 187 188 ContentArea.init(); 189 190 this._places = document.getElementById("placesList"); 191 this._places.addEventListener("select", () => this.onPlaceSelected(true)); 192 this._places.addEventListener("click", event => 193 this.onPlacesListClick(event) 194 ); 195 this._places.addEventListener("focus", event => 196 this.updateDetailsPane(event) 197 ); 198 199 this._initFolderTree(); 200 201 var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks 202 if (window.arguments && window.arguments[0]) { 203 leftPaneSelection = window.arguments[0]; 204 } 205 206 this.selectLeftPaneContainerByHierarchy(leftPaneSelection); 207 if (leftPaneSelection === "History") { 208 let historyNode = this._places.selectedNode; 209 if (historyNode.childCount > 0) { 210 this._places.selectNode(historyNode.getChild(0)); 211 } 212 Glean.library.opened.history.add(1); 213 } else { 214 Glean.library.opened.bookmarks.add(1); 215 } 216 217 // clear the back-stack 218 this._backHistory.splice(0, this._backHistory.length); 219 document 220 .getElementById("OrganizerCommand:Back") 221 .setAttribute("disabled", true); 222 223 // Set up the search UI. 224 PlacesSearchBox.init(); 225 ViewMenu.init(); 226 227 window.addEventListener("AppCommand", this, true); 228 document.addEventListener("command", this); 229 230 let placeContentElement = document.getElementById("placeContent"); 231 placeContentElement.addEventListener("onOpenFlatContainer", event => 232 this.openFlatContainer(event.detail) 233 ); 234 placeContentElement.addEventListener("focus", event => 235 this.updateDetailsPane(event) 236 ); 237 placeContentElement.addEventListener("select", event => 238 this.updateDetailsPane(event) 239 ); 240 241 if (AppConstants.platform === "macosx") { 242 // 1. Map Edit->Find command to OrganizerCommand_find:all. Need to map 243 // both the menuitem and the Find key. 244 let findMenuItem = document.getElementById("menu_find"); 245 findMenuItem.setAttribute("command", "OrganizerCommand_find:all"); 246 let findKey = document.getElementById("key_find"); 247 findKey.setAttribute("command", "OrganizerCommand_find:all"); 248 249 // 2. Disable some keybindings from browser.xhtml 250 let elements = ["cmd_handleBackspace", "cmd_handleShiftBackspace"]; 251 for (let i = 0; i < elements.length; i++) { 252 document.getElementById(elements[i]).setAttribute("disabled", "true"); 253 } 254 255 // 3. MacOS uses a <toolbarbutton> instead of a <menu> 256 document 257 .getElementById("organizeButton") 258 .addEventListener("popupshowing", () => { 259 document.getElementById("placeContent").focus(); 260 }); 261 } 262 263 // remove the "Edit" and "Edit Bookmark" context-menu item, we're in our own details pane 264 let contextMenu = document.getElementById("placesContext"); 265 contextMenu.removeChild(document.getElementById("placesContext_show:info")); 266 contextMenu.removeChild( 267 document.getElementById("placesContext_show_bookmark:info") 268 ); 269 contextMenu.removeChild( 270 document.getElementById("placesContext_show_folder:info") 271 ); 272 let columnsContextPopup = document.getElementById("placesColumnsContext"); 273 columnsContextPopup.addEventListener("command", event => { 274 ViewMenu.showHideColumn(event.target); 275 event.stopPropagation(); 276 }); 277 columnsContextPopup.addEventListener("popupshowing", event => 278 ViewMenu.fillWithColumns(event, null, null, "checkbox", false) 279 ); 280 281 document 282 .getElementById("fileRestorePopup") 283 .addEventListener("popupshowing", () => this.populateRestoreMenu()); 284 285 if (!Services.policies.isAllowed("profileImport")) { 286 document 287 .getElementById("OrganizerCommand_browserImport") 288 .setAttribute("disabled", true); 289 } 290 291 ContentArea.focus(); 292 }, 293 294 QueryInterface: ChromeUtils.generateQI([]), 295 296 handleEvent: function PO_handleEvent(event) { 297 switch (event.type) { 298 case "load": 299 this.init(); 300 break; 301 case "unload": 302 this.destroy(); 303 break; 304 case "command": 305 switch (event.target.id) { 306 // == organizerCommandSet == 307 case "OrganizerCommand_find:all": 308 PlacesSearchBox.findAll(); 309 break; 310 case "OrganizerCommand_export": 311 this.exportBookmarks(); 312 break; 313 case "OrganizerCommand_import": 314 this.importFromFile(); 315 break; 316 case "OrganizerCommand_browserImport": 317 this.importFromBrowser(); 318 break; 319 case "OrganizerCommand_backup": 320 this.backupBookmarks(); 321 break; 322 case "OrganizerCommand_restoreFromFile": 323 this.onRestoreBookmarksFromFile(); 324 break; 325 case "OrganizerCommand_search:save": 326 this.saveSearch(); 327 break; 328 case "OrganizerCommand_search:moreCriteria": 329 PlacesQueryBuilder.addRow(); 330 break; 331 case "OrganizerCommand:Back": 332 this.back(); 333 break; 334 case "OrganizerCommand:Forward": 335 this.forward(); 336 break; 337 case "OrganizerCommand:CloseWindow": 338 window.close(); 339 break; 340 } 341 break; 342 case "AppCommand": 343 event.stopPropagation(); 344 switch (event.command) { 345 case "Back": 346 if (this._backHistory.length) { 347 this.back(); 348 } 349 break; 350 case "Forward": 351 if (this._forwardHistory.length) { 352 this.forward(); 353 } 354 break; 355 case "Search": 356 PlacesSearchBox.findAll(); 357 break; 358 } 359 break; 360 } 361 }, 362 363 destroy: function PO_destroy() {}, 364 365 _location: null, 366 get location() { 367 return this._location; 368 }, 369 370 set location(aLocation) { 371 if (!aLocation || this._location == aLocation) { 372 return; 373 } 374 375 if (this.location) { 376 this._backHistory.unshift(this.location); 377 this._forwardHistory.splice(0, this._forwardHistory.length); 378 } 379 380 this._location = aLocation; 381 this._places.selectPlaceURI(aLocation); 382 383 if (!this._places.hasSelection) { 384 // If no node was found for the given place: uri, just load it directly 385 ContentArea.currentPlace = aLocation; 386 } 387 this.updateDetailsPane(); 388 389 // update navigation commands 390 if (!this._backHistory.length) { 391 document 392 .getElementById("OrganizerCommand:Back") 393 .setAttribute("disabled", true); 394 } else { 395 document 396 .getElementById("OrganizerCommand:Back") 397 .removeAttribute("disabled"); 398 } 399 if (!this._forwardHistory.length) { 400 document 401 .getElementById("OrganizerCommand:Forward") 402 .setAttribute("disabled", true); 403 } else { 404 document 405 .getElementById("OrganizerCommand:Forward") 406 .removeAttribute("disabled"); 407 } 408 }, 409 410 _backHistory: [], 411 _forwardHistory: [], 412 413 back: function PO_back() { 414 this._forwardHistory.unshift(this.location); 415 var historyEntry = this._backHistory.shift(); 416 this._location = null; 417 this.location = historyEntry; 418 }, 419 forward: function PO_forward() { 420 this._backHistory.unshift(this.location); 421 var historyEntry = this._forwardHistory.shift(); 422 this._location = null; 423 this.location = historyEntry; 424 }, 425 426 /** 427 * Called when a place folder is selected in the left pane. 428 * 429 * @param resetSearchBox 430 * true if the search box should also be reset, false otherwise. 431 * The search box should be reset when a new folder in the left 432 * pane is selected; the search scope and text need to be cleared in 433 * preparation for the new folder. Note that if the user manually 434 * resets the search box, either by clicking its reset button or by 435 * deleting its text, this will be false. 436 */ 437 _cachedLeftPaneSelectedURI: null, 438 onPlaceSelected: function PO_onPlaceSelected(resetSearchBox) { 439 // Don't change the right-hand pane contents when there's no selection. 440 if (!this._places.hasSelection) { 441 return; 442 } 443 444 let node = this._places.selectedNode; 445 let placeURI = node.uri; 446 447 // If either the place of the content tree in the right pane has changed or 448 // the user cleared the search box, update the place, hide the search UI, 449 // and update the back/forward buttons by setting location. 450 if (ContentArea.currentPlace != placeURI || !resetSearchBox) { 451 ContentArea.currentPlace = placeURI; 452 this.location = placeURI; 453 } 454 455 // When we invalidate a container we use suppressSelectionEvent, when it is 456 // unset a select event is fired, in many cases the selection did not really 457 // change, so we should check for it, and return early in such a case. Note 458 // that we cannot return any earlier than this point, because when 459 // !resetSearchBox, we need to update location and hide the UI as above, 460 // even though the selection has not changed. 461 if (placeURI == this._cachedLeftPaneSelectedURI) { 462 return; 463 } 464 this._cachedLeftPaneSelectedURI = placeURI; 465 466 // At this point, resetSearchBox is true, because the left pane selection 467 // has changed; otherwise we would have returned earlier. 468 469 let input = PlacesSearchBox.searchFilter; 470 input.clear(); 471 input.editor?.clearUndoRedo(); 472 this._setSearchScopeForNode(node); 473 this.updateDetailsPane(); 474 }, 475 476 /** 477 * Sets the search scope based on aNode's properties. 478 * 479 * @param {object} aNode 480 * the node to set up scope from 481 */ 482 _setSearchScopeForNode: function PO__setScopeForNode(aNode) { 483 let itemGuid = aNode.bookmarkGuid; 484 485 if ( 486 PlacesUtils.nodeIsHistoryContainer(aNode) || 487 itemGuid == PlacesUtils.virtualHistoryGuid 488 ) { 489 PlacesQueryBuilder.setScope("history"); 490 } else if (itemGuid == PlacesUtils.virtualDownloadsGuid) { 491 PlacesQueryBuilder.setScope("downloads"); 492 } else { 493 // Default to All Bookmarks for all other nodes, per bug 469437. 494 PlacesQueryBuilder.setScope("bookmarks"); 495 } 496 }, 497 498 /** 499 * Handle clicks on the places list. 500 * Single Left click, right click or modified click do not result in any 501 * special action, since they're related to selection. 502 * 503 * @param {object} aEvent 504 * The mouse event. 505 */ 506 onPlacesListClick: function PO_onPlacesListClick(aEvent) { 507 // Only handle clicks on tree children. 508 if (aEvent.target.localName != "treechildren") { 509 return; 510 } 511 512 let node = this._places.selectedNode; 513 if (node) { 514 let middleClick = aEvent.button == 1 && aEvent.detail == 1; 515 if (middleClick && PlacesUtils.nodeIsContainer(node)) { 516 // The command execution function will take care of seeing if the 517 // selection is a folder or a different container type, and will 518 // load its contents in tabs. 519 PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this._places); 520 } 521 } 522 }, 523 524 /** 525 * Handle focus changes on the places list and the current content view. 526 */ 527 updateDetailsPane: function PO_updateDetailsPane() { 528 if (!ContentArea.currentViewOptions.showDetailsPane) { 529 return; 530 } 531 // _fillDetailsPane is only invoked when the activeElement is a tree, 532 // there's no other case where we need to update the details pane. This 533 // means it's not possible that while some input field in the panel is 534 // focused we try to update the panel contents causing potential dataloss 535 // of the user's input. 536 let view = PlacesUIUtils.getViewForNode(document.activeElement); 537 if (view) { 538 let selectedNodes = view.selectedNode 539 ? [view.selectedNode] 540 : view.selectedNodes; 541 this._fillDetailsPane(selectedNodes); 542 } 543 }, 544 545 /** 546 * Handle openFlatContainer events. 547 * 548 * @param {object} aContainer 549 * The node the event was dispatched on. 550 */ 551 openFlatContainer(aContainer) { 552 if (aContainer.bookmarkGuid) { 553 PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; 554 this._places.selectItems([aContainer.bookmarkGuid], false); 555 } else if (PlacesUtils.nodeIsQuery(aContainer)) { 556 this._places.selectPlaceURI(aContainer.uri); 557 } 558 }, 559 560 /** 561 * @returns {object} 562 * Returns the options associated with the query currently loaded in the 563 * main places pane. 564 */ 565 getCurrentOptions: function PO_getCurrentOptions() { 566 return PlacesUtils.asQuery(ContentArea.currentView.result.root) 567 .queryOptions; 568 }, 569 570 /** 571 * Show the migration wizard for importing passwords, 572 * cookies, history, preferences, and bookmarks. 573 */ 574 importFromBrowser: function PO_importFromBrowser() { 575 // We pass in the type of source we're using for use in telemetry: 576 MigrationUtils.showMigrationWizard(window, { 577 entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES, 578 }); 579 }, 580 581 /** 582 * Open a file-picker and import the selected file into the bookmarks store 583 */ 584 importFromFile: function PO_importFromFile() { 585 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 586 let fpCallback = function fpCallback_done(aResult) { 587 if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) { 588 var { BookmarkHTMLUtils } = ChromeUtils.importESModule( 589 "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" 590 ); 591 BookmarkHTMLUtils.importFromURL(fp.fileURL.spec).catch(console.error); 592 } 593 }; 594 595 fp.init( 596 window.browsingContext, 597 PlacesUIUtils.promptLocalization.formatValueSync( 598 "places-bookmarks-import" 599 ), 600 Ci.nsIFilePicker.modeOpen 601 ); 602 fp.appendFilters(Ci.nsIFilePicker.filterHTML); 603 fp.open(fpCallback); 604 }, 605 606 /** 607 * Allows simple exporting of bookmarks. 608 */ 609 exportBookmarks: function PO_exportBookmarks() { 610 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 611 let fpCallback = function fpCallback_done(aResult) { 612 if (aResult != Ci.nsIFilePicker.returnCancel) { 613 var { BookmarkHTMLUtils } = ChromeUtils.importESModule( 614 "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" 615 ); 616 BookmarkHTMLUtils.exportToFile(fp.file.path).catch(console.error); 617 } 618 }; 619 620 fp.init( 621 window.browsingContext, 622 PlacesUIUtils.promptLocalization.formatValueSync( 623 "places-bookmarks-export" 624 ), 625 Ci.nsIFilePicker.modeSave 626 ); 627 fp.appendFilters(Ci.nsIFilePicker.filterHTML); 628 fp.defaultString = "bookmarks.html"; 629 fp.open(fpCallback); 630 }, 631 632 /** 633 * Populates the restore menu with the dates of the backups available. 634 */ 635 populateRestoreMenu: function PO_populateRestoreMenu() { 636 let restorePopup = document.getElementById("fileRestorePopup"); 637 638 const dtOptions = { 639 dateStyle: "long", 640 }; 641 let dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); 642 643 // Remove existing menu items. Last item is the restoreFromFile item. 644 while (restorePopup.childNodes.length > 1) { 645 restorePopup.firstChild.remove(); 646 } 647 648 (async () => { 649 let backupFiles = await PlacesBackups.getBackupFiles(); 650 if (!backupFiles.length) { 651 return; 652 } 653 654 // Populate menu with backups. 655 for (let file of backupFiles) { 656 let fileSize = (await IOUtils.stat(file)).size; 657 let [size, unit] = DownloadUtils.convertByteUnits(fileSize); 658 let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", [ 659 size, 660 unit, 661 ]); 662 663 let countString; 664 let count = PlacesBackups.getBookmarkCountForFile(file); 665 if (count != null) { 666 const [msg] = await document.l10n.formatMessages([ 667 { id: "places-details-pane-items-count", args: { count } }, 668 ]); 669 countString = msg.attributes.find( 670 attr => attr.name === "value" 671 )?.value; 672 } 673 674 const backupDate = PlacesBackups.getDateForFile(file); 675 let label = dateFormatter.format(backupDate); 676 label += countString 677 ? ` (${sizeString} - ${countString})` 678 : ` (${sizeString})`; 679 680 let m = restorePopup.insertBefore( 681 document.createXULElement("menuitem"), 682 document.getElementById("restoreFromFile") 683 ); 684 m.setAttribute("label", label); 685 m.setAttribute("value", PathUtils.filename(file)); 686 m.addEventListener("command", () => this.onRestoreMenuItemClick(m)); 687 } 688 689 // Add the restoreFromFile item. 690 restorePopup.insertBefore( 691 document.createXULElement("menuseparator"), 692 document.getElementById("restoreFromFile") 693 ); 694 })(); 695 }, 696 697 /** 698 * Called when a menuitem is selected from the restore menu. 699 * 700 * @param {object} aMenuItem The menuitem that was selected. 701 */ 702 async onRestoreMenuItemClick(aMenuItem) { 703 let backupName = aMenuItem.getAttribute("value"); 704 let backupFilePaths = await PlacesBackups.getBackupFiles(); 705 for (let backupFilePath of backupFilePaths) { 706 if (PathUtils.filename(backupFilePath) == backupName) { 707 PlacesOrganizer.restoreBookmarksFromFile(backupFilePath); 708 break; 709 } 710 } 711 }, 712 713 /** 714 * Called when 'Choose File...' is selected from the restore menu. 715 * Prompts for a file and restores bookmarks to those in the file. 716 */ 717 onRestoreBookmarksFromFile: function PO_onRestoreBookmarksFromFile() { 718 let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); 719 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 720 let fpCallback = aResult => { 721 if (aResult != Ci.nsIFilePicker.returnCancel) { 722 this.restoreBookmarksFromFile(fp.file.path); 723 } 724 }; 725 726 const [title, filterName] = 727 PlacesUIUtils.promptLocalization.formatValuesSync([ 728 "places-bookmarks-restore-title", 729 "places-bookmarks-restore-filter-name", 730 ]); 731 fp.init(window.browsingContext, title, Ci.nsIFilePicker.modeOpen); 732 fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); 733 fp.appendFilters(Ci.nsIFilePicker.filterAll); 734 fp.displayDirectory = backupsDir; 735 fp.open(fpCallback); 736 }, 737 738 /** 739 * Restores bookmarks from a JSON file. 740 * 741 * @param {string} aFilePath 742 * The path of the file to restore from. 743 */ 744 restoreBookmarksFromFile: function PO_restoreBookmarksFromFile(aFilePath) { 745 // check file extension 746 if ( 747 !aFilePath.toLowerCase().endsWith("json") && 748 !aFilePath.toLowerCase().endsWith("jsonlz4") 749 ) { 750 this._showErrorAlert("places-bookmarks-restore-format-error"); 751 return; 752 } 753 754 const [title, body] = PlacesUIUtils.promptLocalization.formatValuesSync([ 755 "places-bookmarks-restore-alert-title", 756 "places-bookmarks-restore-alert", 757 ]); 758 // confirm ok to delete existing bookmarks 759 if (!Services.prompt.confirm(null, title, body)) { 760 return; 761 } 762 763 (async function () { 764 try { 765 await BookmarkJSONUtils.importFromFile(aFilePath, { 766 replace: true, 767 }); 768 } catch (ex) { 769 PlacesOrganizer._showErrorAlert("places-bookmarks-restore-parse-error"); 770 } 771 })(); 772 }, 773 774 _showErrorAlert: function PO__showErrorAlert(l10nId) { 775 const [title, msg] = PlacesUIUtils.promptLocalization.formatValuesSync([ 776 "places-error-title", 777 l10nId, 778 ]); 779 Services.prompt.alert(window, title, msg); 780 }, 781 782 /** 783 * Backup bookmarks to desktop, auto-generate a filename with a date. 784 * The file is a JSON serialization of bookmarks, tags and any annotations 785 * of those items. 786 */ 787 backupBookmarks: function PO_backupBookmarks() { 788 let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); 789 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); 790 let fpCallback = function fpCallback_done(aResult) { 791 if (aResult != Ci.nsIFilePicker.returnCancel) { 792 // There is no OS.File version of the filepicker yet (Bug 937812). 793 PlacesBackups.saveBookmarksToJSONFile(fp.file.path).catch( 794 console.error 795 ); 796 } 797 }; 798 799 const [title, filterName] = 800 PlacesUIUtils.promptLocalization.formatValuesSync([ 801 "places-bookmarks-backup-title", 802 "places-bookmarks-restore-filter-name", 803 ]); 804 fp.init(window.browsingContext, title, Ci.nsIFilePicker.modeSave); 805 fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); 806 fp.defaultString = PlacesBackups.getFilenameForDate(); 807 fp.defaultExtension = "json"; 808 fp.displayDirectory = backupsDir; 809 fp.open(fpCallback); 810 }, 811 812 _fillDetailsPane: function PO__fillDetailsPane(aNodeList) { 813 var infoBox = document.getElementById("infoBox"); 814 var itemsCountBox = document.getElementById("itemsCountBox"); 815 816 // Make sure the infoBox UI is visible if we need to use it, we hide it 817 // below when we don't. 818 infoBox.hidden = false; 819 itemsCountBox.hidden = true; 820 821 let selectedNode = aNodeList.length == 1 ? aNodeList[0] : null; 822 823 // Don't update the panel if it's already editing this node, unless we're 824 // in multi-edit mode. 825 if ( 826 selectedNode && 827 !gEditItemOverlay.multiEdit && 828 ((gEditItemOverlay.concreteGuid && 829 gEditItemOverlay.concreteGuid == 830 PlacesUtils.getConcreteItemGuid(selectedNode)) || 831 (!selectedNode.bookmarkGuid && 832 gEditItemOverlay.uri && 833 gEditItemOverlay.uri == selectedNode.uri)) 834 ) { 835 return; 836 } 837 838 // Clean up the panel before initing it again. 839 gEditItemOverlay.uninitPanel(false); 840 841 if (selectedNode && !PlacesUtils.nodeIsSeparator(selectedNode)) { 842 gEditItemOverlay 843 .initPanel({ 844 node: selectedNode, 845 hiddenRows: ["folderPicker"], 846 }) 847 .catch(ex => console.error(ex)); 848 } else if (!selectedNode && aNodeList[0]) { 849 if (aNodeList.every(PlacesUtils.nodeIsURI)) { 850 let uris = aNodeList.map(node => Services.io.newURI(node.uri)); 851 gEditItemOverlay 852 .initPanel({ 853 uris, 854 hiddenRows: ["folderPicker", "location", "keyword", "name"], 855 }) 856 .catch(ex => console.error(ex)); 857 } else { 858 let selectItemDesc = document.getElementById("selectItemDescription"); 859 let itemsCountLabel = document.getElementById("itemsCountText"); 860 selectItemDesc.hidden = false; 861 document.l10n.setAttributes( 862 itemsCountLabel, 863 "places-details-pane-items-count", 864 { count: aNodeList.length } 865 ); 866 infoBox.hidden = true; 867 } 868 } else { 869 infoBox.hidden = true; 870 let selectItemDesc = document.getElementById("selectItemDescription"); 871 let itemsCountLabel = document.getElementById("itemsCountText"); 872 let itemsCount = 0; 873 if (ContentArea.currentView.result) { 874 let rootNode = ContentArea.currentView.result.root; 875 if (rootNode.containerOpen) { 876 itemsCount = rootNode.childCount; 877 } 878 } 879 if (itemsCount == 0) { 880 selectItemDesc.hidden = true; 881 document.l10n.setAttributes( 882 itemsCountLabel, 883 "places-details-pane-no-items" 884 ); 885 } else { 886 selectItemDesc.hidden = false; 887 document.l10n.setAttributes( 888 itemsCountLabel, 889 "places-details-pane-items-count", 890 { count: itemsCount } 891 ); 892 } 893 } 894 itemsCountBox.hidden = !infoBox.hidden; 895 }, 896 }; 897 898 window.addEventListener("load", PlacesOrganizer); 899 window.addEventListener("unload", PlacesOrganizer); 900 901 /** 902 * A set of utilities relating to search within Bookmarks and History. 903 */ 904 var PlacesSearchBox = { 905 /** 906 * The Search text field 907 * 908 * @see {@link https://searchfox.org/mozilla-central/source/toolkit/content/widgets/moz-input-search} 909 * @returns {HTMLInputElement} 910 */ 911 get searchFilter() { 912 return document.getElementById("searchFilter"); 913 }, 914 915 cumulativeHistorySearches: 0, 916 cumulativeBookmarkSearches: 0, 917 918 /** 919 * Folders to include when searching. 920 */ 921 _folders: [], 922 get folders() { 923 if (!this._folders.length) { 924 this._folders = PlacesUtils.bookmarks.userContentRoots; 925 } 926 return this._folders; 927 }, 928 set folders(aFolders) { 929 this._folders = aFolders; 930 }, 931 932 /** 933 * Run a search for the specified text, over the collection specified by 934 * the dropdown arrow. The default is all bookmarks, but can be 935 * localized to the active collection. 936 * 937 * @param {string} filterString 938 * The text to search for. 939 */ 940 search(filterString) { 941 var PO = PlacesOrganizer; 942 // If the user empties the search box manually, reset it and load all 943 // contents of the current scope. 944 // XXX this might be to jumpy, maybe should search for "", so results 945 // are ungrouped, and search box not reset 946 if (filterString == "") { 947 PO.onPlaceSelected(false); 948 return; 949 } 950 951 let currentView = ContentArea.currentView; 952 953 // Search according to the current scope, which was set by 954 // PQB_setScope() 955 switch (PlacesSearchBox.filterCollection) { 956 case "bookmarks": 957 currentView.applyFilter(filterString, this.folders); 958 Glean.library.search.bookmarks.add(1); 959 this.cumulativeBookmarkSearches++; 960 break; 961 case "history": { 962 let currentOptions = PO.getCurrentOptions(); 963 if ( 964 currentOptions.queryType != 965 Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY 966 ) { 967 let query = PlacesUtils.history.getNewQuery(); 968 query.searchTerms = filterString; 969 let options = currentOptions.clone(); 970 // Make sure we're getting uri results. 971 options.resultType = currentOptions.RESULTS_AS_URI; 972 options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; 973 options.includeHidden = true; 974 currentView.load([query], options); 975 } else { 976 let timerId = Glean.library.historySearchTime.start(); 977 currentView.applyFilter(filterString, null, true); 978 Glean.library.historySearchTime.stopAndAccumulate(timerId); 979 Glean.library.search.history.add(1); 980 this.cumulativeHistorySearches++; 981 } 982 break; 983 } 984 case "downloads": { 985 // The new downloads view doesn't use places for searching downloads. 986 currentView.searchTerm = filterString; 987 break; 988 } 989 default: 990 throw new Error("Invalid filterCollection on search"); 991 } 992 993 // Update the details panel 994 PlacesOrganizer.updateDetailsPane(); 995 }, 996 997 /** 998 * Finds across all history, downloads or all bookmarks. 999 */ 1000 findAll() { 1001 switch (this.filterCollection) { 1002 case "history": 1003 PlacesQueryBuilder.setScope("history"); 1004 break; 1005 case "downloads": 1006 PlacesQueryBuilder.setScope("downloads"); 1007 break; 1008 default: 1009 PlacesQueryBuilder.setScope("bookmarks"); 1010 break; 1011 } 1012 this.focus(); 1013 }, 1014 1015 /** 1016 * Updates the search input placeholder to match the current collection. 1017 */ 1018 updatePlaceholder() { 1019 let l10nId = ""; 1020 switch (this.filterCollection) { 1021 case "history": 1022 l10nId = "places-search-history"; 1023 break; 1024 case "downloads": 1025 l10nId = "places-search-downloads"; 1026 break; 1027 default: 1028 l10nId = "places-search-bookmarks"; 1029 } 1030 document.l10n.setAttributes(this.searchFilter, l10nId); 1031 }, 1032 1033 /** 1034 * Gets/sets the active collection from the dropdown menu. 1035 * 1036 * @returns {string} 1037 */ 1038 get filterCollection() { 1039 return this.searchFilter.getAttribute("collection"); 1040 }, 1041 set filterCollection(collectionName) { 1042 if (collectionName == this.filterCollection) { 1043 return; 1044 } 1045 1046 this.searchFilter.setAttribute("collection", collectionName); 1047 this.updatePlaceholder(); 1048 }, 1049 1050 /** 1051 * Focus the search box 1052 */ 1053 focus() { 1054 this.searchFilter.focus(); 1055 }, 1056 1057 /** 1058 * Set up the gray text in the search bar as the Places View loads. 1059 */ 1060 init() { 1061 this.searchFilter.addEventListener("MozInputSearch:search", e => { 1062 this.search(e.target.value); 1063 }); 1064 this.updatePlaceholder(); 1065 }, 1066 1067 /** 1068 * Gets or sets the text shown in the Places Search Box 1069 * 1070 * @returns {string} 1071 */ 1072 get value() { 1073 return this.searchFilter.value; 1074 }, 1075 set value(value) { 1076 this.searchFilter.value = value; 1077 }, 1078 }; 1079 1080 function updateTelemetry(urlsOpened) { 1081 let historyLinks = urlsOpened.filter( 1082 link => !link.isBookmark && !PlacesUtils.nodeIsBookmark(link) 1083 ); 1084 if (!historyLinks.length) { 1085 Glean.library.cumulativeBookmarkSearches.accumulateSingleSample( 1086 PlacesSearchBox.cumulativeBookmarkSearches 1087 ); 1088 1089 // Clear cumulative search counter 1090 PlacesSearchBox.cumulativeBookmarkSearches = 0; 1091 1092 Glean.library.link.bookmarks.add(urlsOpened.length); 1093 return; 1094 } 1095 1096 // Record cumulative search count before selecting History link from Library 1097 Glean.library.cumulativeHistorySearches.accumulateSingleSample( 1098 PlacesSearchBox.cumulativeHistorySearches 1099 ); 1100 1101 // Clear cumulative search counter 1102 PlacesSearchBox.cumulativeHistorySearches = 0; 1103 1104 Glean.library.link.history.add(historyLinks.length); 1105 } 1106 1107 /** 1108 * Functions and data for advanced query builder 1109 */ 1110 var PlacesQueryBuilder = { 1111 queries: [], 1112 queryOptions: null, 1113 1114 /** 1115 * Sets the search scope. This can be called when no search is active, and 1116 * in that case, when `search()` is called, `aScope` will be used. 1117 * If there is an active search, it's performed again to 1118 * update the content tree. 1119 * 1120 * @param {"bookmarks" | "downloads" | "history"} aScope 1121 * The search scope: "bookmarks", "downloads" or "history". 1122 */ 1123 setScope(aScope) { 1124 // Determine filterCollection, folders, and scopeButtonId based on aScope. 1125 var filterCollection; 1126 var folders = []; 1127 switch (aScope) { 1128 case "history": 1129 filterCollection = "history"; 1130 break; 1131 case "bookmarks": 1132 filterCollection = "bookmarks"; 1133 folders = PlacesUtils.bookmarks.userContentRoots; 1134 break; 1135 case "downloads": 1136 filterCollection = "downloads"; 1137 break; 1138 default: 1139 throw new Error("Invalid search scope"); 1140 } 1141 1142 // Update the search box. Re-search if there's an active search. 1143 PlacesSearchBox.filterCollection = filterCollection; 1144 PlacesSearchBox.folders = folders; 1145 var searchStr = PlacesSearchBox.searchFilter.value; 1146 if (searchStr) { 1147 PlacesSearchBox.search(searchStr); 1148 } 1149 }, 1150 }; 1151 1152 /** 1153 * Population and commands for the View Menu. 1154 */ 1155 var ViewMenu = { 1156 init() { 1157 let columnsPopup = document.querySelector("#viewColumns > menupopup"); 1158 columnsPopup.addEventListener("command", event => { 1159 event.stopPropagation(); 1160 this.showHideColumn(event.target); 1161 }); 1162 columnsPopup.addEventListener("popupshowing", event => 1163 this.fillWithColumns(event, null, null, "checkbox", false) 1164 ); 1165 1166 let sortPopup = document.querySelector("#viewSort > menupopup"); 1167 sortPopup.addEventListener("command", event => { 1168 event.stopPropagation(); 1169 1170 switch (event.target.id) { 1171 case "viewUnsorted": 1172 this.setSortColumn(null, null); 1173 break; 1174 case "viewSortAscending": 1175 this.setSortColumn(null, "ascending"); 1176 break; 1177 case "viewSortDescending": 1178 this.setSortColumn(null, "descending"); 1179 break; 1180 default: 1181 this.setSortColumn(event.target.column, null); 1182 break; 1183 } 1184 }); 1185 sortPopup.addEventListener("popupshowing", event => 1186 this.populateSortMenu(event) 1187 ); 1188 }, 1189 1190 /** 1191 * Removes content generated previously from a menupopup. 1192 * 1193 * @param {object} popup 1194 * The popup that contains the previously generated content. 1195 * @param {string} startID 1196 * The id attribute of an element that is the start of the 1197 * dynamically generated region - remove elements after this 1198 * item only. 1199 * Must be contained by popup. Can be null (in which case the 1200 * contents of popup are removed). 1201 * @param {string} endID 1202 * The id attribute of an element that is the end of the 1203 * dynamically generated region - remove elements up to this 1204 * item only. 1205 * Must be contained by popup. Can be null (in which case all 1206 * items until the end of the popup will be removed). Ignored 1207 * if startID is null. 1208 * @returns {object|null} The element for the caller to insert new items before, 1209 * null if the caller should just append to the popup. 1210 */ 1211 _clean: function VM__clean(popup, startID, endID) { 1212 if (endID && !startID) { 1213 throw new Error("meaningless to have valid endID and null startID"); 1214 } 1215 if (startID) { 1216 var startElement = document.getElementById(startID); 1217 if (startElement.parentNode != popup) { 1218 throw new Error("startElement is not in popup"); 1219 } 1220 if (!startElement) { 1221 throw new Error("startID does not correspond to an existing element"); 1222 } 1223 var endElement = null; 1224 if (endID) { 1225 endElement = document.getElementById(endID); 1226 if (endElement.parentNode != popup) { 1227 throw new Error("endElement is not in popup"); 1228 } 1229 if (!endElement) { 1230 throw new Error("endID does not correspond to an existing element"); 1231 } 1232 } 1233 while (startElement.nextSibling != endElement) { 1234 popup.removeChild(startElement.nextSibling); 1235 } 1236 return endElement; 1237 } 1238 while (popup.hasChildNodes()) { 1239 popup.firstChild.remove(); 1240 } 1241 return null; 1242 }, 1243 1244 /** 1245 * Fills a menupopup with a list of columns 1246 * 1247 * @param {object} event 1248 * The popupshowing event that invoked this function. 1249 * @param {string} startID 1250 * see _clean 1251 * @param {string} endID 1252 * see _clean 1253 * @param {string} type 1254 * the type of the menuitem, e.g. "radio" or "checkbox". 1255 * Can be null (no-type). 1256 * Checkboxes are checked if the column is visible. 1257 * @param {boolean} localize 1258 * If localize is true, the column label and accesskey are set 1259 * via DOM Localization. 1260 * If localize is false, the column label is used as label and 1261 * no accesskey is assigned. 1262 */ 1263 fillWithColumns: function VM_fillWithColumns( 1264 event, 1265 startID, 1266 endID, 1267 type, 1268 localize 1269 ) { 1270 var popup = event.target; 1271 var pivot = this._clean(popup, startID, endID); 1272 1273 var content = document.getElementById("placeContent"); 1274 var columns = content.columns; 1275 for (var i = 0; i < columns.count; ++i) { 1276 var column = columns.getColumnAt(i).element; 1277 var menuitem = document.createXULElement("menuitem"); 1278 menuitem.id = "menucol_" + column.id; 1279 menuitem.column = column; 1280 if (localize) { 1281 const l10nId = SORTBY_L10N_IDS.get(column.getAttribute("anonid")); 1282 document.l10n.setAttributes(menuitem, l10nId); 1283 } else { 1284 const label = column.getAttribute("label"); 1285 menuitem.setAttribute("label", label); 1286 } 1287 if (type == "radio") { 1288 menuitem.setAttribute("type", "radio"); 1289 menuitem.setAttribute("name", "columns"); 1290 // This column is the sort key. Its item is checked. 1291 if (column.hasAttribute("sortDirection")) { 1292 menuitem.setAttribute("checked", "true"); 1293 } 1294 } else if (type == "checkbox") { 1295 menuitem.setAttribute("type", "checkbox"); 1296 // Cannot uncheck the primary column. 1297 if (column.getAttribute("primary") == "true") { 1298 menuitem.setAttribute("disabled", "true"); 1299 } 1300 // Items for visible columns are checked. 1301 if (!column.hidden) { 1302 menuitem.setAttribute("checked", "true"); 1303 } 1304 } 1305 if (pivot) { 1306 popup.insertBefore(menuitem, pivot); 1307 } else { 1308 popup.appendChild(menuitem); 1309 } 1310 } 1311 event.stopPropagation(); 1312 }, 1313 1314 /** 1315 * Set up the content of the view menu. 1316 * 1317 * @param {object} event 1318 * The event that invoked this function 1319 */ 1320 populateSortMenu: function VM_populateSortMenu(event) { 1321 this.fillWithColumns( 1322 event, 1323 "viewUnsorted", 1324 "directionSeparator", 1325 "radio", 1326 true 1327 ); 1328 1329 var sortColumn = this._getSortColumn(); 1330 var viewSortAscending = document.getElementById("viewSortAscending"); 1331 var viewSortDescending = document.getElementById("viewSortDescending"); 1332 // We need to remove an existing checked attribute because the unsorted 1333 // menu item is not rebuilt every time we open the menu like the others. 1334 var viewUnsorted = document.getElementById("viewUnsorted"); 1335 if (!sortColumn) { 1336 viewSortAscending.removeAttribute("checked"); 1337 viewSortDescending.removeAttribute("checked"); 1338 viewUnsorted.setAttribute("checked", "true"); 1339 } else if (sortColumn.getAttribute("sortDirection") == "ascending") { 1340 viewSortAscending.setAttribute("checked", "true"); 1341 viewSortDescending.removeAttribute("checked"); 1342 viewUnsorted.removeAttribute("checked"); 1343 } else if (sortColumn.getAttribute("sortDirection") == "descending") { 1344 viewSortDescending.setAttribute("checked", "true"); 1345 viewSortAscending.removeAttribute("checked"); 1346 viewUnsorted.removeAttribute("checked"); 1347 } 1348 }, 1349 1350 /** 1351 * Shows/Hides a tree column. 1352 * 1353 * @param {object} element 1354 * The menuitem element for the column 1355 */ 1356 showHideColumn: function VM_showHideColumn(element) { 1357 var column = element.column; 1358 1359 var splitter = column.nextSibling; 1360 if (splitter && splitter.localName != "splitter") { 1361 splitter = null; 1362 } 1363 1364 const isChecked = element.getAttribute("checked") == "true"; 1365 column.hidden = !isChecked; 1366 if (splitter) { 1367 splitter.hidden = !isChecked; 1368 } 1369 }, 1370 1371 /** 1372 * Gets the last column that was sorted. 1373 * 1374 * @returns {object|null} the currently sorted column, null if there is no sorted column. 1375 */ 1376 _getSortColumn: function VM__getSortColumn() { 1377 var content = document.getElementById("placeContent"); 1378 var cols = content.columns; 1379 for (var i = 0; i < cols.count; ++i) { 1380 var column = cols.getColumnAt(i).element; 1381 var sortDirection = column.getAttribute("sortDirection"); 1382 if (sortDirection == "ascending" || sortDirection == "descending") { 1383 return column; 1384 } 1385 } 1386 return null; 1387 }, 1388 1389 /** 1390 * Sorts the view by the specified column. 1391 * 1392 * @param {object} aColumn 1393 * The colum that is the sort key. Can be null - the 1394 * current sort column or the title column will be used. 1395 * @param {string} aDirection 1396 * The direction to sort - "ascending" or "descending". 1397 * Can be null - the last direction or descending will be used. 1398 * 1399 * If both aColumnID and aDirection are null, the view will be unsorted. 1400 */ 1401 setSortColumn: function VM_setSortColumn(aColumn, aDirection) { 1402 var result = document.getElementById("placeContent").result; 1403 if (!aColumn && !aDirection) { 1404 result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; 1405 return; 1406 } 1407 1408 var columnId; 1409 if (aColumn) { 1410 columnId = aColumn.getAttribute("anonid"); 1411 if (!aDirection) { 1412 let sortColumn = this._getSortColumn(); 1413 if (sortColumn) { 1414 aDirection = sortColumn.getAttribute("sortDirection"); 1415 } 1416 } 1417 } else { 1418 let sortColumn = this._getSortColumn(); 1419 columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title"; 1420 } 1421 1422 // This maps the possible values of columnId (i.e., anonid's of treecols in 1423 // placeContent) to the default sortingMode for each column. 1424 // key: Sort key in the name of one of the 1425 // nsINavHistoryQueryOptions.SORT_BY_* constants 1426 // dir: Default sort direction to use if none has been specified 1427 const colLookupTable = { 1428 title: { key: "TITLE", dir: "ascending" }, 1429 tags: { key: "TAGS", dir: "ascending" }, 1430 url: { key: "URI", dir: "ascending" }, 1431 date: { key: "DATE", dir: "descending" }, 1432 visitCount: { key: "VISITCOUNT", dir: "descending" }, 1433 dateAdded: { key: "DATEADDED", dir: "descending" }, 1434 lastModified: { key: "LASTMODIFIED", dir: "descending" }, 1435 }; 1436 1437 // Make sure we have a valid column. 1438 if (!colLookupTable.hasOwnProperty(columnId)) { 1439 throw new Error("Invalid column"); 1440 } 1441 1442 // Use a default sort direction if none has been specified. If aDirection 1443 // is invalid, result.sortingMode will be undefined, which has the effect 1444 // of unsorting the tree. 1445 aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase(); 1446 1447 var sortConst = 1448 "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection; 1449 result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst]; 1450 }, 1451 }; 1452 1453 var ContentArea = { 1454 _specialViews: new Map(), 1455 1456 init: function CA_init() { 1457 this._box = document.getElementById("placesViewsBox"); 1458 this._toolbar = document.getElementById("placesToolbar"); 1459 ContentTree.init(); 1460 this._setupView(); 1461 }, 1462 1463 /** 1464 * Gets the content view to be used for loading the given query. 1465 * If a custom view was set by setContentViewForQueryString, that 1466 * view would be returned, else the default tree view is returned 1467 * 1468 * @param {string} aQueryString 1469 * a query string 1470 * @returns {object} the view to be used for loading aQueryString. 1471 */ 1472 getContentViewForQueryString: function CA_getContentViewForQueryString( 1473 aQueryString 1474 ) { 1475 try { 1476 if (this._specialViews.has(aQueryString)) { 1477 let { view, options } = this._specialViews.get(aQueryString); 1478 if (typeof view == "function") { 1479 view = view(); 1480 this._specialViews.set(aQueryString, { view, options }); 1481 } 1482 return view; 1483 } 1484 } catch (ex) { 1485 console.error(ex); 1486 } 1487 return ContentTree.view; 1488 }, 1489 1490 /** 1491 * Sets a custom view to be used rather than the default places tree 1492 * whenever the given query is selected in the left pane. 1493 * 1494 * @param {string} aQueryString 1495 * a query string 1496 * @param {object} aView 1497 * Either the custom view or a function that will return the view 1498 * the first (and only) time it's called. 1499 * @param {object} [aOptions] 1500 * Object defining special options for the view. 1501 * @see ContentTree.viewOptions for supported options and default values. 1502 */ 1503 setContentViewForQueryString: function CA_setContentViewForQueryString( 1504 aQueryString, 1505 aView, 1506 aOptions 1507 ) { 1508 if ( 1509 !aQueryString || 1510 (typeof aView != "object" && typeof aView != "function") 1511 ) { 1512 throw new Error("Invalid arguments"); 1513 } 1514 1515 this._specialViews.set(aQueryString, { 1516 view: aView, 1517 options: aOptions || {}, 1518 }); 1519 }, 1520 1521 get currentView() { 1522 let selectedPane = [...this._box.children].filter( 1523 child => !child.hidden 1524 )[0]; 1525 return PlacesUIUtils.getViewForNode(selectedPane); 1526 }, 1527 set currentView(aNewView) { 1528 let oldView = this.currentView; 1529 if (oldView != aNewView) { 1530 oldView.associatedElement.hidden = true; 1531 aNewView.associatedElement.hidden = false; 1532 1533 // Hide the Tor warning when not in the downloads view. 1534 const isDownloads = aNewView.associatedElement.id === "downloadsListBox"; 1535 const torWarningMessage = document.getElementById( 1536 "placesDownloadsTorWarning" 1537 ); 1538 const torWarningLoosingFocus = 1539 torWarningMessage.contains(document.activeElement) && !isDownloads; 1540 torWarningMessage.classList.toggle("downloads-visible", isDownloads); 1541 1542 // If the content area inactivated view was focused, move focus 1543 // to the new view. 1544 if ( 1545 document.activeElement == oldView.associatedElement || 1546 torWarningLoosingFocus 1547 ) { 1548 aNewView.associatedElement.focus(); 1549 } 1550 } 1551 }, 1552 1553 get currentPlace() { 1554 return this.currentView.place; 1555 }, 1556 set currentPlace(aQueryString) { 1557 let oldView = this.currentView; 1558 let newView = this.getContentViewForQueryString(aQueryString); 1559 newView.place = aQueryString; 1560 if (oldView != newView) { 1561 oldView.active = false; 1562 this.currentView = newView; 1563 this._setupView(); 1564 newView.active = true; 1565 } 1566 }, 1567 1568 /** 1569 * Applies view options. 1570 */ 1571 _setupView: function CA__setupView() { 1572 let options = this.currentViewOptions; 1573 1574 // showDetailsPane. 1575 let detailsPane = document.getElementById("detailsPane"); 1576 detailsPane.hidden = !options.showDetailsPane; 1577 1578 // toolbarSet. 1579 for (let elt of this._toolbar.childNodes) { 1580 // On Windows and Linux the menu buttons are menus wrapped in a menubar. 1581 if (elt.id == "placesMenu") { 1582 for (let menuElt of elt.childNodes) { 1583 menuElt.hidden = !options.toolbarSet.includes(menuElt.id); 1584 } 1585 } else { 1586 elt.hidden = !options.toolbarSet.includes(elt.id); 1587 } 1588 } 1589 }, 1590 1591 /** 1592 * Options for the current view. 1593 * 1594 * @see {@link ContentTree.viewOptions} for supported options and default values. 1595 * @returns {{showDetailsPane: boolean;toolbarSet: string;}} 1596 */ 1597 get currentViewOptions() { 1598 // Use ContentTree options as default. 1599 let viewOptions = ContentTree.viewOptions; 1600 if (this._specialViews.has(this.currentPlace)) { 1601 let { options } = this._specialViews.get(this.currentPlace); 1602 for (let option in options) { 1603 viewOptions[option] = options[option]; 1604 } 1605 } 1606 return viewOptions; 1607 }, 1608 1609 focus() { 1610 this.currentView.associatedElement.focus(); 1611 }, 1612 }; 1613 1614 var ContentTree = { 1615 init: function CT_init() { 1616 this._view = document.getElementById("placeContent"); 1617 this.view.addEventListener("keypress", this); 1618 document 1619 .querySelector("#placeContent > treechildren") 1620 .addEventListener("click", this); 1621 }, 1622 1623 get view() { 1624 return this._view; 1625 }, 1626 1627 get viewOptions() { 1628 return Object.seal({ 1629 showDetailsPane: true, 1630 toolbarSet: 1631 "back-button, forward-button, organizeButton, viewMenu, maintenanceButton, libraryToolbarSpacer, searchFilter", 1632 }); 1633 }, 1634 1635 openSelectedNode: function CT_openSelectedNode(aEvent) { 1636 let view = this.view; 1637 PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent); 1638 }, 1639 1640 handleEvent(event) { 1641 switch (event.type) { 1642 case "click": 1643 this.onClick(event); 1644 break; 1645 case "keypress": 1646 this.onKeyPress(event); 1647 break; 1648 } 1649 }, 1650 1651 onClick: function CT_onClick(aEvent) { 1652 let node = this.view.selectedNode; 1653 if (node) { 1654 let doubleClick = aEvent.button == 0 && aEvent.detail == 2; 1655 let middleClick = aEvent.button == 1 && aEvent.detail == 1; 1656 if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) { 1657 // Open associated uri in the browser. 1658 this.openSelectedNode(aEvent); 1659 } else if (middleClick && PlacesUtils.nodeIsContainer(node)) { 1660 // The command execution function will take care of seeing if the 1661 // selection is a folder or a different container type, and will 1662 // load its contents in tabs. 1663 PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this.view); 1664 } 1665 } 1666 }, 1667 1668 onKeyPress: function CT_onKeyPress(aEvent) { 1669 if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { 1670 this.openSelectedNode(aEvent); 1671 } 1672 }, 1673 };