diff options
Diffstat (limited to 'browser/components/places/content/places.js')
-rw-r--r-- | browser/components/places/content/places.js | 1534 |
1 files changed, 1534 insertions, 0 deletions
diff --git a/browser/components/places/content/places.js b/browser/components/places/content/places.js new file mode 100644 index 0000000000..a9448d37d2 --- /dev/null +++ b/browser/components/places/content/places.js @@ -0,0 +1,1534 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from editBookmark.js */ +/* import-globals-from /toolkit/content/contentAreaUtils.js */ +/* import-globals-from /browser/components/downloads/content/allDownloadsView.js */ + +/* Shared Places Import - change other consumers if you change this: */ +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", +}); +XPCOMUtils.defineLazyScriptGetter( + this, + "PlacesTreeView", + "chrome://browser/content/places/treeView.js" +); +XPCOMUtils.defineLazyScriptGetter( + this, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://browser/content/places/controller.js" +); +/* End Shared Places Import */ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4"; +const HISTORY_LIBRARY_SEARCH_TELEMETRY = + "PLACES_HISTORY_LIBRARY_SEARCH_TIME_MS"; + +const SORTBY_L10N_IDS = new Map([ + ["title", "places-view-sortby-name"], + ["url", "places-view-sortby-url"], + ["date", "places-view-sortby-date"], + ["visitCount", "places-view-sortby-visit-count"], + ["dateAdded", "places-view-sortby-date-added"], + ["lastModified", "places-view-sortby-last-modified"], + ["tags", "places-view-sortby-tags"], +]); + +var PlacesOrganizer = { + _places: null, + + _initFolderTree() { + this._places.place = `place:type=${Ci.nsINavHistoryQueryOptions.RESULTS_AS_LEFT_PANE_QUERY}&excludeItems=1&expandQueries=0`; + }, + + /** + * Selects a left pane built-in item. + * + * @param {string} item The built-in item to select, may be one of (case sensitive): + * AllBookmarks, BookmarksMenu, BookmarksToolbar, + * History, Downloads, Tags, UnfiledBookmarks. + */ + selectLeftPaneBuiltIn(item) { + switch (item) { + case "AllBookmarks": + this._places.selectItems([PlacesUtils.virtualAllBookmarksGuid]); + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + case "History": + this._places.selectItems([PlacesUtils.virtualHistoryGuid]); + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + case "Downloads": + this._places.selectItems([PlacesUtils.virtualDownloadsGuid]); + break; + case "Tags": + this._places.selectItems([PlacesUtils.virtualTagsGuid]); + break; + case "BookmarksMenu": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualMenuGuid, + ]); + break; + case "BookmarksToolbar": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualToolbarGuid, + ]); + break; + case "UnfiledBookmarks": + this.selectLeftPaneContainerByHierarchy([ + PlacesUtils.virtualAllBookmarksGuid, + PlacesUtils.bookmarks.virtualUnfiledGuid, + ]); + break; + default: + throw new Error( + `Unrecognized item ${item} passed to selectLeftPaneRootItem` + ); + } + }, + + /** + * Opens a given hierarchy in the left pane, stopping at the last reachable + * container. Note: item ids should be considered deprecated. + * + * @param {Array | string | number} aHierarchy + * A single container or an array of containers, sorted from + * the outmost to the innermost in the hierarchy. Each + * container may be either an item id, a Places URI string, + * or a named query, like: + * "BookmarksMenu", "BookmarksToolbar", "UnfiledBookmarks", "AllBookmarks". + */ + selectLeftPaneContainerByHierarchy(aHierarchy) { + if (!aHierarchy) { + throw new Error("Containers hierarchy not specified"); + } + let hierarchy = [].concat(aHierarchy); + let selectWasSuppressed = + this._places.view.selection.selectEventsSuppressed; + if (!selectWasSuppressed) { + this._places.view.selection.selectEventsSuppressed = true; + } + try { + for (let container of hierarchy) { + if (typeof container != "string") { + throw new Error("Invalid container type found: " + container); + } + + try { + this.selectLeftPaneBuiltIn(container); + } catch (ex) { + if (container.substr(0, 6) == "place:") { + this._places.selectPlaceURI(container); + } else { + // Must be a guid. + this._places.selectItems([container], false); + } + } + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + } + } finally { + if (!selectWasSuppressed) { + this._places.view.selection.selectEventsSuppressed = false; + } + } + }, + + init: function PO_init() { + // Register the downloads view. + const DOWNLOADS_QUERY = + "place:transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&sort=" + + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; + + ContentArea.setContentViewForQueryString( + DOWNLOADS_QUERY, + () => + new DownloadsPlacesView( + document.getElementById("downloadsListBox"), + false + ), + { + showDetailsPane: false, + toolbarSet: + "back-button, forward-button, organizeButton, clearDownloadsButton, libraryToolbarSpacer, searchFilter", + } + ); + + ContentArea.init(); + + this._places = document.getElementById("placesList"); + this._initFolderTree(); + + var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks + if (window.arguments && window.arguments[0]) { + leftPaneSelection = window.arguments[0]; + } + + this.selectLeftPaneContainerByHierarchy(leftPaneSelection); + if (leftPaneSelection === "History") { + let historyNode = this._places.selectedNode; + if (historyNode.childCount > 0) { + this._places.selectNode(historyNode.getChild(0)); + } + Services.telemetry.keyedScalarAdd("library.opened", "history", 1); + } else { + Services.telemetry.keyedScalarAdd("library.opened", "bookmarks", 1); + } + + // clear the back-stack + this._backHistory.splice(0, this._backHistory.length); + document + .getElementById("OrganizerCommand:Back") + .setAttribute("disabled", true); + + // Set up the search UI. + PlacesSearchBox.init(); + + window.addEventListener("AppCommand", this, true); + + let placeContentElement = document.getElementById("placeContent"); + placeContentElement.addEventListener("onOpenFlatContainer", function (e) { + PlacesOrganizer.openFlatContainer(e.detail); + }); + + if (AppConstants.platform === "macosx") { + // 1. Map Edit->Find command to OrganizerCommand_find:all. Need to map + // both the menuitem and the Find key. + let findMenuItem = document.getElementById("menu_find"); + findMenuItem.setAttribute("command", "OrganizerCommand_find:all"); + let findKey = document.getElementById("key_find"); + findKey.setAttribute("command", "OrganizerCommand_find:all"); + + // 2. Disable some keybindings from browser.xhtml + let elements = ["cmd_handleBackspace", "cmd_handleShiftBackspace"]; + for (let i = 0; i < elements.length; i++) { + document.getElementById(elements[i]).setAttribute("disabled", "true"); + } + } + + // remove the "Edit" and "Edit Bookmark" context-menu item, we're in our own details pane + let contextMenu = document.getElementById("placesContext"); + contextMenu.removeChild(document.getElementById("placesContext_show:info")); + contextMenu.removeChild( + document.getElementById("placesContext_show_bookmark:info") + ); + contextMenu.removeChild( + document.getElementById("placesContext_show_folder:info") + ); + + if (!Services.policies.isAllowed("profileImport")) { + document + .getElementById("OrganizerCommand_browserImport") + .setAttribute("disabled", true); + } + + ContentArea.focus(); + }, + + QueryInterface: ChromeUtils.generateQI([]), + + handleEvent: function PO_handleEvent(aEvent) { + if (aEvent.type != "AppCommand") { + return; + } + + aEvent.stopPropagation(); + switch (aEvent.command) { + case "Back": + if (this._backHistory.length) { + this.back(); + } + break; + case "Forward": + if (this._forwardHistory.length) { + this.forward(); + } + break; + case "Search": + PlacesSearchBox.findAll(); + break; + } + }, + + destroy: function PO_destroy() {}, + + _location: null, + get location() { + return this._location; + }, + + set location(aLocation) { + if (!aLocation || this._location == aLocation) { + return; + } + + if (this.location) { + this._backHistory.unshift(this.location); + this._forwardHistory.splice(0, this._forwardHistory.length); + } + + this._location = aLocation; + this._places.selectPlaceURI(aLocation); + + if (!this._places.hasSelection) { + // If no node was found for the given place: uri, just load it directly + ContentArea.currentPlace = aLocation; + } + this.updateDetailsPane(); + + // update navigation commands + if (!this._backHistory.length) { + document + .getElementById("OrganizerCommand:Back") + .setAttribute("disabled", true); + } else { + document + .getElementById("OrganizerCommand:Back") + .removeAttribute("disabled"); + } + if (!this._forwardHistory.length) { + document + .getElementById("OrganizerCommand:Forward") + .setAttribute("disabled", true); + } else { + document + .getElementById("OrganizerCommand:Forward") + .removeAttribute("disabled"); + } + }, + + _backHistory: [], + _forwardHistory: [], + + back: function PO_back() { + this._forwardHistory.unshift(this.location); + var historyEntry = this._backHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + forward: function PO_forward() { + this._backHistory.unshift(this.location); + var historyEntry = this._forwardHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + + /** + * Called when a place folder is selected in the left pane. + * + * @param resetSearchBox + * true if the search box should also be reset, false otherwise. + * The search box should be reset when a new folder in the left + * pane is selected; the search scope and text need to be cleared in + * preparation for the new folder. Note that if the user manually + * resets the search box, either by clicking its reset button or by + * deleting its text, this will be false. + */ + _cachedLeftPaneSelectedURI: null, + onPlaceSelected: function PO_onPlaceSelected(resetSearchBox) { + // Don't change the right-hand pane contents when there's no selection. + if (!this._places.hasSelection) { + return; + } + + let node = this._places.selectedNode; + let placeURI = node.uri; + + // If either the place of the content tree in the right pane has changed or + // the user cleared the search box, update the place, hide the search UI, + // and update the back/forward buttons by setting location. + if (ContentArea.currentPlace != placeURI || !resetSearchBox) { + ContentArea.currentPlace = placeURI; + this.location = placeURI; + } + + // When we invalidate a container we use suppressSelectionEvent, when it is + // unset a select event is fired, in many cases the selection did not really + // change, so we should check for it, and return early in such a case. Note + // that we cannot return any earlier than this point, because when + // !resetSearchBox, we need to update location and hide the UI as above, + // even though the selection has not changed. + if (placeURI == this._cachedLeftPaneSelectedURI) { + return; + } + this._cachedLeftPaneSelectedURI = placeURI; + + // At this point, resetSearchBox is true, because the left pane selection + // has changed; otherwise we would have returned earlier. + + let input = PlacesSearchBox.searchFilter; + input.value = ""; + input.editor?.clearUndoRedo(); + this._setSearchScopeForNode(node); + this.updateDetailsPane(); + }, + + /** + * Sets the search scope based on aNode's properties. + * + * @param {object} aNode + * the node to set up scope from + */ + _setSearchScopeForNode: function PO__setScopeForNode(aNode) { + let itemGuid = aNode.bookmarkGuid; + + if ( + PlacesUtils.nodeIsHistoryContainer(aNode) || + itemGuid == PlacesUtils.virtualHistoryGuid + ) { + PlacesQueryBuilder.setScope("history"); + } else if (itemGuid == PlacesUtils.virtualDownloadsGuid) { + PlacesQueryBuilder.setScope("downloads"); + } else { + // Default to All Bookmarks for all other nodes, per bug 469437. + PlacesQueryBuilder.setScope("bookmarks"); + } + }, + + /** + * Handle clicks on the places list. + * Single Left click, right click or modified click do not result in any + * special action, since they're related to selection. + * + * @param {object} aEvent + * The mouse event. + */ + onPlacesListClick: function PO_onPlacesListClick(aEvent) { + // Only handle clicks on tree children. + if (aEvent.target.localName != "treechildren") { + return; + } + + let node = this._places.selectedNode; + if (node) { + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this._places); + } + } + }, + + /** + * Handle focus changes on the places list and the current content view. + */ + updateDetailsPane: function PO_updateDetailsPane() { + if (!ContentArea.currentViewOptions.showDetailsPane) { + return; + } + let view = PlacesUIUtils.getViewForNode(document.activeElement); + if (view) { + let selectedNodes = view.selectedNode + ? [view.selectedNode] + : view.selectedNodes; + this._fillDetailsPane(selectedNodes); + } + }, + + /** + * Handle openFlatContainer events. + * + * @param {object} aContainer + * The node the event was dispatched on. + */ + openFlatContainer(aContainer) { + if (aContainer.bookmarkGuid) { + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + this._places.selectItems([aContainer.bookmarkGuid], false); + } else if (PlacesUtils.nodeIsQuery(aContainer)) { + this._places.selectPlaceURI(aContainer.uri); + } + }, + + /** + * @returns {object} + * Returns the options associated with the query currently loaded in the + * main places pane. + */ + getCurrentOptions: function PO_getCurrentOptions() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root) + .queryOptions; + }, + + /** + * Show the migration wizard for importing passwords, + * cookies, history, preferences, and bookmarks. + */ + importFromBrowser: function PO_importFromBrowser() { + // We pass in the type of source we're using for use in telemetry: + MigrationUtils.showMigrationWizard(window, { + entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.PLACES, + }); + }, + + /** + * Open a file-picker and import the selected file into the bookmarks store + */ + importFromFile: function PO_importFromFile() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) { + var { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" + ); + BookmarkHTMLUtils.importFromURL(fp.fileURL.spec).catch(console.error); + } + }; + + fp.init( + window, + PlacesUIUtils.promptLocalization.formatValueSync( + "places-bookmarks-import" + ), + Ci.nsIFilePicker.modeOpen + ); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.open(fpCallback); + }, + + /** + * Allows simple exporting of bookmarks. + */ + exportBookmarks: function PO_exportBookmarks() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + var { BookmarkHTMLUtils } = ChromeUtils.importESModule( + "resource://gre/modules/BookmarkHTMLUtils.sys.mjs" + ); + BookmarkHTMLUtils.exportToFile(fp.file.path).catch(console.error); + } + }; + + fp.init( + window, + PlacesUIUtils.promptLocalization.formatValueSync( + "places-bookmarks-export" + ), + Ci.nsIFilePicker.modeSave + ); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.defaultString = "bookmarks.html"; + fp.open(fpCallback); + }, + + /** + * Populates the restore menu with the dates of the backups available. + */ + populateRestoreMenu: function PO_populateRestoreMenu() { + let restorePopup = document.getElementById("fileRestorePopup"); + + const dtOptions = { + dateStyle: "long", + }; + let dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); + + // Remove existing menu items. Last item is the restoreFromFile item. + while (restorePopup.childNodes.length > 1) { + restorePopup.firstChild.remove(); + } + + (async function () { + let backupFiles = await PlacesBackups.getBackupFiles(); + if (!backupFiles.length) { + return; + } + + // Populate menu with backups. + for (let file of backupFiles) { + let fileSize = (await IOUtils.stat(file)).size; + let [size, unit] = DownloadUtils.convertByteUnits(fileSize); + let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", [ + size, + unit, + ]); + + let countString; + let count = PlacesBackups.getBookmarkCountForFile(file); + if (count != null) { + const [msg] = await document.l10n.formatMessages([ + { id: "places-details-pane-items-count", args: { count } }, + ]); + countString = msg.attributes.find( + attr => attr.name === "value" + )?.value; + } + + const backupDate = PlacesBackups.getDateForFile(file); + let label = dateFormatter.format(backupDate); + label += countString + ? ` (${sizeString} - ${countString})` + : ` (${sizeString})`; + + let m = restorePopup.insertBefore( + document.createXULElement("menuitem"), + document.getElementById("restoreFromFile") + ); + m.setAttribute("label", label); + m.setAttribute("value", PathUtils.filename(file)); + m.setAttribute( + "oncommand", + "PlacesOrganizer.onRestoreMenuItemClick(this);" + ); + } + + // Add the restoreFromFile item. + restorePopup.insertBefore( + document.createXULElement("menuseparator"), + document.getElementById("restoreFromFile") + ); + })(); + }, + + /** + * Called when a menuitem is selected from the restore menu. + * + * @param {object} aMenuItem The menuitem that was selected. + */ + async onRestoreMenuItemClick(aMenuItem) { + let backupName = aMenuItem.getAttribute("value"); + let backupFilePaths = await PlacesBackups.getBackupFiles(); + for (let backupFilePath of backupFilePaths) { + if (PathUtils.filename(backupFilePath) == backupName) { + PlacesOrganizer.restoreBookmarksFromFile(backupFilePath); + break; + } + } + }, + + /** + * Called when 'Choose File...' is selected from the restore menu. + * Prompts for a file and restores bookmarks to those in the file. + */ + onRestoreBookmarksFromFile: function PO_onRestoreBookmarksFromFile() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = aResult => { + if (aResult != Ci.nsIFilePicker.returnCancel) { + this.restoreBookmarksFromFile(fp.file.path); + } + }; + + const [title, filterName] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-restore-title", + "places-bookmarks-restore-filter-name", + ]); + fp.init(window, title, Ci.nsIFilePicker.modeOpen); + fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + /** + * Restores bookmarks from a JSON file. + * + * @param {string} aFilePath + * The path of the file to restore from. + */ + restoreBookmarksFromFile: function PO_restoreBookmarksFromFile(aFilePath) { + // check file extension + if ( + !aFilePath.toLowerCase().endsWith("json") && + !aFilePath.toLowerCase().endsWith("jsonlz4") + ) { + this._showErrorAlert("places-bookmarks-restore-format-error"); + return; + } + + const [title, body] = PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-restore-alert-title", + "places-bookmarks-restore-alert", + ]); + // confirm ok to delete existing bookmarks + if (!Services.prompt.confirm(null, title, body)) { + return; + } + + (async function () { + try { + await BookmarkJSONUtils.importFromFile(aFilePath, { + replace: true, + }); + } catch (ex) { + PlacesOrganizer._showErrorAlert("places-bookmarks-restore-parse-error"); + } + })(); + }, + + _showErrorAlert: function PO__showErrorAlert(l10nId) { + const [title, msg] = PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-error-title", + l10nId, + ]); + Services.prompt.alert(window, title, msg); + }, + + /** + * Backup bookmarks to desktop, auto-generate a filename with a date. + * The file is a JSON serialization of bookmarks, tags and any annotations + * of those items. + */ + backupBookmarks: function PO_backupBookmarks() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + // There is no OS.File version of the filepicker yet (Bug 937812). + PlacesBackups.saveBookmarksToJSONFile(fp.file.path).catch( + console.error + ); + } + }; + + const [title, filterName] = + PlacesUIUtils.promptLocalization.formatValuesSync([ + "places-bookmarks-backup-title", + "places-bookmarks-restore-filter-name", + ]); + fp.init(window, title, Ci.nsIFilePicker.modeSave); + fp.appendFilter(filterName, RESTORE_FILEPICKER_FILTER_EXT); + fp.defaultString = PlacesBackups.getFilenameForDate(); + fp.defaultExtension = "json"; + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + _fillDetailsPane: function PO__fillDetailsPane(aNodeList) { + var infoBox = document.getElementById("infoBox"); + var itemsCountBox = document.getElementById("itemsCountBox"); + + // Make sure the infoBox UI is visible if we need to use it, we hide it + // below when we don't. + infoBox.hidden = false; + itemsCountBox.hidden = true; + + let selectedNode = aNodeList.length == 1 ? aNodeList[0] : null; + + // If an input within a panel is focused, force-blur it so its contents + // are saved + if (gEditItemOverlay.itemId != -1) { + var focusedElement = document.commandDispatcher.focusedElement; + if ( + (HTMLInputElement.isInstance(focusedElement) || + HTMLTextAreaElement.isInstance(focusedElement)) && + /^editBMPanel.*/.test(focusedElement.parentNode.parentNode.id) + ) { + focusedElement.blur(); + } + + // don't update the panel if we are already editing this node unless we're + // in multi-edit mode + if (selectedNode) { + let concreteGuid = PlacesUtils.getConcreteItemGuid(selectedNode); + var nodeIsSame = + gEditItemOverlay.itemId == selectedNode.itemId || + gEditItemOverlay._paneInfo.itemGuid == concreteGuid || + (selectedNode.itemId == -1 && + gEditItemOverlay.uri && + gEditItemOverlay.uri == selectedNode.uri); + if (nodeIsSame && !infoBox.hidden && !gEditItemOverlay.multiEdit) { + return; + } + } + } + + // Clean up the panel before initing it again. + gEditItemOverlay.uninitPanel(false); + + if (selectedNode && !PlacesUtils.nodeIsSeparator(selectedNode)) { + gEditItemOverlay + .initPanel({ + node: selectedNode, + hiddenRows: ["folderPicker"], + }) + .catch(ex => console.error(ex)); + } else if (!selectedNode && aNodeList[0]) { + if (aNodeList.every(PlacesUtils.nodeIsURI)) { + let uris = aNodeList.map(node => Services.io.newURI(node.uri)); + gEditItemOverlay + .initPanel({ + uris, + hiddenRows: ["folderPicker", "location", "keyword", "name"], + }) + .catch(ex => console.error(ex)); + } else { + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + selectItemDesc.hidden = false; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-items-count", + { count: aNodeList.length } + ); + infoBox.hidden = true; + } + } else { + infoBox.hidden = true; + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + let itemsCount = 0; + if (ContentArea.currentView.result) { + let rootNode = ContentArea.currentView.result.root; + if (rootNode.containerOpen) { + itemsCount = rootNode.childCount; + } + } + if (itemsCount == 0) { + selectItemDesc.hidden = true; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-no-items" + ); + } else { + selectItemDesc.hidden = false; + document.l10n.setAttributes( + itemsCountLabel, + "places-details-pane-items-count", + { count: itemsCount } + ); + } + } + itemsCountBox.hidden = !infoBox.hidden; + }, +}; + +/** + * A set of utilities relating to search within Bookmarks and History. + */ +var PlacesSearchBox = { + /** + * The Search text field + * + * @see {@link https://searchfox.org/mozilla-central/source/toolkit/content/widgets/search-textbox.js} + * @returns {HTMLInputElement} + */ + get searchFilter() { + return document.getElementById("searchFilter"); + }, + + cumulativeHistorySearches: 0, + cumulativeBookmarkSearches: 0, + + /** + * Folders to include when searching. + */ + _folders: [], + get folders() { + if (!this._folders.length) { + this._folders = PlacesUtils.bookmarks.userContentRoots; + } + return this._folders; + }, + set folders(aFolders) { + this._folders = aFolders; + }, + + /** + * Run a search for the specified text, over the collection specified by + * the dropdown arrow. The default is all bookmarks, but can be + * localized to the active collection. + * + * @param {string} filterString + * The text to search for. + */ + search(filterString) { + var PO = PlacesOrganizer; + // If the user empties the search box manually, reset it and load all + // contents of the current scope. + // XXX this might be to jumpy, maybe should search for "", so results + // are ungrouped, and search box not reset + if (filterString == "") { + PO.onPlaceSelected(false); + return; + } + + let currentView = ContentArea.currentView; + + // Search according to the current scope, which was set by + // PQB_setScope() + switch (PlacesSearchBox.filterCollection) { + case "bookmarks": + currentView.applyFilter(filterString, this.folders); + Services.telemetry.keyedScalarAdd("library.search", "bookmarks", 1); + this.cumulativeBookmarkSearches++; + break; + case "history": { + let currentOptions = PO.getCurrentOptions(); + if ( + currentOptions.queryType != + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY + ) { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + let options = currentOptions.clone(); + // Make sure we're getting uri results. + options.resultType = currentOptions.RESULTS_AS_URI; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.includeHidden = true; + currentView.load([query], options); + } else { + TelemetryStopwatch.start(HISTORY_LIBRARY_SEARCH_TELEMETRY); + currentView.applyFilter(filterString, null, true); + TelemetryStopwatch.finish(HISTORY_LIBRARY_SEARCH_TELEMETRY); + Services.telemetry.keyedScalarAdd("library.search", "history", 1); + this.cumulativeHistorySearches++; + } + break; + } + case "downloads": { + // The new downloads view doesn't use places for searching downloads. + currentView.searchTerm = filterString; + break; + } + default: + throw new Error("Invalid filterCollection on search"); + } + + // Update the details panel + PlacesOrganizer.updateDetailsPane(); + }, + + /** + * Finds across all history, downloads or all bookmarks. + */ + findAll() { + switch (this.filterCollection) { + case "history": + PlacesQueryBuilder.setScope("history"); + break; + case "downloads": + PlacesQueryBuilder.setScope("downloads"); + break; + default: + PlacesQueryBuilder.setScope("bookmarks"); + break; + } + this.focus(); + }, + + /** + * Updates the search input placeholder to match the current collection. + */ + updatePlaceholder() { + let l10nId = ""; + switch (this.filterCollection) { + case "history": + l10nId = "places-search-history"; + break; + case "downloads": + l10nId = "places-search-downloads"; + break; + default: + l10nId = "places-search-bookmarks"; + } + document.l10n.setAttributes(this.searchFilter, l10nId); + }, + + /** + * Gets/sets the active collection from the dropdown menu. + * + * @returns {string} + */ + get filterCollection() { + return this.searchFilter.getAttribute("collection"); + }, + set filterCollection(collectionName) { + if (collectionName == this.filterCollection) { + return; + } + + this.searchFilter.setAttribute("collection", collectionName); + this.updatePlaceholder(); + }, + + /** + * Focus the search box + */ + focus() { + this.searchFilter.focus(); + }, + + /** + * Set up the gray text in the search bar as the Places View loads. + */ + init() { + this.updatePlaceholder(); + }, + + /** + * Gets or sets the text shown in the Places Search Box + * + * @returns {string} + */ + get value() { + return this.searchFilter.value; + }, + set value(value) { + this.searchFilter.value = value; + }, +}; + +function updateTelemetry(urlsOpened) { + let historyLinks = urlsOpened.filter( + link => !link.isBookmark && !PlacesUtils.nodeIsBookmark(link) + ); + if (!historyLinks.length) { + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_BOOKMARK_SEARCHES" + ); + searchesHistogram.add(PlacesSearchBox.cumulativeBookmarkSearches); + + // Clear cumulative search counter + PlacesSearchBox.cumulativeBookmarkSearches = 0; + + Services.telemetry.keyedScalarAdd( + "library.link", + "bookmarks", + urlsOpened.length + ); + return; + } + + // Record cumulative search count before selecting History link from Library + let searchesHistogram = Services.telemetry.getHistogramById( + "PLACES_LIBRARY_CUMULATIVE_HISTORY_SEARCHES" + ); + searchesHistogram.add(PlacesSearchBox.cumulativeHistorySearches); + + // Clear cumulative search counter + PlacesSearchBox.cumulativeHistorySearches = 0; + + Services.telemetry.keyedScalarAdd( + "library.link", + "history", + historyLinks.length + ); +} + +/** + * Functions and data for advanced query builder + */ +var PlacesQueryBuilder = { + queries: [], + queryOptions: null, + + /** + * Sets the search scope. This can be called when no search is active, and + * in that case, when `search()` is called, `aScope` will be used. + * If there is an active search, it's performed again to + * update the content tree. + * + * @param {"bookmarks" | "downloads" | "history"} aScope + * The search scope: "bookmarks", "downloads" or "history". + */ + setScope(aScope) { + // Determine filterCollection, folders, and scopeButtonId based on aScope. + var filterCollection; + var folders = []; + switch (aScope) { + case "history": + filterCollection = "history"; + break; + case "bookmarks": + filterCollection = "bookmarks"; + folders = PlacesUtils.bookmarks.userContentRoots; + break; + case "downloads": + filterCollection = "downloads"; + break; + default: + throw new Error("Invalid search scope"); + } + + // Update the search box. Re-search if there's an active search. + PlacesSearchBox.filterCollection = filterCollection; + PlacesSearchBox.folders = folders; + var searchStr = PlacesSearchBox.searchFilter.value; + if (searchStr) { + PlacesSearchBox.search(searchStr); + } + }, +}; + +/** + * Population and commands for the View Menu. + */ +var ViewMenu = { + /** + * Removes content generated previously from a menupopup. + * + * @param {object} popup + * The popup that contains the previously generated content. + * @param {string} startID + * The id attribute of an element that is the start of the + * dynamically generated region - remove elements after this + * item only. + * Must be contained by popup. Can be null (in which case the + * contents of popup are removed). + * @param {string} endID + * The id attribute of an element that is the end of the + * dynamically generated region - remove elements up to this + * item only. + * Must be contained by popup. Can be null (in which case all + * items until the end of the popup will be removed). Ignored + * if startID is null. + * @returns {object|null} The element for the caller to insert new items before, + * null if the caller should just append to the popup. + */ + _clean: function VM__clean(popup, startID, endID) { + if (endID && !startID) { + throw new Error("meaningless to have valid endID and null startID"); + } + if (startID) { + var startElement = document.getElementById(startID); + if (startElement.parentNode != popup) { + throw new Error("startElement is not in popup"); + } + if (!startElement) { + throw new Error("startID does not correspond to an existing element"); + } + var endElement = null; + if (endID) { + endElement = document.getElementById(endID); + if (endElement.parentNode != popup) { + throw new Error("endElement is not in popup"); + } + if (!endElement) { + throw new Error("endID does not correspond to an existing element"); + } + } + while (startElement.nextSibling != endElement) { + popup.removeChild(startElement.nextSibling); + } + return endElement; + } + while (popup.hasChildNodes()) { + popup.firstChild.remove(); + } + return null; + }, + + /** + * Fills a menupopup with a list of columns + * + * @param {object} event + * The popupshowing event that invoked this function. + * @param {string} startID + * see _clean + * @param {string} endID + * see _clean + * @param {string} type + * the type of the menuitem, e.g. "radio" or "checkbox". + * Can be null (no-type). + * Checkboxes are checked if the column is visible. + * @param {boolean} localize + * If localize is true, the column label and accesskey are set + * via DOM Localization. + * If localize is false, the column label is used as label and + * no accesskey is assigned. + */ + fillWithColumns: function VM_fillWithColumns( + event, + startID, + endID, + type, + localize + ) { + var popup = event.target; + var pivot = this._clean(popup, startID, endID); + + var content = document.getElementById("placeContent"); + var columns = content.columns; + for (var i = 0; i < columns.count; ++i) { + var column = columns.getColumnAt(i).element; + var menuitem = document.createXULElement("menuitem"); + menuitem.id = "menucol_" + column.id; + menuitem.column = column; + if (localize) { + const l10nId = SORTBY_L10N_IDS.get(column.getAttribute("anonid")); + document.l10n.setAttributes(menuitem, l10nId); + } else { + const label = column.getAttribute("label"); + menuitem.setAttribute("label", label); + } + if (type == "radio") { + menuitem.setAttribute("type", "radio"); + menuitem.setAttribute("name", "columns"); + // This column is the sort key. Its item is checked. + if (column.getAttribute("sortDirection") != "") { + menuitem.setAttribute("checked", "true"); + } + } else if (type == "checkbox") { + menuitem.setAttribute("type", "checkbox"); + // Cannot uncheck the primary column. + if (column.getAttribute("primary") == "true") { + menuitem.setAttribute("disabled", "true"); + } + // Items for visible columns are checked. + if (!column.hidden) { + menuitem.setAttribute("checked", "true"); + } + } + if (pivot) { + popup.insertBefore(menuitem, pivot); + } else { + popup.appendChild(menuitem); + } + } + event.stopPropagation(); + }, + + /** + * Set up the content of the view menu. + * + * @param {object} event + * The event that invoked this function + */ + populateSortMenu: function VM_populateSortMenu(event) { + this.fillWithColumns( + event, + "viewUnsorted", + "directionSeparator", + "radio", + true + ); + + var sortColumn = this._getSortColumn(); + var viewSortAscending = document.getElementById("viewSortAscending"); + var viewSortDescending = document.getElementById("viewSortDescending"); + // We need to remove an existing checked attribute because the unsorted + // menu item is not rebuilt every time we open the menu like the others. + var viewUnsorted = document.getElementById("viewUnsorted"); + if (!sortColumn) { + viewSortAscending.removeAttribute("checked"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.setAttribute("checked", "true"); + } else if (sortColumn.getAttribute("sortDirection") == "ascending") { + viewSortAscending.setAttribute("checked", "true"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } else if (sortColumn.getAttribute("sortDirection") == "descending") { + viewSortDescending.setAttribute("checked", "true"); + viewSortAscending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } + }, + + /** + * Shows/Hides a tree column. + * + * @param {object} element + * The menuitem element for the column + */ + showHideColumn: function VM_showHideColumn(element) { + var column = element.column; + + var splitter = column.nextSibling; + if (splitter && splitter.localName != "splitter") { + splitter = null; + } + + const isChecked = element.getAttribute("checked") == "true"; + column.hidden = !isChecked; + if (splitter) { + splitter.hidden = !isChecked; + } + }, + + /** + * Gets the last column that was sorted. + * + * @returns {object|null} the currently sorted column, null if there is no sorted column. + */ + _getSortColumn: function VM__getSortColumn() { + var content = document.getElementById("placeContent"); + var cols = content.columns; + for (var i = 0; i < cols.count; ++i) { + var column = cols.getColumnAt(i).element; + var sortDirection = column.getAttribute("sortDirection"); + if (sortDirection == "ascending" || sortDirection == "descending") { + return column; + } + } + return null; + }, + + /** + * Sorts the view by the specified column. + * + * @param {object} aColumn + * The colum that is the sort key. Can be null - the + * current sort column or the title column will be used. + * @param {string} aDirection + * The direction to sort - "ascending" or "descending". + * Can be null - the last direction or descending will be used. + * + * If both aColumnID and aDirection are null, the view will be unsorted. + */ + setSortColumn: function VM_setSortColumn(aColumn, aDirection) { + var result = document.getElementById("placeContent").result; + if (!aColumn && !aDirection) { + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + return; + } + + var columnId; + if (aColumn) { + columnId = aColumn.getAttribute("anonid"); + if (!aDirection) { + let sortColumn = this._getSortColumn(); + if (sortColumn) { + aDirection = sortColumn.getAttribute("sortDirection"); + } + } + } else { + let sortColumn = this._getSortColumn(); + columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title"; + } + + // This maps the possible values of columnId (i.e., anonid's of treecols in + // placeContent) to the default sortingMode for each column. + // key: Sort key in the name of one of the + // nsINavHistoryQueryOptions.SORT_BY_* constants + // dir: Default sort direction to use if none has been specified + const colLookupTable = { + title: { key: "TITLE", dir: "ascending" }, + tags: { key: "TAGS", dir: "ascending" }, + url: { key: "URI", dir: "ascending" }, + date: { key: "DATE", dir: "descending" }, + visitCount: { key: "VISITCOUNT", dir: "descending" }, + dateAdded: { key: "DATEADDED", dir: "descending" }, + lastModified: { key: "LASTMODIFIED", dir: "descending" }, + }; + + // Make sure we have a valid column. + if (!colLookupTable.hasOwnProperty(columnId)) { + throw new Error("Invalid column"); + } + + // Use a default sort direction if none has been specified. If aDirection + // is invalid, result.sortingMode will be undefined, which has the effect + // of unsorting the tree. + aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase(); + + var sortConst = + "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection; + result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst]; + }, +}; + +var ContentArea = { + _specialViews: new Map(), + + init: function CA_init() { + this._box = document.getElementById("placesViewsBox"); + this._toolbar = document.getElementById("placesToolbar"); + ContentTree.init(); + this._setupView(); + }, + + /** + * Gets the content view to be used for loading the given query. + * If a custom view was set by setContentViewForQueryString, that + * view would be returned, else the default tree view is returned + * + * @param {string} aQueryString + * a query string + * @returns {object} the view to be used for loading aQueryString. + */ + getContentViewForQueryString: function CA_getContentViewForQueryString( + aQueryString + ) { + try { + if (this._specialViews.has(aQueryString)) { + let { view, options } = this._specialViews.get(aQueryString); + if (typeof view == "function") { + view = view(); + this._specialViews.set(aQueryString, { view, options }); + } + return view; + } + } catch (ex) { + console.error(ex); + } + return ContentTree.view; + }, + + /** + * Sets a custom view to be used rather than the default places tree + * whenever the given query is selected in the left pane. + * + * @param {string} aQueryString + * a query string + * @param {object} aView + * Either the custom view or a function that will return the view + * the first (and only) time it's called. + * @param {object} [aOptions] + * Object defining special options for the view. + * @see ContentTree.viewOptions for supported options and default values. + */ + setContentViewForQueryString: function CA_setContentViewForQueryString( + aQueryString, + aView, + aOptions + ) { + if ( + !aQueryString || + (typeof aView != "object" && typeof aView != "function") + ) { + throw new Error("Invalid arguments"); + } + + this._specialViews.set(aQueryString, { + view: aView, + options: aOptions || {}, + }); + }, + + get currentView() { + let selectedPane = [...this._box.children].filter( + child => !child.hidden + )[0]; + return PlacesUIUtils.getViewForNode(selectedPane); + }, + set currentView(aNewView) { + let oldView = this.currentView; + if (oldView != aNewView) { + oldView.associatedElement.hidden = true; + aNewView.associatedElement.hidden = false; + + // If the content area inactivated view was focused, move focus + // to the new view. + if (document.activeElement == oldView.associatedElement) { + aNewView.associatedElement.focus(); + } + } + }, + + get currentPlace() { + return this.currentView.place; + }, + set currentPlace(aQueryString) { + let oldView = this.currentView; + let newView = this.getContentViewForQueryString(aQueryString); + newView.place = aQueryString; + if (oldView != newView) { + oldView.active = false; + this.currentView = newView; + this._setupView(); + newView.active = true; + } + }, + + /** + * Applies view options. + */ + _setupView: function CA__setupView() { + let options = this.currentViewOptions; + + // showDetailsPane. + let detailsPane = document.getElementById("detailsPane"); + detailsPane.hidden = !options.showDetailsPane; + + // toolbarSet. + for (let elt of this._toolbar.childNodes) { + // On Windows and Linux the menu buttons are menus wrapped in a menubar. + if (elt.id == "placesMenu") { + for (let menuElt of elt.childNodes) { + menuElt.hidden = !options.toolbarSet.includes(menuElt.id); + } + } else { + elt.hidden = !options.toolbarSet.includes(elt.id); + } + } + }, + + /** + * Options for the current view. + * + * @see {@link ContentTree.viewOptions} for supported options and default values. + * @returns {{showDetailsPane: boolean;toolbarSet: string;}} + */ + get currentViewOptions() { + // Use ContentTree options as default. + let viewOptions = ContentTree.viewOptions; + if (this._specialViews.has(this.currentPlace)) { + let { options } = this._specialViews.get(this.currentPlace); + for (let option in options) { + viewOptions[option] = options[option]; + } + } + return viewOptions; + }, + + focus() { + this.currentView.associatedElement.focus(); + }, +}; + +var ContentTree = { + init: function CT_init() { + this._view = document.getElementById("placeContent"); + }, + + get view() { + return this._view; + }, + + get viewOptions() { + return Object.seal({ + showDetailsPane: true, + toolbarSet: + "back-button, forward-button, organizeButton, viewMenu, maintenanceButton, libraryToolbarSpacer, searchFilter", + }); + }, + + openSelectedNode: function CT_openSelectedNode(aEvent) { + let view = this.view; + PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent); + }, + + onClick: function CT_onClick(aEvent) { + let node = this.view.selectedNode; + if (node) { + let doubleClick = aEvent.button == 0 && aEvent.detail == 2; + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) { + // Open associated uri in the browser. + this.openSelectedNode(aEvent); + } else if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openMultipleLinksInTabs(node, aEvent, this.view); + } + } + }, + + onKeyPress: function CT_onKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) { + this.openSelectedNode(aEvent); + } + }, +}; |