diff options
Diffstat (limited to 'comm/suite/components/places/PlacesUIUtils.jsm')
-rw-r--r-- | comm/suite/components/places/PlacesUIUtils.jsm | 1499 |
1 files changed, 1499 insertions, 0 deletions
diff --git a/comm/suite/components/places/PlacesUIUtils.jsm b/comm/suite/components/places/PlacesUIUtils.jsm new file mode 100644 index 0000000000..5440384212 --- /dev/null +++ b/comm/suite/components/places/PlacesUIUtils.jsm @@ -0,0 +1,1499 @@ +/* -*- 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 <menu> 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 <META> + * 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; +} |