/* -*- 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); } }, };