tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };