/* -*- 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/. */ var EXPORTED_SYMBOLS = ["PlacesUIUtils"]; const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); const {clearTimeout, setTimeout} = ChromeUtils.import("resource://gre/modules/Timer.jsm"); Cu.importGlobalProperties(["Element"]); XPCOMUtils.defineLazyModuleGetters(this, { OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.jsm", PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", PluralForm: "resource://gre/modules/PluralForm.jsm", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", RecentWindow: "resource:///modules/RecentWindow.jsm", PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", PlacesTransactions: "resource://gre/modules/PlacesTransactions.jsm", }); XPCOMUtils.defineLazyGetter(this, "bundle", function() { return Services.strings.createBundle("chrome://communicator/locale/places/places.properties"); }); const gInContentProcess = Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT; const FAVICON_REQUEST_TIMEOUT = 60 * 1000; // Map from windows to arrays of data about pending favicon loads. var gFaviconLoadDataMap = new Map(); const ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD = 10; // copied from utilityOverlay.js const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab"; var InternalFaviconLoader = { /** * This gets called for every inner window that is destroyed. * In the parent process, we process the destruction ourselves. In the child process, * we notify the parent which will then process it based on that message. */ observe(subject, topic, data) { let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data; this.removeRequestsForInner(innerWindowID); }, /** * Actually cancel the request, and clear the timeout for cancelling it. */ _cancelRequest({uri, innerWindowID, timerID, callback}, reason) { // Break cycle let request = callback.request; delete callback.request; // Ensure we don't time out. clearTimeout(timerID); try { request.cancel(); } catch (ex) { Cu.reportError("When cancelling a request for " + uri.spec + " because " + reason + ", it was already canceled!"); } }, /** * Called for every inner that gets destroyed, only in the parent process. */ removeRequestsForInner(innerID) { for (let [window, loadDataForWindow] of gFaviconLoadDataMap) { let newLoadDataForWindow = loadDataForWindow.filter(loadData => { let innerWasDestroyed = loadData.innerWindowID == innerID; if (innerWasDestroyed) { this._cancelRequest(loadData, "the inner window was destroyed or a new favicon was loaded for it"); } // Keep the items whose inner is still alive. return !innerWasDestroyed; }); // Map iteration with for...of is safe against modification, so // now just replace the old value: gFaviconLoadDataMap.set(window, newLoadDataForWindow); } }, /** * Called when a toplevel chrome window unloads. We use this to tidy up after ourselves, * avoid leaks, and cancel any remaining requests. The last part should in theory be * handled by the inner-window-destroyed handlers. We clean up just to be on the safe side. */ onUnload(win) { let loadDataForWindow = gFaviconLoadDataMap.get(win); if (loadDataForWindow) { for (let loadData of loadDataForWindow) { this._cancelRequest(loadData, "the chrome window went away"); } } gFaviconLoadDataMap.delete(win); }, /** * Remove a particular favicon load's loading data from our map tracking * load data per chrome window. * * @param win * the chrome window in which we should look for this load * @param filterData ({innerWindowID, uri, callback}) * the data we should use to find this particular load to remove. * * @return the loadData object we removed, or null if we didn't find any. */ _removeLoadDataFromWindowMap(win, {innerWindowID, uri, callback}) { let loadDataForWindow = gFaviconLoadDataMap.get(win); if (loadDataForWindow) { let itemIndex = loadDataForWindow.findIndex(loadData => { return loadData.innerWindowID == innerWindowID && loadData.uri.equals(uri) && loadData.callback.request == callback.request; }); if (itemIndex != -1) { let loadData = loadDataForWindow[itemIndex]; loadDataForWindow.splice(itemIndex, 1); return loadData; } } return null; }, /** * Create a function to use as a nsIFaviconDataCallback, so we can remove cancelling * information when the request succeeds. Note that right now there are some edge-cases, * such as about: URIs with chrome:// favicons where the success callback is not invoked. * This is OK: we will 'cancel' the request after the timeout (or when the window goes * away) but that will be a no-op in such cases. */ _makeCompletionCallback(win, id) { return { onComplete(uri) { let loadData = InternalFaviconLoader._removeLoadDataFromWindowMap(win, { uri, innerWindowID: id, callback: this, }); if (loadData) { clearTimeout(loadData.timerID); } delete this.request; }, }; }, ensureInitialized() { if (this._initialized) { return; } this._initialized = true; Services.obs.addObserver(this, "inner-window-destroyed"); Services.ppmm.addMessageListener("Toolkit:inner-window-destroyed", msg => { this.removeRequestsForInner(msg.data); }); }, loadFavicon(browser, principal, uri, requestContextID) { this.ensureInitialized(); let win = browser.ownerGlobal; if (!gFaviconLoadDataMap.has(win)) { gFaviconLoadDataMap.set(win, []); let unloadHandler = event => { let doc = event.target; let eventWin = doc.defaultView; if (eventWin == win) { win.removeEventListener("unload", unloadHandler); this.onUnload(win); } }; win.addEventListener("unload", unloadHandler, true); } let {innerWindowID, currentURI} = browser; // First we do the actual setAndFetch call: let loadType = PrivateBrowsingUtils.isWindowPrivate(win) ? PlacesUtils.favicons.FAVICON_LOAD_PRIVATE : PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE; let callback = this._makeCompletionCallback(win, innerWindowID); let request = PlacesUtils.favicons.setAndFetchFaviconForPage(currentURI, uri, false, loadType, callback, principal, requestContextID); // Now register the result so we can cancel it if/when necessary. if (!request) { // The favicon service can return with success but no-op (and leave request // as null) if the icon is the same as the page (e.g. for images) or if it is // the favicon for an error page. In this case, we do not need to do anything else. return; } callback.request = request; let loadData = {innerWindowID, uri, callback}; loadData.timerID = setTimeout(() => { this._cancelRequest(loadData, "it timed out"); this._removeLoadDataFromWindowMap(win, loadData); }, FAVICON_REQUEST_TIMEOUT); let loadDataForWindow = gFaviconLoadDataMap.get(win); loadDataForWindow.push(loadData); }, }; var PlacesUIUtils = { ORGANIZER_LEFTPANE_VERSION: 8, ORGANIZER_FOLDER_ANNO: "PlacesOrganizer/OrganizerFolder", ORGANIZER_QUERY_ANNO: "PlacesOrganizer/OrganizerQuery", LOAD_IN_SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar", DESCRIPTION_ANNO: "bookmarkProperties/description", /** * Makes a URI from a spec, and do fixup * @param aSpec * The string spec of the URI * @return A URI object for the spec. */ createFixedURI: function PUIU_createFixedURI(aSpec) { return Services.uriFixup.createFixupURI(aSpec, Ci.nsIURIFixup.FIXUP_FLAG_NONE); }, getFormattedString: function PUIU_getFormattedString(key, params) { return bundle.formatStringFromName(key, params, params.length); }, /** * Get a localized plural string for the specified key name and numeric value * substituting parameters. * * @param aKey * String, key for looking up the localized string in the bundle * @param aNumber * Number based on which the final localized form is looked up * @param aParams * Array whose items will substitute #1, #2,... #n parameters * in the string. * * @see https://developer.mozilla.org/en/Localization_and_Plurals * @return The localized plural string. */ getPluralString: function PUIU_getPluralString(aKey, aNumber, aParams) { let str = PluralForm.get(aNumber, bundle.GetStringFromName(aKey)); // Replace #1 with aParams[0], #2 with aParams[1], and so on. return str.replace(/\#(\d+)/g, function(matchedId, matchedNumber) { let param = aParams[parseInt(matchedNumber, 10) - 1]; return param !== undefined ? param : matchedId; }); }, getString: function PUIU_getString(key) { return bundle.GetStringFromName(key); }, /** * Shows the bookmark dialog corresponding to the specified info. * * @param aInfo * Describes the item to be edited/added in the dialog. * See documentation at the top of bookmarkProperties.js * @param aWindow * Owner window for the new dialog. * * @see documentation at the top of bookmarkProperties.js * @return true if any transaction has been performed, false otherwise. */ showBookmarkDialog(aInfo, aParentWindow) { // Preserve size attributes differently based on the fact the dialog has // a folder picker or not, since it needs more horizontal space than the // other controls. let hasFolderPicker = !("hiddenRows" in aInfo) || !aInfo.hiddenRows.includes("folderPicker"); // Use a different chrome url to persist different sizes. let dialogURL = hasFolderPicker ? "chrome://communicator/content/places/bookmarkProperties2.xul" : "chrome://communicator/content/places/bookmarkProperties.xul"; let features = "centerscreen,chrome,modal,resizable=yes"; let topUndoEntry; let batchBlockingDeferred; // Set the transaction manager into batching mode. topUndoEntry = PlacesTransactions.topUndoEntry; batchBlockingDeferred = PromiseUtils.defer(); PlacesTransactions.batch(async () => { await batchBlockingDeferred.promise; }); aParentWindow.openDialog(dialogURL, "", features, aInfo); let performed = ("performed" in aInfo && aInfo.performed); batchBlockingDeferred.resolve(); if (!performed && topUndoEntry != PlacesTransactions.topUndoEntry) { PlacesTransactions.undo().catch(Cu.reportError); } return performed; }, /** * set and fetch a favicon. Can only be used from the parent process. * @param browser {Browser} The XUL browser element for which we're fetching a favicon. * @param principal {Principal} The loading principal to use for the fetch. * @param uri {URI} The URI to fetch. */ loadFavicon(browser, principal, uri, requestContextID) { if (gInContentProcess) { throw new Error("Can't track loads from within the child process!"); } InternalFaviconLoader.loadFavicon(browser, principal, uri, requestContextID); }, /** * Returns the closet ancestor places view for the given DOM node * @param aNode * a DOM node * @return the closet ancestor places view if exists, null otherwsie. */ getViewForNode: function PUIU_getViewForNode(aNode) { let node = aNode; if (node.localName == "panelview" && node._placesView) { return node._placesView; } // The view for a of which its associated menupopup is a places // view, is the menupopup. if (node.localName == "menu" && !node._placesNode && node.lastChild._placesView) return node.lastChild._placesView; while (Element.isInstance(node)) { if (node._placesView) return node._placesView; if (node.localName == "tree" && node.getAttribute("type") == "places") return node; node = node.parentNode; } return null; }, /** * Returns the active PlacesController for a given command. * * @param win The window containing the affected view * @param command The command * @return a PlacesController */ getControllerForCommand(win, command) { // A context menu may be built for non-focusable views. Thus, we first try // to look for a view associated with document.popupNode let popupNode; try { popupNode = win.document.popupNode; } catch (e) { // The document went away (bug 797307). return null; } if (popupNode) { let view = this.getViewForNode(popupNode); if (view && view._contextMenuShown) return view.controllers.getControllerForCommand(command); } // When we're not building a context menu, only focusable views // are possible. Thus, we can safely use the command dispatcher. let controller = win.top.document.commandDispatcher .getControllerForCommand(command); return controller || null; }, /** * Update all the Places commands for the given window. * * @param win The window to update. */ updateCommands(win) { // Get the controller for one of the places commands. let controller = this.getControllerForCommand(win, "placesCmd_open"); for (let command of [ "placesCmd_open", "placesCmd_open:window", "placesCmd_open:privatewindow", "placesCmd_open:tab", "placesCmd_new:folder", "placesCmd_new:bookmark", "placesCmd_new:separator", "placesCmd_show:info", "placesCmd_reload", "placesCmd_sortBy:name", "placesCmd_cut", "placesCmd_copy", "placesCmd_paste", "placesCmd_delete", ]) { win.goSetCommandEnabled(command, controller && controller.isCommandEnabled(command)); } }, /** * Executes the given command on the currently active controller. * * @param win The window containing the affected view * @param command The command to execute */ doCommand(win, command) { let controller = this.getControllerForCommand(win, command); if (controller && controller.isCommandEnabled(command)) controller.doCommand(command); }, /** * By calling this before visiting an URL, the visit will be associated to a * TRANSITION_TYPED transition (if there is no a referrer). * This is used when visiting pages from the history menu, history sidebar, * url bar, url autocomplete results, and history searches from the places * organizer. If this is not called visits will be marked as * TRANSITION_LINK. */ markPageAsTyped: function PUIU_markPageAsTyped(aURL) { PlacesUtils.history.markPageAsTyped(this.createFixedURI(aURL)); }, /** * By calling this before visiting an URL, the visit will be associated to a * TRANSITION_BOOKMARK transition. * This is used when visiting pages from the bookmarks menu, * personal toolbar, and bookmarks from within the places organizer. * If this is not called visits will be marked as TRANSITION_LINK. */ markPageAsFollowedBookmark: function PUIU_markPageAsFollowedBookmark(aURL) { PlacesUtils.history.markPageAsFollowedBookmark(this.createFixedURI(aURL)); }, /** * By calling this before visiting an URL, any visit in frames will be * associated to a TRANSITION_FRAMED_LINK transition. * This is actually used to distinguish user-initiated visits in frames * so automatic visits can be correctly ignored. */ markPageAsFollowedLink: function PUIU_markPageAsFollowedLink(aURL) { PlacesUtils.history.markPageAsFollowedLink(this.createFixedURI(aURL)); }, /** * Allows opening of javascript/data URI only if the given node is * bookmarked (see bug 224521). * @param aURINode * a URI node * @param aWindow * a window on which a potential error alert is shown on. * @return true if it's safe to open the node in the browser, false otherwise. * */ checkURLSecurity: function PUIU_checkURLSecurity(aURINode, aWindow) { if (PlacesUtils.nodeIsBookmark(aURINode)) return true; var uri = Services.io.newURI(aURINode.uri); if (uri.schemeIs("javascript") || uri.schemeIs("data")) { const BRANDING_BUNDLE_URI = "chrome://branding/locale/brand.properties"; var brandShortName = Services.strings .createBundle(BRANDING_BUNDLE_URI) .GetStringFromName("brandShortName"); var errorStr = this.getString("load-js-data-url-error"); Services.prompt.alert(aWindow, brandShortName, errorStr); return false; } return true; }, /** * Get the description associated with a document, as specified in a * element. * @param doc * A DOM Document to get a description for * @return A description string if a META element was discovered with a * "description" or "httpequiv" attribute, empty string otherwise. */ getDescriptionFromDocument: function PUIU_getDescriptionFromDocument(doc) { var metaElements = doc.getElementsByTagName("META"); for (var i = 0; i < metaElements.length; ++i) { if (metaElements[i].name.toLowerCase() == "description" || metaElements[i].httpEquiv.toLowerCase() == "description") { return metaElements[i].content; } } return ""; }, /** * Retrieve the description of an item * @param aItemId * item identifier * @return the description of the given item, or an empty string if it is * not set. */ getItemDescription: function PUIU_getItemDescription(aItemId) { if (PlacesUtils.annotations.itemHasAnnotation(aItemId, this.DESCRIPTION_ANNO)) return PlacesUtils.annotations.getItemAnnotation(aItemId, this.DESCRIPTION_ANNO); return ""; }, /** * Check whether or not the given node represents a removable entry (either in * history or in bookmarks). * * @param aNode * a node, except the root node of a query. * @param aView * The view originating the request. * @return true if the aNode represents a removable entry, false otherwise. */ canUserRemove(aNode, aView) { let parentNode = aNode.parent; if (!parentNode) { // canUserRemove doesn't accept root nodes. return false; } // Is it a query pointing to one of the special root folders? if (PlacesUtils.nodeIsQuery(parentNode) && PlacesUtils.nodeIsFolder(aNode)) { let guid = PlacesUtils.getConcreteItemGuid(aNode); // If the parent folder is not a folder, it must be a query, and so this node // cannot be removed. if (PlacesUtils.isRootItem(guid)) { return false; } } // If it's not a bookmark, we can remove it unless it's a child of a // livemark. if (aNode.itemId == -1) { // Rather than executing a db query, checking the existence of the feedURI // annotation, detect livemark children by the fact that they are the only // direct non-bookmark children of bookmark folders. return !PlacesUtils.nodeIsFolder(parentNode); } // Generally it's always possible to remove children of a query. if (PlacesUtils.nodeIsQuery(parentNode)) return true; // Otherwise it has to be a child of an editable folder. return !this.isFolderReadOnly(parentNode, aView); }, /** * DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH * TO GUIDS IS COMPLETE (BUG 1071511). * * Check whether or not the given Places node points to a folder which * should not be modified by the user (i.e. its children should be unremovable * and unmovable, new children should be disallowed, etc). * These semantics are not inherited, meaning that read-only folder may * contain editable items (for instance, the places root is read-only, but all * of its direct children aren't). * * You should only pass folder nodes. * * @param placesNode * any folder result node. * @param view * The view originating the request. * @throws if placesNode is not a folder result node or views is invalid. * @note livemark "folders" are considered read-only (but see bug 1072833). * @return true if placesNode is a read-only folder, false otherwise. */ isFolderReadOnly(placesNode, view) { if (typeof placesNode != "object" || !PlacesUtils.nodeIsFolder(placesNode)) { throw new Error("invalid value for placesNode"); } if (!view || typeof view != "object") { throw new Error("invalid value for aView"); } let itemId = PlacesUtils.getConcreteItemId(placesNode); if (itemId == PlacesUtils.placesRootId || view.controller.hasCachedLivemarkInfo(placesNode)) return true; // leftPaneFolderId is a lazy getter // performing at least a synchronous DB query (and on its very first call // in a fresh profile, it also creates the entire structure). // Therefore we don't want to this function, which is called very often by // isCommandEnabled, to ever be the one that invokes it first, especially // because isCommandEnabled may be called way before the left pane folder is // even created (for example, if the user only uses the bookmarks menu or // toolbar for managing bookmarks). To do so, we avoid comparing to those // special folder if the lazy getter is still in place. This is safe merely // because the only way to access the left pane contents goes through // "resolving" the leftPaneFolderId getter. if (typeof Object.getOwnPropertyDescriptor(this, "leftPaneFolderId").get == "function") { return false; } return itemId == this.leftPaneFolderId; }, /** aItemsToOpen needs to be an array of objects of the form: * {uri: string, isBookmark: boolean} */ _openTabset: function PUIU__openTabset(aItemsToOpen, aEvent, aWindow) { if (!aItemsToOpen.length) return; // Prefer the caller window if it's a browser window, otherwise use // the top browser window. var browserWindow = null; browserWindow = aWindow && aWindow.document.documentElement.getAttribute("windowtype") == "navigator:browser" ? aWindow : RecentWindow.getMostRecentBrowserWindow(); var urls = []; let skipMarking = browserWindow && PrivateBrowsingUtils.isWindowPrivate(browserWindow); for (let item of aItemsToOpen) { urls.push(item.uri); if (skipMarking) { continue; } if (item.isBookmark) this.markPageAsFollowedBookmark(item.uri); else this.markPageAsTyped(item.uri); } // whereToOpenLink doesn't return "window" when there's no browser window // open (Bug 630255). var where = browserWindow ? browserWindow.whereToOpenLink(aEvent, false, true) : "window"; if (where == "window") { // There is no browser window open, thus open a new one. var uriList = PlacesUtils.toISupportsString(urls.join("|")); var args = Cc["@mozilla.org/array;1"] .createInstance(Ci.nsIMutableArray); args.appendElement(uriList); browserWindow = Services.ww.openWindow(aWindow, "chrome://navigator/content/navigator.xul", null, "chrome,dialog=no,all", args); return; } var loadInBackground = where == "tabshifted"; // For consistency, we want all the bookmarks to open in new tabs, instead // of having one of them replace the currently focused tab. Hence we call // loadTabs with aReplace set to false. browserWindow.gBrowser.loadTabs(urls, { inBackground: loadInBackground, replace: false, triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), }); }, openLiveMarkNodesInTabs: function PUIU_openLiveMarkNodesInTabs(aNode, aEvent, aView) { let window = aView.ownerWindow; PlacesUtils.livemarks.getLivemark({id: aNode.itemId}) .then(aLivemark => { let urlsToOpen = []; let nodes = aLivemark.getNodesForContainer(aNode); for (let node of nodes) { urlsToOpen.push({uri: node.uri, isBookmark: false}); } if (OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) { this._openTabset(urlsToOpen, aEvent, window); } }, Cu.reportError); }, openContainerNodeInTabs: function PUIU_openContainerInTabs(aNode, aEvent, aView) { let window = aView.ownerWindow; let urlsToOpen = PlacesUtils.getURLsForContainerNode(aNode); if (OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) { this._openTabset(urlsToOpen, aEvent, window); } }, openURINodesInTabs: function PUIU_openURINodesInTabs(aNodes, aEvent, aView) { let window = aView.ownerWindow; let urlsToOpen = []; for (var i = 0; i < aNodes.length; i++) { // Skip over separators and folders. if (PlacesUtils.nodeIsURI(aNodes[i])) urlsToOpen.push({uri: aNodes[i].uri, isBookmark: PlacesUtils.nodeIsBookmark(aNodes[i])}); } this._openTabset(urlsToOpen, aEvent, window); }, /** * Loads the node's URL in the appropriate tab or window or as a web * panel given the user's preference specified by modifier keys tracked by a * DOM mouse/key event. * @param aNode * An uri result node. * @param aEvent * The DOM mouse/key event with modifier keys set that track the * user's preferred destination window or tab. * @param aExternal * Called from the library window or an external application. * Link handling for external applications will apply when true. */ openNodeWithEvent: function PUIU_openNodeWithEvent(aNode, aEvent, aExternal = false) { let window = aEvent.target.ownerGlobal; let whereTo; if (aExternal) { let openParms = window.whereToLoadExternalLink(); whereTo = openParms.where; } else { whereTo = window.whereToOpenLink(aEvent, false, true); } this._openNodeIn(aNode, whereTo, window); }, /** * Loads the node's URL in the appropriate tab or window or as a * web panel. * see also openUILinkIn */ openNodeIn: function PUIU_openNodeIn(aNode, aWhere, aView, aPrivate) { let window = aView.ownerWindow; this._openNodeIn(aNode, aWhere, window, aPrivate); }, _openNodeIn: function PUIU__openNodeIn(aNode, aWhere, aWindow, aPrivate = false) { if (aNode && PlacesUtils.nodeIsURI(aNode) && this.checkURLSecurity(aNode, aWindow)) { let isBookmark = PlacesUtils.nodeIsBookmark(aNode); if (!PrivateBrowsingUtils.isWindowPrivate(aWindow)) { if (isBookmark) this.markPageAsFollowedBookmark(aNode.uri); else this.markPageAsTyped(aNode.uri); } // Check whether the node is a bookmark which should be opened as // a web panel // Currently not supported in SeaMonkey. Please stay tuned. // if (aWhere == "current" && isBookmark) { // if (PlacesUtils.annotations // .itemHasAnnotation(aNode.itemId, this.LOAD_IN_SIDEBAR_ANNO)) { // let browserWin = this._getTopBrowserWin(); // if (browserWin) { // browserWin.openWebPanel(aNode.title, aNode.uri); // return; // } // } // } aWindow.openUILinkIn(aNode.uri, aWhere, { allowPopups: aNode.uri.startsWith("javascript:"), inBackground: Services.prefs.getBoolPref("browser.tabs.avoidBrowserFocus"), aNoReferrer: true, private: aPrivate, }); } }, /** * Helper for guessing scheme from an url string. * Used to avoid nsIURI overhead in frequently called UI functions. * * @param aUrlString the url to guess the scheme from. * * @return guessed scheme for this url string. * * @note this is not supposed be perfect, so use it only for UI purposes. */ guessUrlSchemeForUI: function PUIU_guessUrlSchemeForUI(aUrlString) { return aUrlString.substr(0, aUrlString.indexOf(":")); }, getBestTitle: function PUIU_getBestTitle(aNode, aDoNotCutTitle) { var title; if (!aNode.title && PlacesUtils.nodeIsURI(aNode)) { // if node title is empty, try to set the label using host and filename // Services.io.newURI() will throw if aNode.uri is not a valid URI try { var uri = Services.io.newURI(aNode.uri); var host = uri.host; var fileName = uri.QueryInterface(Ci.nsIURL).fileName; // if fileName is empty, use path to distinguish labels if (aDoNotCutTitle) { title = host + uri.pathQueryRef; } else { title = host + (fileName ? (host ? "/" + this.ellipsis + "/" : "") + fileName : uri.pathQueryRef); } } catch (e) { // Use (no title) for non-standard URIs (data:, javascript:, ...) title = ""; } } else title = aNode.title; return title || this.getString("noTitle"); }, get leftPaneQueries() { // build the map this.leftPaneFolderId; return this.leftPaneQueries; }, get leftPaneFolderId() { delete this.leftPaneFolderId; return this.leftPaneFolderId = this.maybeRebuildLeftPane(); }, // Get the folder id for the organizer left-pane folder. maybeRebuildLeftPane() { let leftPaneRoot = -1; // Shortcuts to services. let bs = PlacesUtils.bookmarks; let as = PlacesUtils.annotations; // This is the list of the left pane queries. let queries = { "PlacesRoot": { title: "" }, "History": { title: this.getString("OrganizerQueryHistory") }, "Tags": { title: this.getString("OrganizerQueryTags") }, "AllBookmarks": { title: this.getString("OrganizerQueryAllBookmarks") }, }; // All queries but PlacesRoot. const EXPECTED_QUERY_COUNT = 3; // Removes an item and associated annotations, ignoring eventual errors. function safeRemoveItem(aItemId) { try { if (as.itemHasAnnotation(aItemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO) && !(as.getItemAnnotation(aItemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO) in queries)) { // Some extension annotated their roots with our query annotation, // so we should not delete them. return; } // removeItemAnnotation does not check if item exists, nor the anno, // so this is safe to do. as.removeItemAnnotation(aItemId, PlacesUIUtils.ORGANIZER_FOLDER_ANNO); as.removeItemAnnotation(aItemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO); // This will throw if the annotation is an orphan. bs.removeItem(aItemId); } catch (e) { /* orphan anno */ } } // Returns true if item really exists, false otherwise. function itemExists(aItemId) { try { bs.getFolderIdForItem(aItemId); return true; } catch (e) { return false; } } // Get all items marked as being the left pane folder. let items = as.getItemsWithAnnotation(this.ORGANIZER_FOLDER_ANNO); if (items.length > 1) { // Something went wrong, we cannot have more than one left pane folder, // remove all left pane folders and continue. We will create a new one. items.forEach(safeRemoveItem); } else if (items.length == 1 && items[0] != -1) { leftPaneRoot = items[0]; // Check that organizer left pane root is valid. let version = as.getItemAnnotation(leftPaneRoot, this.ORGANIZER_FOLDER_ANNO); if (version != this.ORGANIZER_LEFTPANE_VERSION || !itemExists(leftPaneRoot)) { // Invalid root, we must rebuild the left pane. safeRemoveItem(leftPaneRoot); leftPaneRoot = -1; } } if (leftPaneRoot != -1) { // A valid left pane folder has been found. // Build the leftPaneQueries Map. This is used to quickly access them, // associating a mnemonic name to the real item ids. delete this.leftPaneQueries; this.leftPaneQueries = {}; let queryItems = as.getItemsWithAnnotation(this.ORGANIZER_QUERY_ANNO); // While looping through queries we will also check for their validity. let queriesCount = 0; let corrupt = false; for (let i = 0; i < queryItems.length; i++) { let queryName = as.getItemAnnotation(queryItems[i], this.ORGANIZER_QUERY_ANNO); // Some extension did use our annotation to decorate their items // with icons, so we should check only our elements, to avoid dataloss. if (!(queryName in queries)) continue; let query = queries[queryName]; query.itemId = queryItems[i]; if (!itemExists(query.itemId)) { // Orphan annotation, bail out and create a new left pane root. corrupt = true; break; } // Check that all queries have valid parents. let parentId = bs.getFolderIdForItem(query.itemId); if (!queryItems.includes(parentId) && parentId != leftPaneRoot) { // The parent is not part of the left pane, bail out and create a new // left pane root. corrupt = true; break; } // Titles could have been corrupted or the user could have changed his // locale. Check title and eventually fix it. if (bs.getItemTitle(query.itemId) != query.title) bs.setItemTitle(query.itemId, query.title); if ("concreteId" in query) { if (bs.getItemTitle(query.concreteId) != query.concreteTitle) bs.setItemTitle(query.concreteId, query.concreteTitle); } // Add the query to our cache. this.leftPaneQueries[queryName] = query.itemId; queriesCount++; } // Note: it's not enough to just check for queriesCount, since we may // find an invalid query just after accounting for a sufficient number of // valid ones. As well as we can't just rely on corrupt since we may find // less valid queries than expected. if (corrupt || queriesCount != EXPECTED_QUERY_COUNT) { // Queries number is wrong, so the left pane must be corrupt. // Note: we can't just remove the leftPaneRoot, because some query could // have a bad parent, so we have to remove all items one by one. queryItems.forEach(safeRemoveItem); safeRemoveItem(leftPaneRoot); } else { // Everything is fine, return the current left pane folder. return leftPaneRoot; } } // Create a new left pane folder. var callback = { // Helper to create an organizer special query. create_query: function CB_create_query(aQueryName, aParentId, aQueryUrl) { let itemId = bs.insertBookmark(aParentId, Services.io.newURI(aQueryUrl), bs.DEFAULT_INDEX, queries[aQueryName].title); // Mark as special organizer query. as.setItemAnnotation(itemId, PlacesUIUtils.ORGANIZER_QUERY_ANNO, aQueryName, 0, as.EXPIRE_NEVER); // We should never backup this, since it changes between profiles. as.setItemAnnotation(itemId, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, 1, 0, as.EXPIRE_NEVER); // Add to the queries map. PlacesUIUtils.leftPaneQueries[aQueryName] = itemId; return itemId; }, // Helper to create an organizer special folder. create_folder: function CB_create_folder(aFolderName, aParentId, aIsRoot) { // Left Pane Root Folder. let folderId = bs.createFolder(aParentId, queries[aFolderName].title, bs.DEFAULT_INDEX); // We should never backup this, since it changes between profiles. as.setItemAnnotation(folderId, PlacesUtils.EXCLUDE_FROM_BACKUP_ANNO, 1, 0, as.EXPIRE_NEVER); if (aIsRoot) { // Mark as special left pane root. as.setItemAnnotation(folderId, PlacesUIUtils.ORGANIZER_FOLDER_ANNO, PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION, 0, as.EXPIRE_NEVER); } else { // Mark as special organizer folder. as.setItemAnnotation(folderId, PlacesUIUtils.ORGANIZER_QUERY_ANNO, aFolderName, 0, as.EXPIRE_NEVER); PlacesUIUtils.leftPaneQueries[aFolderName] = folderId; } return folderId; }, runBatched: function CB_runBatched(aUserData) { delete PlacesUIUtils.leftPaneQueries; PlacesUIUtils.leftPaneQueries = { }; // Left Pane Root Folder. leftPaneRoot = this.create_folder("PlacesRoot", bs.placesRoot, true); // History Query. this.create_query("History", leftPaneRoot, "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY + "&sort=" + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING); // Tags Query. this.create_query("Tags", leftPaneRoot, "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY + "&sort=" + Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING); // All Bookmarks Folder. this.create_query("AllBookmarks", leftPaneRoot, "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY); } }; bs.runInBatchMode(callback, null); return leftPaneRoot; }, /** * If an item is a left-pane query, returns the name of the query * or an empty string if not. * * @param aItemId id of a container * @return the name of the query, or empty string if not a left-pane query */ getLeftPaneQueryNameFromId: function PUIU_getLeftPaneQueryNameFromId(aItemId) { var queryName = ""; // If the let pane hasn't been built, use the annotation service // directly, to avoid building the left pane too early. if (Object.getOwnPropertyDescriptor(this, "leftPaneFolderId").value === undefined) { try { queryName = PlacesUtils.annotations. getItemAnnotation(aItemId, this.ORGANIZER_QUERY_ANNO); } catch (ex) { // doesn't have the annotation queryName = ""; } } else { // If the left pane has already been built, use the name->id map // cached in PlacesUIUtils. for (let [name, id] of Object.entries(this.leftPaneQueries)) { if (aItemId == id) queryName = name; } } return queryName; }, shouldShowTabsFromOtherComputersMenuitem() { let weaveOK = Weave.Status.checkSetup() != Weave.CLIENT_NOT_CONFIGURED && Weave.Svc.Prefs.get("firstSync", "") != "notReady"; return weaveOK; }, /** * WARNING TO ADDON AUTHORS: DO NOT USE THIS METHOD. IT'S LIKELY TO BE REMOVED IN A * FUTURE RELEASE. * * Checks if a place: href represents a folder shortcut. * * @param queryString * the query string to check (a place: href) * @return whether or not queryString represents a folder shortcut. * @throws if queryString is malformed. */ isFolderShortcutQueryString(queryString) { // Based on GetSimpleBookmarksQueryFolder in nsNavHistory.cpp. let queriesParam = { }, optionsParam = { }; PlacesUtils.history.queryStringToQueries(queryString, queriesParam, { }, optionsParam); let queries = queries.value; if (queries.length == 0) throw new Error(`Invalid place: uri: ${queryString}`); return queries.length == 1 && queries[0].folderCount == 1 && !queries[0].hasBeginTime && !queries[0].hasEndTime && !queries[0].hasDomain && !queries[0].hasURI && !queries[0].hasSearchTerms && !queries[0].tags.length == 0 && optionsParam.value.maxResults == 0; }, /** * @see showAddBookmarkUI * This opens the dialog with only the name and folder pickers visible by * default. * * This is to be used only outside of the SeaMonkey browser part e.g. for * bookmarking in mail and news windows. * * You can still pass in the various paramaters as the default properties * for the new bookmark. * * The keyword field will be visible only if the aKeyword parameter * was used. */ showMinimalAddBookmarkUI: function PUIU_showMinimalAddBookmarkUI(aURI, aTitle, aDescription, aDefaultInsertionPoint, aShowPicker, aLoadInSidebar, aKeyword, aPostData, aCharSet) { var info = { action: "add", type: "bookmark", hiddenRows: ["description"] }; if (aURI) info.uri = aURI; // allow default empty title if (typeof(aTitle) == "string") info.title = aTitle; if (aDescription) info.description = aDescription; if (aDefaultInsertionPoint) { info.defaultInsertionPoint = aDefaultInsertionPoint; if (!aShowPicker) info.hiddenRows.push("folderPicker"); } info.hiddenRows = info.hiddenRows.concat(["location"]); if (typeof(aKeyword) == "string") { info.keyword = aKeyword; // Hide the Tags field if we are adding a keyword. info.hiddenRows.push("tags"); // Keyword related params. if (typeof(aPostData) == "string") info.postData = aPostData; if (typeof(aCharSet) == "string") info.charSet = aCharSet; } else info.hiddenRows.push("keyword"); return this.showBookmarkDialog(info, focusManager.activeWindow || Services.wm.getMostRecentWindow(null)); }, /** * Helpers for consumers of editBookmarkOverlay which don't have a node as their input. * * Given a bookmark object for either a url bookmark or a folder, returned by * Bookmarks.fetch (see Bookmark.jsm), this creates a node-like object suitable for * initialising the edit overlay with it. * * @param aFetchInfo * a bookmark object returned by Bookmarks.fetch. * @return a node-like object suitable for initialising editBookmarkOverlay. * @throws if aFetchInfo is representing a separator. */ async promiseNodeLikeFromFetchInfo(aFetchInfo) { if (aFetchInfo.itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR) throw new Error("promiseNodeLike doesn't support separators"); let parent = { itemId: await PlacesUtils.promiseItemId(aFetchInfo.parentGuid), bookmarkGuid: aFetchInfo.parentGuid, type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER }; return Object.freeze({ itemId: await PlacesUtils.promiseItemId(aFetchInfo.guid), bookmarkGuid: aFetchInfo.guid, title: aFetchInfo.title, uri: aFetchInfo.url !== undefined ? aFetchInfo.url.href : "", get type() { if (aFetchInfo.itemType == PlacesUtils.bookmarks.TYPE_FOLDER) return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER; if (this.uri.length == 0) throw new Error("Unexpected item type"); if (/^place:/.test(this.uri)) { if (this.isFolderShortcutQueryString(this.uri)) return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; return Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY; } return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; }, get parent() { return parent; } }); }, /** * This function wraps potentially large places transaction operations * with batch notifications to the result node, hence switching the views * to batch mode. * * @param {nsINavHistoryResult} resultNode The result node to turn on batching. * @note If resultNode is not supplied, the function will pass-through to * functionToWrap. * @param {Integer} itemsBeingChanged The count of items being changed. If the * count is lower than a threshold, then * batching won't be set. * @param {Function} functionToWrap The function to */ async batchUpdatesForNode(resultNode, itemsBeingChanged, functionToWrap) { if (!resultNode) { await functionToWrap(); return; } resultNode = resultNode.QueryInterface(Ci.nsINavBookmarkObserver); if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) { resultNode.onBeginUpdateBatch(); } try { await functionToWrap(); } finally { if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) { resultNode.onEndUpdateBatch(); } } }, /** * Constructs a Places Transaction for the drop or paste of a blob of data * into a container. * * @param aData * The unwrapped data blob of dropped or pasted data. * @param aNewParentGuid * GUID of the container the data was dropped or pasted into. * @param aIndex * The index within the container the item was dropped or pasted at. * @param aCopy * The drag action was copy, so don't move folders or links. * * @return a Places Transaction that can be transacted for performing the * move/insert command. */ getTransactionForData(aData, aNewParentGuid, aIndex, aCopy) { if (!this.SUPPORTED_FLAVORS.includes(aData.type)) throw new Error(`Unsupported '${aData.type}' data type`); if ("itemGuid" in aData && "instanceId" in aData && aData.instanceId == PlacesUtils.instanceId) { if (!this.PLACES_FLAVORS.includes(aData.type)) throw new Error(`itemGuid unexpectedly set on ${aData.type} data`); let info = { guid: aData.itemGuid, newParentGuid: aNewParentGuid, newIndex: aIndex }; if (aCopy) { info.excludingAnnotation = "Places/SmartBookmark"; return PlacesTransactions.Copy(info); } return PlacesTransactions.Move(info); } // Since it's cheap and harmless, we allow the paste of separators and // bookmarks from builds that use legacy transactions (i.e. when itemGuid // was not set on PLACES_FLAVORS data). Containers are a different story, // and thus disallowed. if (aData.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) throw new Error("Can't copy a container from a legacy-transactions build"); if (aData.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { return PlacesTransactions.NewSeparator({ parentGuid: aNewParentGuid, index: aIndex }); } let title = aData.type != PlacesUtils.TYPE_UNICODE ? aData.title : aData.uri; return PlacesTransactions.NewBookmark({ url: Services.io.newURI(aData.uri), title, parentGuid: aNewParentGuid, index: aIndex }); }, /** * Processes a set of transfer items that have been dropped or pasted. * Batching will be applied where necessary. * * @param {Array} items A list of unwrapped nodes to process. * @param {Object} insertionPoint The requested point for insertion. * @param {Boolean} doCopy Set to true to copy the items, false will move them * if possible. * @paramt {Object} view The view that should be used for batching. * @return {Array} Returns an empty array when the insertion point is a tag, else * returns an array of copied or moved guids. */ async handleTransferItems(items, insertionPoint, doCopy, view) { let transactions; let itemsCount; if (insertionPoint.isTag) { let urls = items.filter(item => "uri" in item).map(item => item.uri); itemsCount = urls.length; transactions = [PlacesTransactions.Tag({ urls, tag: insertionPoint.tagName })]; } else { let insertionIndex = await insertionPoint.getIndex(); itemsCount = items.length; transactions = await getTransactionsForTransferItems( items, insertionIndex, insertionPoint.guid, doCopy); } // Check if we actually have something to add, if we don't it probably wasn't // valid, or it was moving to the same location, so just ignore it. if (!transactions.length) { return []; } let guidsToSelect = []; let resultForBatching = getResultForBatching(view); // If we're inserting into a tag, we don't get the guid, so we'll just // pass the transactions direct to the batch function. let batchingItem = transactions; if (!insertionPoint.isTag) { // If we're not a tag, then we need to get the ids of the items to select. batchingItem = async () => { for (let transaction of transactions) { let guid = await transaction.transact(); if (guid) { guidsToSelect.push(guid); } } }; } await this.batchUpdatesForNode(resultForBatching, itemsCount, async () => { await PlacesTransactions.batch(batchingItem); }); return guidsToSelect; }, }; // These are lazy getters to avoid importing PlacesUtils immediately. XPCOMUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => { return [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, PlacesUtils.TYPE_X_MOZ_PLACE]; }); XPCOMUtils.defineLazyGetter(PlacesUIUtils, "URI_FLAVORS", () => { return [PlacesUtils.TYPE_X_MOZ_URL, TAB_DROP_TYPE, PlacesUtils.TYPE_UNICODE]; }); XPCOMUtils.defineLazyGetter(PlacesUIUtils, "SUPPORTED_FLAVORS", () => { return [...PlacesUIUtils.PLACES_FLAVORS, ...PlacesUIUtils.URI_FLAVORS]; }); XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function() { return Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data; }); XPCOMUtils.defineLazyServiceGetter(this, "focusManager", "@mozilla.org/focus-manager;1", "nsIFocusManager"); /** * Determines if an unwrapped node can be moved. * * @param unwrappedNode * A node unwrapped by PlacesUtils.unwrapNodes(). * @return True if the node can be moved, false otherwise. */ function canMoveUnwrappedNode(unwrappedNode) { if ((unwrappedNode.concreteGuid && PlacesUtils.isRootItem(unwrappedNode.concreteGuid)) || unwrappedNode.id <= 0 || PlacesUtils.isRootItem(unwrappedNode.id)) { return false; } let parentGuid = unwrappedNode.parentGuid; // If there's no parent Guid, this was likely a virtual query that returns // bookmarks, such as a tags query. if (!parentGuid || parentGuid == PlacesUtils.bookmarks.rootGuid) { return false; } // leftPaneFolderId and allBookmarksFolderId are lazy getters running // at least a synchronous DB query. Therefore we don't want to invoke // them first, especially because isCommandEnabled may be called way // before the left pane folder is even necessary. if (typeof Object.getOwnPropertyDescriptor(PlacesUIUtils, "leftPaneFolderId").get != "function" && (unwrappedNode.parent == PlacesUIUtils.leftPaneFolderId)) { return false; } return true; } /** * This gets the most appropriate item for using for batching. In the case of multiple * views being related, the method returns the most expensive result to batch. * For example, if it detects the left-hand library pane, then it will look for * and return the reference to the right-hand pane. * * @param {Object} viewOrElement The item to check. * @return {Object} Will return the best result node to batch, or null * if one could not be found. */ function getResultForBatching(viewOrElement) { if (viewOrElement && Element.isInstance(viewOrElement) && viewOrElement.id === "placesList") { // Note: fall back to the existing item if we can't find the right-hane pane. viewOrElement = viewOrElement.ownerDocument.getElementById("placeContent") || viewOrElement; } if (viewOrElement && viewOrElement.result) { return viewOrElement.result; } return null; } /** * Processes a set of transfer items and returns transactions to insert or * move them. * * @param {Array} items A list of unwrapped nodes to get transactions for. * @param {Integer} insertionIndex The requested index for insertion. * @param {String} insertionParentGuid The guid of the parent folder to insert * or move the items to. * @param {Boolean} doCopy Set to true to copy the items, false will move them * if possible. * @return {Array} Returns an array of created PlacesTransactions. */ async function getTransactionsForTransferItems(items, insertionIndex, insertionParentGuid, doCopy) { let transactions = []; let index = insertionIndex; for (let item of items) { if (index != -1 && item.itemGuid) { // Note: we use the parent from the existing bookmark as the sidebar // gives us an unwrapped.parent that is actually a query and not the real // parent. let existingBookmark = await PlacesUtils.bookmarks.fetch(item.itemGuid); // If we're dropping on the same folder, then we may need to adjust // the index to insert at the correct place. if (existingBookmark && insertionParentGuid == existingBookmark.parentGuid) { if (index > existingBookmark.index) { // If we're dragging down, we need to go one lower to insert at // the real point as moving the element changes the index of // everything below by 1. index--; } else if (index == existingBookmark.index) { // This isn't moving so we skip it. continue; } } } // If this is not a copy, check for safety that we can move the // source, otherwise report an error and fallback to a copy. if (!doCopy && !canMoveUnwrappedNode(item)) { Cu.reportError("Tried to move an unmovable Places " + "node, reverting to a copy operation."); doCopy = true; } transactions.push( PlacesUIUtils.getTransactionForData(item, insertionParentGuid, index, doCopy)); if (index != -1 && item.itemGuid) { index++; } } return transactions; }