diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/suite/components/places | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/suite/components/places')
84 files changed, 20890 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; +} diff --git a/comm/suite/components/places/content/bookmarkProperties.js b/comm/suite/components/places/content/bookmarkProperties.js new file mode 100644 index 0000000000..8078a4e9a7 --- /dev/null +++ b/comm/suite/components/places/content/bookmarkProperties.js @@ -0,0 +1,524 @@ +/* -*- 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/. */ + +/** + * The panel is initialized based on data given in the js object passed + * as window.arguments[0]. The object must have the following fields set: + * @ action (String). Possible values: + * - "add" - for adding a new item. + * @ type (String). Possible values: + * - "bookmark" + * @ loadBookmarkInSidebar - optional, the default state for the + * "Load this bookmark in the sidebar" field. + * - "folder" + * @ URIList (Array of nsIURI objects) - optional, list of uris to + * be bookmarked under the new folder. + * - "livemark" + * @ uri (nsIURI object) - optional, the default uri for the new item. + * The property is not used for the "folder with items" type. + * @ title (String) - optional, the default title for the new item. + * @ description (String) - optional, the default description for the new + * item. + * @ defaultInsertionPoint (InsertionPoint JS object) - optional, the + * default insertion point for the new item. + * @ keyword (String) - optional, the default keyword for the new item. + * @ postData (String) - optional, POST data to accompany the keyword. + * @ charSet (String) - optional, character-set to accompany the keyword. + * Notes: + * 1) If |uri| is set for a bookmark/livemark item and |title| isn't, + * the dialog will query the history tables for the title associated + * with the given uri. If the dialog is set to adding a folder with + * bookmark items under it (see URIList), a default static title is + * used ("[Folder Name]"). + * 2) The index field of the default insertion point is ignored if + * the folder picker is shown. + * - "edit" - for editing a bookmark item or a folder. + * @ type (String). Possible values: + * - "bookmark" + * @ node (an nsINavHistoryResultNode object) - a node representing + * the bookmark. + * - "folder" (also applies to livemarks) + * @ node (an nsINavHistoryResultNode object) - a node representing + * the folder. + * @ hiddenRows (Strings array) - optional, list of rows to be hidden + * regardless of the item edited or added by the dialog. + * Possible values: + * - "title" + * - "location" + * - "description" + * - "keyword" + * - "tags" + * - "loadInSidebar" + * - "folderPicker" - hides both the tree and the menu. + * + * window.arguments[0].performed is set to true if any transaction has + * been performed by the dialog. + */ + +/* import-globals-from editBookmarkOverlay.js */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); + +const BOOKMARK_ITEM = 0; +const BOOKMARK_FOLDER = 1; +const LIVEMARK_CONTAINER = 2; + +const ACTION_EDIT = 0; +const ACTION_ADD = 1; + +var elementsHeight = new Map(); + +var BookmarkPropertiesPanel = { + + /** UI Text Strings */ + __strings: null, + get _strings() { + if (!this.__strings) { + this.__strings = document.getElementById("stringBundle"); + } + return this.__strings; + }, + + _action: null, + _itemType: null, + _itemId: -1, + _uri: null, + _loadInSidebar: false, + _title: "", + _description: "", + _URIs: [], + _keyword: "", + _postData: null, + _charSet: "", + _feedURI: null, + _siteURI: null, + + _defaultInsertionPoint: null, + _hiddenRows: [], + + /** + * This method returns the correct label for the dialog's "accept" + * button based on the variant of the dialog. + */ + _getAcceptLabel: function BPP__getAcceptLabel() { + if (this._action == ACTION_ADD) { + if (this._URIs.length) + return this._strings.getString("dialogAcceptLabelAddMulti"); + + if (this._itemType == LIVEMARK_CONTAINER) + return this._strings.getString("dialogAcceptLabelAddLivemark"); + + if (this._dummyItem || this._loadInSidebar) + return this._strings.getString("dialogAcceptLabelAddItem"); + + return this._strings.getString("dialogAcceptLabelSaveItem"); + } + return this._strings.getString("dialogAcceptLabelEdit"); + }, + + /** + * This method returns the correct title for the current variant + * of this dialog. + */ + _getDialogTitle: function BPP__getDialogTitle() { + if (this._action == ACTION_ADD) { + if (this._itemType == BOOKMARK_ITEM) + return this._strings.getString("dialogTitleAddBookmark"); + if (this._itemType == LIVEMARK_CONTAINER) + return this._strings.getString("dialogTitleAddLivemark"); + + // add folder + if (this._itemType != BOOKMARK_FOLDER) + throw new Error("Unknown item type"); + if (this._URIs.length) + return this._strings.getString("dialogTitleAddMulti"); + + return this._strings.getString("dialogTitleAddFolder"); + } + if (this._action == ACTION_EDIT) { + return this._strings.getFormattedString("dialogTitleEdit", [this._title]); + } + return ""; + }, + + /** + * Determines the initial data for the item edited or added by this dialog + */ + async _determineItemInfo() { + let dialogInfo = window.arguments[0]; + this._action = dialogInfo.action == "add" ? ACTION_ADD : ACTION_EDIT; + this._hiddenRows = dialogInfo.hiddenRows ? dialogInfo.hiddenRows : []; + if (this._action == ACTION_ADD) { + if (!("type" in dialogInfo)) + throw new Error("missing type property for add action"); + + if ("title" in dialogInfo) + this._title = dialogInfo.title; + + if ("defaultInsertionPoint" in dialogInfo) { + this._defaultInsertionPoint = dialogInfo.defaultInsertionPoint; + } else { + this._defaultInsertionPoint = + new PlacesInsertionPoint({ + parentId: PlacesUtils.bookmarksMenuFolderId, + parentGuid: PlacesUtils.bookmarks.menuGuid + }); + } + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + if ("uri" in dialogInfo) { + if (!(dialogInfo.uri instanceof Ci.nsIURI)) + throw new Error("uri property should be a uri object"); + this._uri = dialogInfo.uri; + if (typeof(this._title) != "string") { + this._title = await PlacesUtils.history.fetch(this._uri) || + this._uri.spec; + } + } else { + this._uri = Services.io.newURI("about:blank"); + this._title = this._strings.getString("newBookmarkDefault"); + this._dummyItem = true; + } + + if ("loadBookmarkInSidebar" in dialogInfo) + this._loadInSidebar = dialogInfo.loadBookmarkInSidebar; + + if ("keyword" in dialogInfo) { + this._keyword = dialogInfo.keyword; + this._isAddKeywordDialog = true; + if ("postData" in dialogInfo) + this._postData = dialogInfo.postData; + if ("charSet" in dialogInfo) + this._charSet = dialogInfo.charSet; + } + break; + + case "folder": + this._itemType = BOOKMARK_FOLDER; + if (!this._title) { + if ("URIList" in dialogInfo) { + this._title = this._strings.getString("bookmarkAllTabsDefault"); + this._URIs = dialogInfo.URIList; + } else + this._title = this._strings.getString("newFolderDefault"); + this._dummyItem = true; + } + break; + + case "livemark": + this._itemType = LIVEMARK_CONTAINER; + if ("feedURI" in dialogInfo) + this._feedURI = dialogInfo.feedURI; + if ("siteURI" in dialogInfo) + this._siteURI = dialogInfo.siteURI; + + if (!this._title) { + if (this._feedURI) { + this._title = await PlacesUtils.history.fetch(this._feedURI) || + this._feedURI.spec; + } else + this._title = this._strings.getString("newLivemarkDefault"); + } + } + + if ("description" in dialogInfo) + this._description = dialogInfo.description; + } else { // edit + this._node = dialogInfo.node; + this._title = this._node.title; + if (PlacesUtils.nodeIsFolder(this._node)) + this._itemType = BOOKMARK_FOLDER; + else if (PlacesUtils.nodeIsURI(this._node)) + this._itemType = BOOKMARK_ITEM; + } + }, + + /** + * This method should be called by the onload of the Bookmark Properties + * dialog to initialize the state of the panel. + */ + async onDialogLoad() { + await this._determineItemInfo(); + + document.title = this._getDialogTitle(); + + // Disable the buttons until we have all the information required. + let acceptButton = document.documentElement.getButton("accept"); + acceptButton.disabled = true; + + // Allow initialization to complete in a truely async manner so that we're + // not blocking the main thread. + this._initDialog().catch(ex => { + Cu.reportError(`Failed to initialize dialog: ${ex}`); + }); + }, + + /** + * Initializes the dialog, gathering the required bookmark data. This function + * will enable the accept button (if appropraite) when it is complete. + */ + async _initDialog() { + let acceptButton = document.documentElement.getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + let acceptButtonDisabled = false; + + // Do not use sizeToContent, otherwise, due to bug 90276, the dialog will + // grow at every opening. + // Since elements can be uncollapsed asynchronously, we must observe their + // mutations and resize the dialog using a cached element size. + this._height = window.outerHeight; + this._mutationObserver = new MutationObserver(mutations => { + for (let mutation of mutations) { + let target = mutation.target; + let id = target.id; + if (!/^editBMPanel_.*(Row|Checkbox)$/.test(id)) + continue; + + let collapsed = target.getAttribute("collapsed") === "true"; + let wasCollapsed = mutation.oldValue === "true"; + if (collapsed == wasCollapsed) + continue; + + if (collapsed) { + this._height -= elementsHeight.get(id); + elementsHeight.delete(id); + } else { + elementsHeight.set(id, target.boxObject.height); + this._height += elementsHeight.get(id); + } + window.resizeTo(window.outerWidth, this._height); + } + }); + + this._mutationObserver.observe(document, + { subtree: true, + attributeOldValue: true, + attributeFilter: ["collapsed"] }); + + // Some controls are flexible and we want to update their cached size when + // the dialog is resized. + window.addEventListener("resize", this); + + switch (this._action) { + case ACTION_EDIT: + gEditItemOverlay.initPanel({ node: this._node, + hiddenRows: this._hiddenRows, + focusedElement: "first" }); + acceptButtonDisabled = gEditItemOverlay.readOnly; + break; + case ACTION_ADD: + this._node = await this._promiseNewItem(); + // Edit the new item + gEditItemOverlay.initPanel({ node: this._node, + hiddenRows: this._hiddenRows, + postData: this._postData, + focusedElement: "first" }); + + // Empty location field if the uri is about:blank, this way inserting a new + // url will be easier for the user, Accept button will be automatically + // disabled by the input listener until the user fills the field. + let locationField = this._element("locationField"); + if (locationField.value == "about:blank") + locationField.value = ""; + + // if this is an uri related dialog disable accept button until + // the user fills an uri value. + if (this._itemType == BOOKMARK_ITEM) + acceptButtonDisabled = !this._inputIsValid(); + break; + } + + if (!gEditItemOverlay.readOnly) { + // Listen on uri fields to enable accept button if input is valid + if (this._itemType == BOOKMARK_ITEM) { + this._element("locationField") + .addEventListener("input", this); + if (this._isAddKeywordDialog) { + this._element("keywordField") + .addEventListener("input", this); + } + } + } + // Only enable the accept button once we've finished everything. + acceptButton.disabled = acceptButtonDisabled; + }, + + // nsIDOMEventListener + handleEvent: function BPP_handleEvent(aEvent) { + var target = aEvent.target; + switch (aEvent.type) { + case "input": + if (target.id == "editBMPanel_locationField" || + target.id == "editBMPanel_keywordField") { + // Check uri fields to enable accept button if input is valid + document.documentElement + .getButton("accept").disabled = !this._inputIsValid(); + } + break; + case "resize": + for (let [id, oldHeight] of elementsHeight) { + let newHeight = document.getElementById(id).boxObject.height; + this._height += -oldHeight + newHeight; + elementsHeight.set(id, newHeight); + } + break; + } + }, + + // nsISupports + QueryInterface: function BPP_QueryInterface(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_NOINTERFACE; + }, + + _element: function BPP__element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + onDialogUnload() { + // gEditItemOverlay does not exist anymore here, so don't rely on it. + this._mutationObserver.disconnect(); + delete this._mutationObserver; + + window.removeEventListener("resize", this); + + // Calling removeEventListener with arguments which do not identify any + // currently registered EventListener on the EventTarget has no effect. + this._element("locationField") + .removeEventListener("input", this); + }, + + onDialogAccept() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement.blur(); + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + window.arguments[0].performed = true; + }, + + onDialogCancel() { + // We have to uninit the panel first, otherwise late changes could force it + // to commit more transactions. + gEditItemOverlay.uninitPanel(true); + window.arguments[0].performed = false; + }, + + /** + * This method checks to see if the input fields are in a valid state. + * + * @returns true if the input is valid, false otherwise + */ + _inputIsValid: function BPP__inputIsValid() { + if (this._itemType == BOOKMARK_ITEM && + !this._containsValidURI("locationField")) + return false; + if (this._isAddKeywordDialog && !this._element("keywordField").value.length) + return false; + + return true; + }, + + /** + * Determines whether the XUL textbox with the given ID contains a + * string that can be converted into an nsIURI. + * + * @param aTextboxID + * the ID of the textbox element whose contents we'll test + * + * @returns true if the textbox contains a valid URI string, false otherwise + */ + _containsValidURI: function BPP__containsValidURI(aTextboxID) { + try { + var value = this._element(aTextboxID).value; + if (value) { + PlacesUIUtils.createFixedURI(value); + return true; + } + } catch (e) { } + return false; + }, + + /** + * [New Item Mode] Get the insertion point details for the new item, given + * dialog state and opening arguments. + * + * The container-identifier and insertion-index are returned separately in + * the form of [containerIdentifier, insertionIndex] + */ + async _getInsertionPointDetails() { + return [ + this._defaultInsertionPoint.itemId, + await this._defaultInsertionPoint.getIndex(), + this._defaultInsertionPoint.guid, + ]; + }, + + async _promiseNewItem() { + let [containerId, index, parentGuid] = await this._getInsertionPointDetails(); + let annotations = []; + if (this._description) { + annotations.push({ name: PlacesUIUtils.DESCRIPTION_ANNO, + value: this._description }); + } + if (this._loadInSidebar) { + annotations.push({ name: PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, + value: true }); + } + + let itemGuid; + let info = { parentGuid, index, title: this._title, annotations }; + if (this._itemType == BOOKMARK_ITEM) { + info.url = this._uri; + if (this._keyword) + info.keyword = this._keyword; + if (this._postData) + info.postData = this._postData; + + if (this._charSet && !PrivateBrowsingUtils.isWindowPrivate(window)) + PlacesUtils.setCharsetForURI(this._uri, this._charSet); + + itemGuid = await PlacesTransactions.NewBookmark(info).transact(); + } else if (this._itemType == LIVEMARK_CONTAINER) { + info.feedUrl = this._feedURI; + if (this._siteURI) + info.siteUrl = this._siteURI; + + itemGuid = await PlacesTransactions.NewLivemark(info).transact(); + } else if (this._itemType == BOOKMARK_FOLDER) { + // NewFolder requires a url rather than uri. + info.children = this._URIs.map(item => { + return { url: item.uri, title: item.title }; + }); + itemGuid = await PlacesTransactions.NewFolder(info).transact(); + } else { + throw new Error(`unexpected value for _itemType: ${this._itemType}`); + } + + this._itemGuid = itemGuid; + this._itemId = await PlacesUtils.promiseItemId(itemGuid); + return Object.freeze({ + itemId: this._itemId, + bookmarkGuid: this._itemGuid, + title: this._title, + uri: this._uri ? this._uri.spec : "", + type: this._itemType == BOOKMARK_ITEM ? + Ci.nsINavHistoryResultNode.RESULT_TYPE_URI : + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER, + parent: { + itemId: containerId, + bookmarkGuid: parentGuid, + type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER + } + }); + } +}; diff --git a/comm/suite/components/places/content/bookmarkProperties.xul b/comm/suite/components/places/content/bookmarkProperties.xul new file mode 100644 index 0000000000..739d21c879 --- /dev/null +++ b/comm/suite/components/places/content/bookmarkProperties.xul @@ -0,0 +1,41 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://communicator/skin/"?> +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/editBookmarkOverlay.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/bookmarks.css"?> + +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/places/editBookmarkOverlay.xul"?> + +<!DOCTYPE dialog [ + <!ENTITY % editBookmarkOverlayDTD SYSTEM "chrome://communicator/locale/places/editBookmarkOverlay.dtd"> + %editBookmarkOverlayDTD; +]> + +<dialog id="bookmarkproperties" + buttons="accept, cancel" + buttoniconaccept="save" + ondialogaccept="BookmarkPropertiesPanel.onDialogAccept();" + ondialogcancel="BookmarkPropertiesPanel.onDialogCancel();" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="BookmarkPropertiesPanel.onDialogLoad();" + onunload="BookmarkPropertiesPanel.onDialogUnload();" + style="min-width: 30em;" + persist="screenX screenY width"> + + <stringbundleset id="stringbundleset"> + <stringbundle id="stringBundle" + src="chrome://communicator/locale/places/bookmarkProperties.properties"/> + </stringbundleset> + + <script src="chrome://communicator/content/places/editBookmarkOverlay.js"/> + <script src="chrome://communicator/content/places/bookmarkProperties.js"/> + +<vbox id="editBookmarkPanelContent"/> + +</dialog> diff --git a/comm/suite/components/places/content/bookmarksPanel.js b/comm/suite/components/places/content/bookmarksPanel.js new file mode 100644 index 0000000000..e0c79eb742 --- /dev/null +++ b/comm/suite/components/places/content/bookmarksPanel.js @@ -0,0 +1,24 @@ +/* -*- 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/. */ + +function init() { + document.getElementById("bookmarks-view").place = + "place:type=" + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; +} + +function searchBookmarks(aSearchString) { + var tree = document.getElementById("bookmarks-view"); + if (!aSearchString) + tree.place = tree.place; + else + tree.applyFilter(aSearchString, + [PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.toolbarFolderId, + PlacesUtils.mobileFolderId]); +} + +window.addEventListener("SidebarFocused", + () => document.getElementById("search-box").focus()); diff --git a/comm/suite/components/places/content/bookmarksPanel.xul b/comm/suite/components/places/content/bookmarksPanel.xul new file mode 100644 index 0000000000..59b9957a5a --- /dev/null +++ b/comm/suite/components/places/content/bookmarksPanel.xul @@ -0,0 +1,54 @@ +<?xml version="1.0"?> <!-- -*- Mode: SGML; indent-tabs-mode: nil; -*- --> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://communicator/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://communicator/skin/sidebar/sidebarListView.css" type="text/css"?> +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/bookmarks.css"?> + +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/utilityOverlay.xul"?> + +<!DOCTYPE page SYSTEM "chrome://communicator/locale/places/places.dtd"> + +<page id="bookmarksPanel" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="init();" + onunload="SidebarUtils.setMouseoverURL('');"> + + <script src="chrome://communicator/content/bookmarks/sidebarUtils.js"/> + <script src="chrome://communicator/content/bookmarks/bookmarksPanel.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <commandset id="placesCommands"/> + <menupopup id="placesContext"/> + + <!-- Bookmarks and history tooltip --> + <tooltip id="bhTooltip"/> + + <hbox id="sidebar-search-container" align="center"> + <textbox id="search-box" flex="1" type="search" + placeholder="&search.placeholder;" + aria-controls="bookmarks-view" + oncommand="searchBookmarks(this.value);"/> + </hbox> + + <tree id="bookmarks-view" class="sidebar-placesTree" type="places" + flex="1" + hidecolumnpicker="true" + treelines="true" + context="placesContext" + onkeypress="SidebarUtils.handleTreeKeyPress(event);" + onclick="SidebarUtils.handleTreeClick(this, event, true);" + onmousemove="SidebarUtils.handleTreeMouseMove(event);" + onmouseout="SidebarUtils.setMouseoverURL('');"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren id="bookmarks-view-children" view="bookmarks-view" + class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</page> diff --git a/comm/suite/components/places/content/browserPlacesViews.js b/comm/suite/components/places/content/browserPlacesViews.js new file mode 100644 index 0000000000..4b3e4c24c2 --- /dev/null +++ b/comm/suite/components/places/content/browserPlacesViews.js @@ -0,0 +1,2287 @@ +/* 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/. */ + +/* eslint-env mozilla/browser-window */ + +/** + * The base view implements everything that's common to the toolbar and + * menu views. + */ +function PlacesViewBase(aPlace, aOptions = {}) { + if ("rootElt" in aOptions) + this._rootElt = aOptions.rootElt; + if ("viewElt" in aOptions) + this._viewElt = aOptions.viewElt; + this.options = aOptions; + this._controller = new PlacesController(this); + this.place = aPlace; + this._viewElt.controllers.appendController(this._controller); +} + +PlacesViewBase.prototype = { + // The xul element that holds the entire view. + _viewElt: null, + get viewElt() { + return this._viewElt; + }, + + get associatedElement() { + return this._viewElt; + }, + + get controllers() { + return this._viewElt.controllers; + }, + + // The xul element that represents the root container. + _rootElt: null, + + // Set to true for views that are represented by native widgets (i.e. + // the native mac menu). + _nativeView: false, + + QueryInterface: XPCOMUtils.generateQI( + [Ci.nsINavHistoryResultObserver, + Ci.nsISupportsWeakReference]), + + _place: "", + get place() { + return this._place; + }, + set place(val) { + this._place = val; + + let history = PlacesUtils.history; + let queries = { }, options = { }; + history.queryStringToQueries(val, queries, { }, options); + if (!queries.value.length) + queries.value = [history.getNewQuery()]; + + let result = history.executeQueries(queries.value, queries.value.length, + options.value); + result.addObserver(this); + return val; + }, + + _result: null, + get result() { + return this._result; + }, + set result(val) { + if (this._result == val) + return val; + + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + } + + if (this._rootElt.localName == "menupopup") + this._rootElt._built = false; + + this._result = val; + if (val) { + this._resultNode = val.root; + this._rootElt._placesNode = this._resultNode; + this._domNodes = new Map(); + this._domNodes.set(this._resultNode, this._rootElt); + + // This calls _rebuild through invalidateContainer. + this._resultNode.containerOpen = true; + } else { + this._resultNode = null; + delete this._domNodes; + } + + return val; + }, + + _options: null, + get options() { + return this._options; + }, + set options(val) { + if (!val) + val = {}; + + if (!("extraClasses" in val)) + val.extraClasses = {}; + this._options = val; + + return val; + }, + + /** + * Gets the DOM node used for the given places node. + * + * @param aPlacesNode + * a places result node. + * @param aAllowMissing + * whether the node may be missing + * @throws if there is no DOM node set for aPlacesNode. + */ + _getDOMNodeForPlacesNode: + function PVB__getDOMNodeForPlacesNode(aPlacesNode, aAllowMissing = false) { + let node = this._domNodes.get(aPlacesNode, null); + if (!node && !aAllowMissing) { + throw new Error("No DOM node set for aPlacesNode.\nnode.type: " + + aPlacesNode.type + ". node.parent: " + aPlacesNode); + } + return node; + }, + + get controller() { + return this._controller; + }, + + get selType() { + return "single"; + }, + selectItems() { }, + selectAll() { }, + + get selectedNode() { + if (this._contextMenuShown) { + let anchor = this._contextMenuShown.triggerNode; + if (!anchor) + return null; + + if (anchor._placesNode) + return this._rootElt == anchor ? null : anchor._placesNode; + + anchor = anchor.parentNode; + return this._rootElt == anchor ? null : (anchor._placesNode || null); + } + return null; + }, + + get hasSelection() { + return this.selectedNode != null; + }, + + get selectedNodes() { + let selectedNode = this.selectedNode; + return selectedNode ? [selectedNode] : []; + }, + + get removableSelectionRanges() { + // On static content the current selectedNode would be the selection's + // parent node. We don't want to allow removing a node when the + // selection is not explicit. + if (document.popupNode && + (document.popupNode == "menupopup" || !document.popupNode._placesNode)) + return []; + + return [this.selectedNodes]; + }, + + get draggableSelection() { + return [this._draggedElt]; + }, + + get insertionPoint() { + // There is no insertion point for history queries, so bail out now and + // save a lot of work when updating commands. + let resultNode = this._resultNode; + if (PlacesUtils.nodeIsQuery(resultNode) && + PlacesUtils.asQuery(resultNode).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) + return null; + + // By default, the insertion point is at the top level, at the end. + let index = PlacesUtils.bookmarks.DEFAULT_INDEX; + let container = this._resultNode; + let orientation = Ci.nsITreeView.DROP_BEFORE; + let tagName = null; + + let selectedNode = this.selectedNode; + if (selectedNode) { + let popup = document.popupNode; + if (!popup._placesNode || popup._placesNode == this._resultNode || + popup._placesNode.itemId == -1 || !selectedNode.parent) { + // If a static menuitem is selected, or if the root node is selected, + // the insertion point is inside the folder, at the end. + container = selectedNode; + orientation = Ci.nsITreeView.DROP_ON; + } else { + // In all other cases the insertion point is before that node. + container = selectedNode.parent; + index = container.getChildIndex(selectedNode); + if (PlacesUtils.nodeIsTagQuery(container)) { + tagName = container.title; + // TODO (Bug 1160193): properly support dropping on a tag root. + if (!tagName) + return null; + } + } + } + + if (this.controller.disallowInsertion(container)) + return null; + + return new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(container), + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, orientation, tagName + }); + }, + + buildContextMenu: function PVB_buildContextMenu(aPopup) { + this._contextMenuShown = aPopup; + window.updateCommands("places"); + return this.controller.buildContextMenu(aPopup); + }, + + destroyContextMenu: function PVB_destroyContextMenu(aPopup) { + this._contextMenuShown = null; + }, + + clearAllContents(aPopup) { + let kid = aPopup.firstChild; + while (kid) { + let next = kid.nextSibling; + if (!kid.classList.contains("panel-header")) { + kid.remove(); + } + kid = next; + } + aPopup._emptyMenuitem = aPopup._startMarker = aPopup._endMarker = null; + }, + + _cleanPopup: function PVB_cleanPopup(aPopup, aDelay) { + // Ensure markers are here when `invalidateContainer` is called before the + // popup is shown, which may the case for panelviews, for example. + this._ensureMarkers(aPopup); + // Remove Places nodes from the popup. + let child = aPopup._startMarker; + while (child.nextSibling != aPopup._endMarker) { + let sibling = child.nextSibling; + if (sibling._placesNode && !aDelay) { + aPopup.removeChild(sibling); + } else if (sibling._placesNode && aDelay) { + // HACK (bug 733419): the popups originating from the OS X native + // menubar don't live-update while open, thus we don't clean it + // until the next popupshowing, to avoid zombie menuitems. + if (!aPopup._delayedRemovals) + aPopup._delayedRemovals = []; + aPopup._delayedRemovals.push(sibling); + child = child.nextSibling; + } else { + child = child.nextSibling; + } + } + }, + + _rebuildPopup: function PVB__rebuildPopup(aPopup) { + let resultNode = aPopup._placesNode; + if (!resultNode.containerOpen) + return; + + if (this.controller.hasCachedLivemarkInfo(resultNode)) { + this._setEmptyPopupStatus(aPopup, false); + aPopup._built = true; + this._populateLivemarkPopup(aPopup); + return; + } + + this._cleanPopup(aPopup); + + let cc = resultNode.childCount; + if (cc > 0) { + this._setEmptyPopupStatus(aPopup, false); + let fragment = document.createDocumentFragment(); + for (let i = 0; i < cc; ++i) { + let child = resultNode.getChild(i); + this._insertNewItemToPopup(child, fragment); + } + aPopup.insertBefore(fragment, aPopup._endMarker); + } else { + this._setEmptyPopupStatus(aPopup, true); + } + aPopup._built = true; + }, + + _removeChild: function PVB__removeChild(aChild) { + // If document.popupNode pointed to this child, null it out, + // otherwise controller's command-updating may rely on the removed + // item still being "selected". + if (document.popupNode == aChild) + document.popupNode = null; + + aChild.remove(); + }, + + _setEmptyPopupStatus: + function PVB__setEmptyPopupStatus(aPopup, aEmpty) { + if (!aPopup._emptyMenuitem) { + let label = PlacesUIUtils.getString("bookmarksMenuEmptyFolder"); + aPopup._emptyMenuitem = document.createElement("menuitem"); + aPopup._emptyMenuitem.setAttribute("label", label); + aPopup._emptyMenuitem.setAttribute("disabled", true); + aPopup._emptyMenuitem.className = "bookmark-item"; + if (typeof this.options.extraClasses.entry == "string") + aPopup._emptyMenuitem.classList.add(this.options.extraClasses.entry); + } + + if (aEmpty) { + aPopup.setAttribute("emptyplacesresult", "true"); + // Don't add the menuitem if there is static content. + if (!aPopup._startMarker.previousSibling && + !aPopup._endMarker.nextSibling) + aPopup.insertBefore(aPopup._emptyMenuitem, aPopup._endMarker); + } else { + aPopup.removeAttribute("emptyplacesresult"); + try { + aPopup.removeChild(aPopup._emptyMenuitem); + } catch (ex) {} + } + }, + + _createDOMNodeForPlacesNode: + function PVB__createDOMNodeForPlacesNode(aPlacesNode) { + this._domNodes.delete(aPlacesNode); + + let element; + let type = aPlacesNode.type; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + element = document.createElement("menuseparator"); + element.setAttribute("class", "small-separator"); + } else { + let itemId = aPlacesNode.itemId; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) { + element = document.createElement("menuitem"); + element.className = "menuitem-iconic bookmark-item menuitem-with-favicon"; + element.setAttribute("scheme", + PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri)); + } else if (PlacesUtils.containerTypes.includes(type)) { + element = document.createElement("menu"); + element.setAttribute("container", "true"); + + if (aPlacesNode.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + element.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aPlacesNode)) + element.setAttribute("tagContainer", "true"); + else if (PlacesUtils.nodeIsDay(aPlacesNode)) + element.setAttribute("dayContainer", "true"); + else if (PlacesUtils.nodeIsHost(aPlacesNode)) + element.setAttribute("hostContainer", "true"); + } else if (itemId != -1) { + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + element.setAttribute("livemark", "true"); + if (AppConstants.platform === "macosx") { + // OS X native menubar doesn't track list-style-images since + // it doesn't have a frame (bug 733415). Thus enforce updating. + element.setAttribute("image", ""); + element.removeAttribute("image"); + } + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + }, () => undefined); + } + + let popup = document.createElement("menupopup"); + popup._placesNode = PlacesUtils.asContainer(aPlacesNode); + + if (!this._nativeView) { + popup.setAttribute("placespopup", "true"); + } + + element.appendChild(popup); + element.className = "menu-iconic bookmark-item"; + if (typeof this.options.extraClasses.entry == "string") { + element.classList.add(this.options.extraClasses.entry); + } + + this._domNodes.set(aPlacesNode, popup); + } else + throw "Unexpected node"; + + element.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + + let icon = aPlacesNode.icon; + if (icon) + element.setAttribute("image", icon); + } + + element._placesNode = aPlacesNode; + if (!this._domNodes.has(aPlacesNode)) + this._domNodes.set(aPlacesNode, element); + + return element; + }, + + _insertNewItemToPopup: + function PVB__insertNewItemToPopup(aNewChild, aInsertionNode, aBefore = null) { + let element = this._createDOMNodeForPlacesNode(aNewChild); + + if (element.localName == "menuitem" || element.localName == "menu") { + if (typeof this.options.extraClasses.entry == "string") + element.classList.add(this.options.extraClasses.entry); + } + + aInsertionNode.insertBefore(element, aBefore); + return element; + }, + + _setLivemarkSiteURIMenuItem: + function PVB__setLivemarkSiteURIMenuItem(aPopup) { + let livemarkInfo = this.controller.getCachedLivemarkInfo(aPopup._placesNode); + let siteUrl = livemarkInfo && livemarkInfo.siteURI ? + livemarkInfo.siteURI.spec : null; + if (!siteUrl && aPopup._siteURIMenuitem) { + aPopup.removeChild(aPopup._siteURIMenuitem); + aPopup._siteURIMenuitem = null; + aPopup.removeChild(aPopup._siteURIMenuseparator); + aPopup._siteURIMenuseparator = null; + } else if (siteUrl && !aPopup._siteURIMenuitem) { + // Add "Open (Feed Name)" menuitem. + aPopup._siteURIMenuitem = document.createElement("menuitem"); + aPopup._siteURIMenuitem.className = "openlivemarksite-menuitem"; + if (typeof this.options.extraClasses.entry == "string") { + aPopup._siteURIMenuitem.classList.add(this.options.extraClasses.entry); + } + aPopup._siteURIMenuitem.setAttribute("targetURI", siteUrl); + aPopup._siteURIMenuitem.setAttribute("oncommand", + "openUILink(this.getAttribute('targetURI'), event);"); + + // If a user middle-clicks this item we serve the oncommand event. + // We are using checkForMiddleClick because of Bug 246720. + // Note: stopPropagation is needed to avoid serving middle-click + // with BT_onClick that would open all items in tabs. + aPopup._siteURIMenuitem.setAttribute("onclick", + "checkForMiddleClick(this, event); event.stopPropagation();"); + let label = + PlacesUIUtils.getFormattedString("menuOpenLivemarkOrigin.label", + [aPopup.parentNode.getAttribute("label")]); + aPopup._siteURIMenuitem.setAttribute("label", label); + aPopup.insertBefore(aPopup._siteURIMenuitem, aPopup._startMarker); + + aPopup._siteURIMenuseparator = document.createElement("menuseparator"); + aPopup.insertBefore(aPopup._siteURIMenuseparator, aPopup._startMarker); + } + }, + + /** + * Add, update or remove the livemark status menuitem. + * @param aPopup + * The livemark container popup + * @param aStatus + * The livemark status + */ + _setLivemarkStatusMenuItem: + function PVB_setLivemarkStatusMenuItem(aPopup, aStatus) { + let statusMenuitem = aPopup._statusMenuitem; + if (!statusMenuitem) { + // Create the status menuitem and cache it in the popup object. + statusMenuitem = document.createElement("menuitem"); + statusMenuitem.className = "livemarkstatus-menuitem"; + if (typeof this.options.extraClasses.entry == "string") { + statusMenuitem.classList.add(this.options.extraClasses.entry); + } + statusMenuitem.setAttribute("disabled", true); + aPopup._statusMenuitem = statusMenuitem; + } + + if (aStatus == Ci.mozILivemark.STATUS_LOADING || + aStatus == Ci.mozILivemark.STATUS_FAILED) { + // Status has changed, update the cached status menuitem. + let stringId = aStatus == Ci.mozILivemark.STATUS_LOADING ? + "bookmarksLivemarkLoading" : "bookmarksLivemarkFailed"; + statusMenuitem.setAttribute("label", PlacesUIUtils.getString(stringId)); + if (aPopup._startMarker.nextSibling != statusMenuitem) + aPopup.insertBefore(statusMenuitem, aPopup._startMarker.nextSibling); + } else if (aPopup._statusMenuitem.parentNode == aPopup) { + // The livemark has finished loading. + aPopup.removeChild(aPopup._statusMenuitem); + } + }, + + toggleCutNode: function PVB_toggleCutNode(aPlacesNode, aValue) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // We may get the popup for menus, but we need the menu itself. + if (elt.localName == "menupopup") + elt = elt.parentNode; + if (aValue) + elt.setAttribute("cutting", "true"); + else + elt.removeAttribute("cutting"); + }, + + nodeURIChanged: function PVB_nodeURIChanged(aPlacesNode, aURIString) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + elt.setAttribute("scheme", PlacesUIUtils.guessUrlSchemeForUI(aPlacesNode.uri)); + }, + + nodeIconChanged: function PVB_nodeIconChanged(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's nothing to + // be done when the icon changes. + if (elt == this._rootElt) + return; + + // Here we need the <menu>. + if (elt.localName == "menupopup") { + elt = elt.parentNode; + } + // We must remove and reset the attribute to force an update. + elt.removeAttribute("image"); + elt.setAttribute("image", aPlacesNode.icon); + }, + + nodeAnnotationChanged: + function PVB_nodeAnnotationChanged(aPlacesNode, aAnno) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // All livemarks have a feedURI, so use it as our indicator of a livemark + // being modified. + if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + let menu = elt.parentNode; + if (!menu.hasAttribute("livemark")) { + menu.setAttribute("livemark", "true"); + if (AppConstants.platform === "macosx") { + // OS X native menubar doesn't track list-style-images since + // it doesn't have a frame (bug 733415). Thus enforce updating. + menu.setAttribute("image", ""); + menu.removeAttribute("image"); + } + } + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + // Controller will use this to build the meta data for the node. + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + this.invalidateContainer(aPlacesNode); + }, () => undefined); + } + }, + + nodeTitleChanged: + function PVB_nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node, thus there's + // nothing to be done when the title changes. + if (elt == this._rootElt) + return; + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (!aNewTitle && elt.localName != "toolbarbutton") { + // Many users consider toolbars as shortcuts containers, so explicitly + // allow empty labels on toolbarbuttons. For any other element try to be + // smarter, guessing a title from the uri. + elt.setAttribute("label", PlacesUIUtils.getBestTitle(aPlacesNode)); + } else { + elt.setAttribute("label", aNewTitle); + } + }, + + nodeRemoved: + function PVB_nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (parentElt._built) { + parentElt.removeChild(elt); + + // Figure out if we need to show the "<Empty>" menu-item. + // TODO Bug 517701: This doesn't seem to handle the case of an empty + // root. + if (parentElt._startMarker.nextSibling == parentElt._endMarker) + this._setEmptyPopupStatus(parentElt, true); + } + }, + + nodeHistoryDetailsChanged: + function PVB_nodeHistoryDetailsChanged(aPlacesNode, aTime, aCount) { + if (aPlacesNode.parent && + this.controller.hasCachedLivemarkInfo(aPlacesNode.parent)) { + // Find the node in the parent. + let popup = this._getDOMNodeForPlacesNode(aPlacesNode.parent); + for (let child = popup._startMarker.nextSibling; + child != popup._endMarker; + child = child.nextSibling) { + if (child._placesNode && child._placesNode.uri == aPlacesNode.uri) { + if (aPlacesNode.accessCount) + child.setAttribute("visited", "true"); + else + child.removeAttribute("visited"); + break; + } + } + } + }, + + nodeTagsChanged() { }, + nodeDateAddedChanged() { }, + nodeLastModifiedChanged() { }, + nodeKeywordChanged() { }, + sortingChanged() { }, + batching() { }, + + nodeInserted: + function PVB_nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (!parentElt._built) + return; + + let index = Array.prototype.indexOf.call(parentElt.childNodes, parentElt._startMarker) + + aIndex + 1; + this._insertNewItemToPopup(aPlacesNode, parentElt, + parentElt.childNodes[index] || parentElt._endMarker); + this._setEmptyPopupStatus(parentElt, false); + }, + + nodeMoved: + function PBV_nodeMoved(aPlacesNode, + aOldParentPlacesNode, aOldIndex, + aNewParentPlacesNode, aNewIndex) { + // Note: the current implementation of moveItem does not actually + // use this notification when the item in question is moved from one + // folder to another. Instead, it calls nodeRemoved and nodeInserted + // for the two folders. Thus, we can assume old-parent == new-parent. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + // If our root node is a folder, it might be moved. There's nothing + // we need to do in that case. + if (elt == this._rootElt) + return; + + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt._built) { + // Move the node. + parentElt.removeChild(elt); + let index = Array.prototype.indexOf.call(parentElt.childNodes, parentElt._startMarker) + + aNewIndex + 1; + parentElt.insertBefore(elt, parentElt.childNodes[index]); + } + }, + + containerStateChanged: + function PVB_containerStateChanged(aPlacesNode, aOldState, aNewState) { + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED || + aNewState == Ci.nsINavHistoryContainerResultNode.STATE_CLOSED) { + this.invalidateContainer(aPlacesNode); + + if (PlacesUtils.nodeIsFolder(aPlacesNode)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (queryOptions.excludeItems) { + return; + } + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + let shouldInvalidate = + !this.controller.hasCachedLivemarkInfo(aPlacesNode); + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) { + aLivemark.registerForUpdates(aPlacesNode, this); + // Prioritize the current livemark. + aLivemark.reload(); + PlacesUtils.livemarks.reloadLivemarks(); + if (shouldInvalidate) + this.invalidateContainer(aPlacesNode); + } else { + aLivemark.unregisterForUpdates(aPlacesNode); + } + }, () => undefined); + } + } + }, + + _populateLivemarkPopup: function PVB__populateLivemarkPopup(aPopup) { + this._setLivemarkSiteURIMenuItem(aPopup); + // Show the loading status only if there are no entries yet. + if (aPopup._startMarker.nextSibling == aPopup._endMarker) + this._setLivemarkStatusMenuItem(aPopup, Ci.mozILivemark.STATUS_LOADING); + + PlacesUtils.livemarks.getLivemark({ id: aPopup._placesNode.itemId }) + .then(aLivemark => { + let placesNode = aPopup._placesNode; + if (!placesNode.containerOpen) + return; + + if (aLivemark.status != Ci.mozILivemark.STATUS_LOADING) + this._setLivemarkStatusMenuItem(aPopup, aLivemark.status); + this._cleanPopup(aPopup, + this._nativeView && aPopup.parentNode.hasAttribute("open")); + + let children = aLivemark.getNodesForContainer(placesNode); + for (let i = 0; i < children.length; i++) { + let child = children[i]; + this.nodeInserted(placesNode, child, i); + if (child.accessCount) + this._getDOMNodeForPlacesNode(child).setAttribute("visited", true); + else + this._getDOMNodeForPlacesNode(child).removeAttribute("visited"); + } + }, Cu.reportError); + }, + + /** + * Checks whether the popup associated with the provided element is open. + * This method may be overridden by classes that extend this base class. + * + * @param {Element} elt + * @return {Boolean} + */ + _isPopupOpen(elt) { + return !!elt.parentNode.open; + }, + + invalidateContainer: function PVB_invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + elt._built = false; + + // If the menupopup is open we should live-update it. + if (this._isPopupOpen(elt)) + this._rebuildPopup(elt); + }, + + uninit: function PVB_uninit() { + if (this._result) { + this._result.removeObserver(this); + this._resultNode.containerOpen = false; + this._resultNode = null; + this._result = null; + } + + if (this._controller) { + this._controller.terminate(); + // Removing the controller will fail if it is already no longer there. + // This can happen if the view element was removed/reinserted without + // our knowledge. There is no way to check for that having happened + // without the possibility of an exception. :-( + try { + this._viewElt.controllers.removeController(this._controller); + } catch (ex) { + } finally { + this._controller = null; + } + } + + delete this._viewElt._placesView; + }, + + get isRTL() { + if ("_isRTL" in this) + return this._isRTL; + + return this._isRTL = document.defaultView + .getComputedStyle(this.viewElt) + .direction == "rtl"; + }, + + get ownerWindow() { + return window; + }, + + /** + * Adds an "Open All in Tabs" menuitem to the bottom of the popup. + * @param aPopup + * a Places popup. + */ + _mayAddCommandsItems: function PVB__mayAddCommandsItems(aPopup) { + // The command items are never added to the root popup. + if (aPopup == this._rootElt) + return; + + let hasMultipleURIs = false; + + // Check if the popup contains at least 2 menuitems with places nodes. + // We don't currently support opening multiple uri nodes when they are not + // populated by the result. + if (aPopup._placesNode.childCount > 0) { + let currentChild = aPopup.firstChild; + let numURINodes = 0; + while (currentChild) { + if (currentChild.localName == "menuitem" && currentChild._placesNode) { + if (++numURINodes == 2) + break; + } + currentChild = currentChild.nextSibling; + } + hasMultipleURIs = numURINodes > 1; + } + + let isLiveMark = false; + if (this.controller.hasCachedLivemarkInfo(aPopup._placesNode)) { + hasMultipleURIs = true; + isLiveMark = true; + } + + if (!hasMultipleURIs) { + aPopup.setAttribute("singleitempopup", "true"); + } else { + aPopup.removeAttribute("singleitempopup"); + } + + if (!hasMultipleURIs) { + // We don't have to show any option. + if (aPopup._endOptOpenAllInTabs) { + aPopup.removeChild(aPopup._endOptOpenAllInTabs); + aPopup._endOptOpenAllInTabs = null; + + aPopup.removeChild(aPopup._endOptSeparator); + aPopup._endOptSeparator = null; + } + } else if (!aPopup._endOptOpenAllInTabs) { + // Create a separator before options. + aPopup._endOptSeparator = document.createElement("menuseparator"); + aPopup._endOptSeparator.className = "bookmarks-actions-menuseparator"; + aPopup.appendChild(aPopup._endOptSeparator); + + // Add the "Open All in Tabs" menuitem. + aPopup._endOptOpenAllInTabs = document.createElement("menuitem"); + aPopup._endOptOpenAllInTabs.className = "openintabs-menuitem"; + + if (typeof this.options.extraClasses.entry == "string") + aPopup._endOptOpenAllInTabs.classList.add(this.options.extraClasses.entry); + if (typeof this.options.extraClasses.footer == "string") + aPopup._endOptOpenAllInTabs.classList.add(this.options.extraClasses.footer); + + if (isLiveMark) { + aPopup._endOptOpenAllInTabs.setAttribute("oncommand", + "PlacesUIUtils.openLiveMarkNodesInTabs(this.parentNode._placesNode, event, " + + "PlacesUIUtils.getViewForNode(this));"); + } else { + aPopup._endOptOpenAllInTabs.setAttribute("oncommand", + "PlacesUIUtils.openContainerNodeInTabs(this.parentNode._placesNode, event, " + + "PlacesUIUtils.getViewForNode(this));"); + } + aPopup._endOptOpenAllInTabs.setAttribute("onclick", + "checkForMiddleClick(this, event); event.stopPropagation();"); + aPopup._endOptOpenAllInTabs.setAttribute("label", + gNavigatorBundle.getString("menuOpenAllInTabs.label")); + aPopup.appendChild(aPopup._endOptOpenAllInTabs); + } + }, + + _ensureMarkers: function PVB__ensureMarkers(aPopup) { + if (aPopup._startMarker) + return; + + // _startMarker is an hidden menuseparator that lives before places nodes. + aPopup._startMarker = document.createElement("menuseparator"); + aPopup._startMarker.hidden = true; + aPopup.insertBefore(aPopup._startMarker, aPopup.firstChild); + + // _endMarker is a DOM node that lives after places nodes, specified with + // the 'insertionPoint' option or will be a hidden menuseparator. + let node = this.options.insertionPoint ? + aPopup.querySelector(this.options.insertionPoint) : null; + if (node) { + aPopup._endMarker = node; + } else { + aPopup._endMarker = document.createElement("menuseparator"); + aPopup._endMarker.hidden = true; + } + aPopup.appendChild(aPopup._endMarker); + + // Move the markers to the right position. + let firstNonStaticNodeFound = false; + for (let i = 0; i < aPopup.childNodes.length; i++) { + let child = aPopup.childNodes[i]; + // Menus that have static content at the end, but are initially empty, + // use a special "builder" attribute to figure out where to start + // inserting places nodes. + if (child.getAttribute("builder") == "end") { + aPopup.insertBefore(aPopup._endMarker, child); + break; + } + + if (child._placesNode && !firstNonStaticNodeFound) { + firstNonStaticNodeFound = true; + aPopup.insertBefore(aPopup._startMarker, child); + } + } + if (!firstNonStaticNodeFound) { + aPopup.insertBefore(aPopup._startMarker, aPopup._endMarker); + } + }, + + _onPopupShowing: function PVB__onPopupShowing(aEvent) { + // Avoid handling popupshowing of inner views. + let popup = aEvent.originalTarget; + + this._ensureMarkers(popup); + + // Remove any delayed element, see _cleanPopup for details. + if ("_delayedRemovals" in popup) { + while (popup._delayedRemovals.length > 0) { + popup.removeChild(popup._delayedRemovals.shift()); + } + } + + if (popup._placesNode && PlacesUIUtils.getViewForNode(popup) == this) { + if (!popup._placesNode.containerOpen) + popup._placesNode.containerOpen = true; + if (!popup._built) + this._rebuildPopup(popup); + + this._mayAddCommandsItems(popup); + } + }, + + _addEventListeners: + function PVB__addEventListeners(aObject, aEventNames, aCapturing = false) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.addEventListener(aEventNames[i], this, aCapturing); + } + }, + + _removeEventListeners: + function PVB__removeEventListeners(aObject, aEventNames, aCapturing = false) { + for (let i = 0; i < aEventNames.length; i++) { + aObject.removeEventListener(aEventNames[i], this, aCapturing); + } + }, +}; + +function PlacesToolbar(aPlace) { + let startTime = Date.now(); + // Add some smart getters for our elements. + let thisView = this; + [ + ["_viewElt", "PlacesToolbar"], + ["_rootElt", "PlacesToolbarItems"], + ["_dropIndicator", "PlacesToolbarDropIndicator"], + ["_chevron", "PlacesChevron"], + ["_chevronPopup", "PlacesChevronPopup"] + ].forEach(function(elementGlobal) { + let [name, id] = elementGlobal; + thisView.__defineGetter__(name, function() { + let element = document.getElementById(id); + if (!element) + return null; + + delete thisView[name]; + return thisView[name] = element; + }); + }); + + this._viewElt._placesView = this; + + this._addEventListeners(this._viewElt, this._cbEvents, false); + this._addEventListeners(this._rootElt, ["popupshowing", "popuphidden"], true); + this._addEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._addEventListeners(window, ["resize", "unload"], false); + + // If personal-bookmarks has been dragged to the tabs toolbar, + // we have to track addition and removals of tabs, to properly + // recalculate the available space for bookmarks. + // TODO (bug 734730): Use a performant mutation listener when available. + if (this._viewElt.parentNode.parentNode == document.getElementById("TabsToolbar")) { + this._addEventListeners(gBrowser.tabContainer, ["TabOpen", "TabClose"], false); + } + + PlacesViewBase.call(this, aPlace); +} + +PlacesToolbar.prototype = { + __proto__: PlacesViewBase.prototype, + + _cbEvents: ["dragstart", "dragover", "dragexit", "dragend", "drop", + "mousemove", "mouseover", "mouseout"], + + QueryInterface: function PT_QueryInterface(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsITimerCallback)) + return this; + + return PlacesViewBase.prototype.QueryInterface.apply(this, arguments); + }, + + uninit: function PT_uninit() { + this._removeEventListeners(this._viewElt, this._cbEvents, false); + this._removeEventListeners(this._rootElt, ["popupshowing", "popuphidden"], + true); + this._removeEventListeners(this._rootElt, ["overflow", "underflow"], true); + this._removeEventListeners(window, ["resize", "unload"], false); + this._removeEventListeners(gBrowser.tabContainer, ["TabOpen", "TabClose"], false); + + if (this._chevron._placesView) { + this._chevron._placesView.uninit(); + } + + PlacesViewBase.prototype.uninit.apply(this, arguments); + }, + + _openedMenuButton: null, + _allowPopupShowing: true, + + _rebuild: function PT__rebuild() { + // Clear out references to existing nodes, since they will be removed + // and re-added. + if (this._overFolder.elt) + this._clearOverFolder(); + + this._openedMenuButton = null; + while (this._rootElt.hasChildNodes()) { + this._rootElt.firstChild.remove(); + } + + let fragment = document.createDocumentFragment(); + let cc = this._resultNode.childCount; + if (cc > 0) { + // There could be a lot of nodes, but we only want to build the ones that + // are likely to be shown, not all of them. Then we'll lazily create the + // missing nodes when needed. + // We don't want to cause reflows at every node insertion to calculate + // a precise size, thus we guess a size from the first node. + let button = this._insertNewItem(this._resultNode.getChild(0), + this._rootElt); + requestAnimationFrame(() => { + // May have been destroyed in the meanwhile. + if (!this._resultNode || !this._rootElt) + return; + // We assume a button with just the icon will be more or less a square, + // then compensate the measurement error by considering a larger screen + // width. Moreover the window could be bigger than the screen. + let size = button.clientHeight; + let limit = Math.min(cc, parseInt((window.screen.width * 1.5) / size)); + for (let i = 1; i < limit; ++i) { + this._insertNewItem(this._resultNode.getChild(i), fragment); + } + this._rootElt.appendChild(fragment); + + this.updateNodesVisibility(); + }); + } + + if (this._chevronPopup.hasAttribute("type")) { + // Chevron has already been initialized, but since we are forcing + // a rebuild of the toolbar, it has to be rebuilt. + // Otherwise, it will be initialized when the toolbar overflows. + this._chevronPopup.place = this.place; + } + }, + + _insertNewItem: + function PT__insertNewItem(aChild, aInsertionNode, aBefore = null) { + this._domNodes.delete(aChild); + + let type = aChild.type; + let button; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + button = document.createElement("toolbarseparator"); + } else { + button = document.createElement("toolbarbutton"); + button.className = "bookmark-item"; + button.setAttribute("label", aChild.title || ""); + + if (PlacesUtils.containerTypes.includes(type)) { + button.setAttribute("type", "menu"); + button.setAttribute("container", "true"); + + if (PlacesUtils.nodeIsQuery(aChild)) { + button.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aChild)) + button.setAttribute("tagContainer", "true"); + } else if (PlacesUtils.nodeIsFolder(aChild)) { + PlacesUtils.livemarks.getLivemark({ id: aChild.itemId }) + .then(aLivemark => { + button.setAttribute("livemark", "true"); + this.controller.cacheLivemarkInfo(aChild, aLivemark); + }, () => undefined); + } + + let popup = document.createElement("menupopup"); + popup.setAttribute("placespopup", "true"); + button.appendChild(popup); + popup._placesNode = PlacesUtils.asContainer(aChild); + popup.setAttribute("context", "placesContext"); + + this._domNodes.set(aChild, popup); + } else if (PlacesUtils.nodeIsURI(aChild)) { + button.setAttribute("scheme", + PlacesUIUtils.guessUrlSchemeForUI(aChild.uri)); + } + } + + button._placesNode = aChild; + if (!this._domNodes.has(aChild)) + this._domNodes.set(aChild, button); + + if (aBefore) + aInsertionNode.insertBefore(button, aBefore); + else + aInsertionNode.appendChild(button); + return button; + }, + + _updateChevronPopupNodesVisibility: + function PT__updateChevronPopupNodesVisibility() { + // Note the toolbar by default builds less nodes than the chevron popup. + for (let toolbarNode = this._rootElt.firstChild, + node = this._chevronPopup._startMarker.nextSibling; + toolbarNode && node; + toolbarNode = toolbarNode.nextSibling, node = node.nextSibling) { + node.hidden = toolbarNode.style.visibility != "hidden"; + } + }, + + _onChevronPopupShowing: + function PT__onChevronPopupShowing(aEvent) { + // Handle popupshowing only for the chevron popup, not for nested ones. + if (aEvent.target != this._chevronPopup) + return; + + if (!this._chevron._placesView) + this._chevron._placesView = new PlacesMenu(aEvent, this.place); + + this._updateChevronPopupNodesVisibility(); + }, + + handleEvent: function PT_handleEvent(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "resize": + // This handler updates nodes visibility in both the toolbar + // and the chevron popup when a window resize does not change + // the overflow status of the toolbar. + if (aEvent.target == aEvent.currentTarget) { + this.updateNodesVisibility(); + } + break; + case "overflow": + if (!this._isOverflowStateEventRelevant(aEvent)) + return; + this._onOverflow(); + break; + case "underflow": + if (!this._isOverflowStateEventRelevant(aEvent)) + return; + this._onUnderflow(); + break; + case "TabOpen": + case "TabClose": + this.updateNodesVisibility(); + break; + case "dragstart": + this._onDragStart(aEvent); + break; + case "dragover": + this._onDragOver(aEvent); + break; + case "dragexit": + this._onDragExit(aEvent); + break; + case "dragend": + this._onDragEnd(aEvent); + break; + case "drop": + this._onDrop(aEvent); + break; + case "mouseover": + this._onMouseOver(aEvent); + break; + case "mousemove": + this._onMouseMove(aEvent); + break; + case "mouseout": + this._onMouseOut(aEvent); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + default: + throw "Trying to handle unexpected event."; + } + }, + + _isOverflowStateEventRelevant: function PT_isOverflowStateEventRelevant(aEvent) { + // Ignore events not aimed at ourselves, as well as purely vertical ones: + return aEvent.target == aEvent.currentTarget && aEvent.detail > 0; + }, + + _onOverflow: function PT_onOverflow() { + // Attach the popup binding to the chevron popup if it has not yet + // been initialized. + if (!this._chevronPopup.hasAttribute("type")) { + this._chevronPopup.setAttribute("place", this.place); + this._chevronPopup.setAttribute("type", "places"); + } + this._chevron.collapsed = false; + this.updateNodesVisibility(); + }, + + _onUnderflow: function PT_onUnderflow() { + this.updateNodesVisibility(); + this._chevron.collapsed = true; + }, + + updateNodesVisibility: function PT_updateNodesVisibility() { + // Update the chevron on a timer. This will avoid repeated work when + // lot of changes happen in a small timeframe. + if (this._updateNodesVisibilityTimer) + this._updateNodesVisibilityTimer.cancel(); + + this._updateNodesVisibilityTimer = this._setTimer(100); + }, + + _updateNodesVisibilityTimerCallback: function PT__updateNodesVisibilityTimerCallback() { + let scrollRect = this._rootElt.getBoundingClientRect(); + let childOverflowed = false; + for (let child of this._rootElt.childNodes) { + // Once a child overflows, all the next ones will. + if (!childOverflowed) { + let childRect = child.getBoundingClientRect(); + childOverflowed = this.isRTL ? (childRect.left < scrollRect.left) + : (childRect.right > scrollRect.right); + } + + if (childOverflowed) { + child.removeAttribute("image"); + child.style.visibility = "hidden"; + } else { + let icon = child._placesNode.icon; + if (icon) + child.setAttribute("image", icon); + child.style.visibility = "visible"; + } + } + + // We rebuild the chevron on popupShowing, so if it is open + // we must update it. + if (!this._chevron.collapsed && this._chevron.open) + this._updateChevronPopupNodesVisibility(); + let event = new CustomEvent("BookmarksToolbarVisibilityUpdated", {bubbles: true}); + this._viewElt.dispatchEvent(event); + }, + + nodeInserted: + function PT_nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { // Node is on the toolbar. + let children = this._rootElt.childNodes; + // Nothing to do if it's a never-visible node, but note it's possible + // we are appending. + if (aIndex > children.length) + return; + + // Note that childCount is already accounting for the node being added, + // thus we must subtract one node from it. + if (this._resultNode.childCount - 1 > children.length) { + if (aIndex == children.length) { + // If we didn't build all the nodes and new node is being appended, + // we can skip it as well. + return; + } + // Keep the number of built nodes consistent. + this._rootElt.removeChild(this._rootElt.lastChild); + } + + let button = this._insertNewItem(aPlacesNode, this._rootElt, + children[aIndex] || null); + let prevSiblingOverflowed = aIndex > 0 && aIndex <= children.length && + children[aIndex - 1].style.visibility == "hidden"; + if (prevSiblingOverflowed) { + button.style.visibility = "hidden"; + } else { + let icon = aPlacesNode.icon; + if (icon) + button.setAttribute("image", icon); + this.updateNodesVisibility(); + } + return; + } + + PlacesViewBase.prototype.nodeInserted.apply(this, arguments); + }, + + nodeRemoved: + function PT_nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt == this._rootElt) { // Node is on the toolbar. + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt) + return; + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + let overflowed = elt.style.visibility == "hidden"; + this._removeChild(elt); + if (this._resultNode.childCount > this._rootElt.childNodes.length) { + // A new node should be built to keep a coherent number of children. + this._insertNewItem(this._resultNode.getChild(this._rootElt.childNodes.length), + this._rootElt); + } + if (!overflowed) + this.updateNodesVisibility(); + return; + } + + PlacesViewBase.prototype.nodeRemoved.apply(this, arguments); + }, + + nodeMoved: + function PT_nodeMoved(aPlacesNode, + aOldParentPlacesNode, aOldIndex, + aNewParentPlacesNode, aNewIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt == this._rootElt) { // Node is on the toolbar. + // Do nothing if the node will never be visible. + let lastBuiltIndex = this._rootElt.childNodes.length - 1; + if (aOldIndex > lastBuiltIndex && aNewIndex > lastBuiltIndex + 1) + return; + + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + if (elt) { + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + this._removeChild(elt); + } + + if (aNewIndex > lastBuiltIndex + 1) { + if (this._resultNode.childCount > this._rootElt.childNodes.length) { + // If the element was built and becomes non built, another node should + // be built to keep a coherent number of children. + this._insertNewItem(this._resultNode.getChild(this._rootElt.childNodes.length), + this._rootElt); + } + return; + } + + if (!elt) { + // The node has not been inserted yet, so we must create it. + elt = this._insertNewItem(aPlacesNode, this._rootElt, this._rootElt.childNodes[aNewIndex]); + let icon = aPlacesNode.icon; + if (icon) + elt.setAttribute("image", icon); + } else { + this._rootElt.insertBefore(elt, this._rootElt.childNodes[aNewIndex]); + } + + // The chevron view may get nodeMoved after the toolbar. In such a case, + // we should ensure (by manually swapping menuitems) that the actual nodes + // are in the final position before updateNodesVisibility tries to update + // their visibility, or the chevron may go out of sync. + // Luckily updateNodesVisibility runs on a timer, so, by the time it updates + // nodes, the menu has already handled the notification. + + this.updateNodesVisibility(); + return; + } + + PlacesViewBase.prototype.nodeMoved.apply(this, arguments); + }, + + nodeAnnotationChanged: + function PT_nodeAnnotationChanged(aPlacesNode, aAnno) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt || elt == this._rootElt) + return; + + // We're notified for the menupopup, not the containing toolbarbutton. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (elt.parentNode == this._rootElt) { // Node is on the toolbar. + // All livemarks have a feedURI, so use it as our indicator. + if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + elt.setAttribute("livemark", true); + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + this.invalidateContainer(aPlacesNode); + }, Cu.reportError); + } + } else { + // Node is in a submenu. + PlacesViewBase.prototype.nodeAnnotationChanged.apply(this, arguments); + } + }, + + nodeTitleChanged: function PT_nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + + // Nothing to do if it's a never-visible node. + if (!elt || elt == this._rootElt) + return; + + PlacesViewBase.prototype.nodeTitleChanged.apply(this, arguments); + + // Here we need the <menu>. + if (elt.localName == "menupopup") + elt = elt.parentNode; + + if (elt.parentNode == this._rootElt) { // Node is on the toolbar. + if (elt.style.visibility != "hidden") + this.updateNodesVisibility(); + } + }, + + invalidateContainer: function PT_invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode, true); + // Nothing to do if it's a never-visible node. + if (!elt) + return; + + if (elt == this._rootElt) { + // Container is the toolbar itself. + this._rebuild(); + return; + } + + PlacesViewBase.prototype.invalidateContainer.apply(this, arguments); + }, + + _overFolder: { elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null }, + + _clearOverFolder: function PT__clearOverFolder() { + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + if (this._overFolder.elt && this._overFolder.elt.lastChild) { + if (!this._overFolder.elt.lastChild.hasAttribute("dragover")) { + this._overFolder.elt.lastChild.hidePopup(); + } + this._overFolder.elt.removeAttribute("dragover"); + this._overFolder.elt = null; + } + if (this._overFolder.openTimer) { + this._overFolder.openTimer.cancel(); + this._overFolder.openTimer = null; + } + if (this._overFolder.closeTimer) { + this._overFolder.closeTimer.cancel(); + this._overFolder.closeTimer = null; + } + }, + + /** + * This function returns information about where to drop when dragging over + * the toolbar. The returned object has the following properties: + * - ip: the insertion point for the bookmarks service. + * - beforeIndex: child index to drop before, for the drop indicator. + * - folderElt: the folder to drop into, if applicable. + */ + _getDropPoint: function PT__getDropPoint(aEvent) { + if (!PlacesUtils.nodeIsFolder(this._resultNode)) + return null; + + let dropPoint = { ip: null, beforeIndex: null, folderElt: null }; + let elt = aEvent.target; + if (elt._placesNode && elt != this._rootElt && + elt.localName != "menupopup") { + let eltRect = elt.getBoundingClientRect(); + let eltIndex = Array.prototype.indexOf.call(this._rootElt.childNodes, elt); + if (PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isFolderReadOnly(elt._placesNode, this)) { + // This is a folder. + // If we are in the middle of it, drop inside it. + // Otherwise, drop before it, with regards to RTL mode. + let threshold = eltRect.width * 0.25; + if (this.isRTL ? (aEvent.clientX > eltRect.right - threshold) + : (aEvent.clientX < eltRect.left + threshold)) { + // Drop before this folder. + dropPoint.ip = + new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(this._resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: eltIndex, + orientation: Ci.nsITreeView.DROP_BEFORE + }); + dropPoint.beforeIndex = eltIndex; + } else if (this.isRTL ? (aEvent.clientX > eltRect.left + threshold) + : (aEvent.clientX < eltRect.right - threshold)) { + // Drop inside this folder. + let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode) ? + elt._placesNode.title : null; + dropPoint.ip = + new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(elt._placesNode), + parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode), + tagName + }); + dropPoint.beforeIndex = eltIndex; + dropPoint.folderElt = elt; + } else { + // Drop after this folder. + let beforeIndex = + (eltIndex == this._rootElt.childNodes.length - 1) ? + -1 : eltIndex + 1; + + dropPoint.ip = + new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(this._resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: beforeIndex, + orientation: Ci.nsITreeView.DROP_BEFORE + }); + dropPoint.beforeIndex = beforeIndex; + } + } else { + // This is a non-folder node or a read-only folder. + // Drop before it with regards to RTL mode. + let threshold = eltRect.width * 0.5; + if (this.isRTL ? (aEvent.clientX > eltRect.left + threshold) + : (aEvent.clientX < eltRect.left + threshold)) { + // Drop before this bookmark. + dropPoint.ip = + new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(this._resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: eltIndex, + orientation: Ci.nsITreeView.DROP_BEFORE + }); + dropPoint.beforeIndex = eltIndex; + } else { + // Drop after this bookmark. + let beforeIndex = + eltIndex == this._rootElt.childNodes.length - 1 ? + -1 : eltIndex + 1; + dropPoint.ip = + new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(this._resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + index: beforeIndex, + orientation: Ci.nsITreeView.DROP_BEFORE + }); + dropPoint.beforeIndex = beforeIndex; + } + } + } else { + // We are most likely dragging on the empty area of the + // toolbar, we should drop after the last node. + dropPoint.ip = + new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(this._resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(this._resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE + }); + dropPoint.beforeIndex = -1; + } + + return dropPoint; + }, + + _setTimer: function PT_setTimer(aTime) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + }, + + notify: function PT_notify(aTimer) { + if (aTimer == this._updateNodesVisibilityTimer) { + this._updateNodesVisibilityTimer = null; + // Bug 1440070: This should use promiseDocumentFlushed, so that + // _updateNodesVisibilityTimerCallback can use getBoundsWithoutFlush. + window.requestAnimationFrame(this._updateNodesVisibilityTimerCallback.bind(this)); + } else if (aTimer == this._ibTimer) { + // * Timer to turn off indicator bar. + this._dropIndicator.collapsed = true; + this._ibTimer = null; + } else if (aTimer == this._overFolder.openTimer) { + // * Timer to open a menubutton that's being dragged over. + // Set the autoopen attribute on the folder's menupopup so that + // the menu will automatically close when the mouse drags off of it. + this._overFolder.elt.lastChild.setAttribute("autoopened", "true"); + this._overFolder.elt.open = true; + this._overFolder.openTimer = null; + } else if (aTimer == this._overFolder.closeTimer) { + // * Timer to close a menubutton that's been dragged off of. + // Close the menubutton if we are not dragging over it or one of + // its children. The autoopened attribute will let the menu know to + // close later if the menu is still being dragged over. + let currentPlacesNode = PlacesControllerDragHelper.currentDropTarget; + let inHierarchy = false; + while (currentPlacesNode) { + if (currentPlacesNode == this._rootElt) { + inHierarchy = true; + break; + } + currentPlacesNode = currentPlacesNode.parentNode; + } + // The _clearOverFolder() function will close the menu for + // _overFolder.elt. So null it out if we don't want to close it. + if (inHierarchy) + this._overFolder.elt = null; + + // Clear out the folder and all associated timers. + this._clearOverFolder(); + } + }, + + _onMouseOver: function PT__onMouseOver(aEvent) { + let button = aEvent.target; + if (button.parentNode == this._rootElt && button._placesNode && + PlacesUtils.nodeIsURI(button._placesNode)) + window.XULBrowserWindow.setOverLink(aEvent.target._placesNode.uri, null); + }, + + _onMouseOut: function PT__onMouseOut(aEvent) { + window.XULBrowserWindow.setOverLink("", null); + }, + + _cleanupDragDetails: function PT__cleanupDragDetails() { + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._draggedElt = null; + if (this._ibTimer) + this._ibTimer.cancel(); + + this._dropIndicator.collapsed = true; + }, + + _onDragStart: function PT__onDragStart(aEvent) { + // Sub menus have their own d&d handlers. + let draggedElt = aEvent.target; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) + return; + + if (draggedElt.localName == "toolbarbutton" && + draggedElt.getAttribute("type") == "menu") { + // If the drag gesture on a container is toward down we open instead + // of dragging. + let translateY = this._cachedMouseMoveEvent.clientY - aEvent.clientY; + let translateX = this._cachedMouseMoveEvent.clientX - aEvent.clientX; + if ((translateY) >= Math.abs(translateX / 2)) { + // Don't start the drag. + aEvent.preventDefault(); + // Open the menu. + draggedElt.open = true; + return; + } + + // If the menu is open, close it. + if (draggedElt.open) { + draggedElt.lastChild.hidePopup(); + draggedElt.open = false; + } + } + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(aEvent); + aEvent.stopPropagation(); + }, + + _onDragOver: function PT__onDragOver(aEvent) { + // Cache the dataTransfer + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + let dt = aEvent.dataTransfer; + + let dropPoint = this._getDropPoint(aEvent); + if (!dropPoint || !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt)) { + this._dropIndicator.collapsed = true; + aEvent.stopPropagation(); + return; + } + + if (this._ibTimer) { + this._ibTimer.cancel(); + this._ibTimer = null; + } + + if (dropPoint.folderElt || aEvent.originalTarget == this._chevron) { + // Dropping over a menubutton or chevron button. + // Set styles and timer to open relative menupopup. + let overElt = dropPoint.folderElt || this._chevron; + if (this._overFolder.elt != overElt) { + this._clearOverFolder(); + this._overFolder.elt = overElt; + this._overFolder.openTimer = this._setTimer(this._overFolder.hoverTime); + } + if (!this._overFolder.elt.hasAttribute("dragover")) + this._overFolder.elt.setAttribute("dragover", "true"); + + this._dropIndicator.collapsed = true; + } else { + // Dragging over a normal toolbarbutton, + // show indicator bar and move it to the appropriate drop point. + let ind = this._dropIndicator; + ind.parentNode.collapsed = false; + let halfInd = ind.clientWidth / 2; + let translateX; + if (this.isRTL) { + halfInd = Math.ceil(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().right - halfInd; + if (this._rootElt.firstChild) { + if (dropPoint.beforeIndex == -1) + translateX += this._rootElt.lastChild.getBoundingClientRect().left; + else { + translateX += this._rootElt.childNodes[dropPoint.beforeIndex] + .getBoundingClientRect().right; + } + } + } else { + halfInd = Math.floor(halfInd); + translateX = 0 - this._rootElt.getBoundingClientRect().left + + halfInd; + if (this._rootElt.firstChild) { + if (dropPoint.beforeIndex == -1) + translateX += this._rootElt.lastChild.getBoundingClientRect().right; + else { + translateX += this._rootElt.childNodes[dropPoint.beforeIndex] + .getBoundingClientRect().left; + } + } + } + + ind.style.transform = "translate(" + Math.round(translateX) + "px)"; + ind.style.marginInlineStart = (-ind.clientWidth) + "px"; + ind.collapsed = false; + + // Clear out old folder information. + this._clearOverFolder(); + } + + aEvent.preventDefault(); + aEvent.stopPropagation(); + }, + + _onDrop: function PT__onDrop(aEvent) { + PlacesControllerDragHelper.currentDropTarget = aEvent.target; + + let dropPoint = this._getDropPoint(aEvent); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop(dropPoint.ip, aEvent.dataTransfer) + .catch(Cu.reportError); + aEvent.preventDefault(); + } + + this._cleanupDragDetails(); + aEvent.stopPropagation(); + }, + + _onDragExit: function PT__onDragExit(aEvent) { + PlacesControllerDragHelper.currentDropTarget = null; + + // Set timer to turn off indicator bar (if we turn it off + // here, dragenter might be called immediately after, creating + // flicker). + if (this._ibTimer) + this._ibTimer.cancel(); + this._ibTimer = this._setTimer(10); + + // If we hovered over a folder, close it now. + if (this._overFolder.elt) + this._overFolder.closeTimer = this._setTimer(this._overFolder.hoverTime); + }, + + _onDragEnd: function PT_onDragEnd(aEvent) { + this._cleanupDragDetails(); + }, + + _onPopupShowing: function PT__onPopupShowing(aEvent) { + if (!this._allowPopupShowing) { + this._allowPopupShowing = true; + aEvent.preventDefault(); + return; + } + + let parent = aEvent.target.parentNode; + if (parent.localName == "toolbarbutton") + this._openedMenuButton = parent; + + PlacesViewBase.prototype._onPopupShowing.apply(this, arguments); + }, + + _onPopupHidden: function PT__onPopupHidden(aEvent) { + let popup = aEvent.target; + let placesNode = popup._placesNode; + // Avoid handling popuphidden of inner views + if (placesNode && PlacesUIUtils.getViewForNode(popup) == this) { + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + // Though, we want to always close feed containers so their expiration + // status will be checked at next opening. + if (!PlacesUtils.nodeIsFolder(placesNode) || + this.controller.hasCachedLivemarkInfo(placesNode)) { + placesNode.containerOpen = false; + } + } + + let parent = popup.parentNode; + if (parent.localName == "toolbarbutton") { + this._openedMenuButton = null; + // Clear the dragover attribute if present, if we are dragging into a + // folder in the hierachy of current opened popup we don't clear + // this attribute on clearOverFolder. See Notify for closeTimer. + if (parent.hasAttribute("dragover")) + parent.removeAttribute("dragover"); + } + }, + + _onMouseMove: function PT__onMouseMove(aEvent) { + // Used in dragStart to prevent dragging folders when dragging down. + this._cachedMouseMoveEvent = aEvent; + + if (this._openedMenuButton == null || + PlacesControllerDragHelper.getSession()) + return; + + let target = aEvent.originalTarget; + if (this._openedMenuButton != target && + target.localName == "toolbarbutton" && + target.type == "menu") { + this._openedMenuButton.open = false; + target.open = true; + } + } +}; + +/** + * View for Places menus. This object should be created during the first + * popupshowing that's dispatched on the menu. + */ +function PlacesMenu(aPopupShowingEvent, aPlace, aOptions) { + this._rootElt = aPopupShowingEvent.target; // <menupopup> + this._viewElt = this._rootElt.parentNode; // <menu> + this._viewElt._placesView = this; + this._addEventListeners(this._rootElt, ["popupshowing", "popuphidden"], true); + this._addEventListeners(window, ["unload"], false); + + if (AppConstants.platform === "macosx") { + // Must walk up to support views in sub-menus, like Bookmarks Toolbar menu. + for (let elt = this._viewElt.parentNode; elt; elt = elt.parentNode) { + if (elt.localName == "menubar") { + this._nativeView = true; + break; + } + } + } + + PlacesViewBase.call(this, aPlace, aOptions); + this._onPopupShowing(aPopupShowingEvent); +} + +PlacesMenu.prototype = { + __proto__: PlacesViewBase.prototype, + + QueryInterface: function PM_QueryInterface(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener)) + return this; + + return PlacesViewBase.prototype.QueryInterface.apply(this, arguments); + }, + + _removeChild: function PM_removeChild(aChild) { + PlacesViewBase.prototype._removeChild.apply(this, arguments); + }, + + uninit: function PM_uninit() { + this._removeEventListeners(this._rootElt, ["popupshowing", "popuphidden"], + true); + this._removeEventListeners(window, ["unload"], false); + + PlacesViewBase.prototype.uninit.apply(this, arguments); + }, + + handleEvent: function PM_handleEvent(aEvent) { + switch (aEvent.type) { + case "unload": + this.uninit(); + break; + case "popupshowing": + this._onPopupShowing(aEvent); + break; + case "popuphidden": + this._onPopupHidden(aEvent); + break; + } + }, + + _onPopupHidden: function PM__onPopupHidden(aEvent) { + // Avoid handling popuphidden of inner views. + let popup = aEvent.originalTarget; + let placesNode = popup._placesNode; + if (!placesNode || PlacesUIUtils.getViewForNode(popup) != this) + return; + + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + // Though, we want to always close feed containers so their expiration + // status will be checked at next opening. + if (!PlacesUtils.nodeIsFolder(placesNode) || + this.controller.hasCachedLivemarkInfo(placesNode)) + placesNode.containerOpen = false; + + // The autoopened attribute is set for folders which have been + // automatically opened when dragged over. Turn off this attribute + // when the folder closes because it is no longer applicable. + popup.removeAttribute("autoopened"); + popup.removeAttribute("dragstart"); + } +}; + +function PlacesPanelMenuView(aPlace, aViewId, aRootId, aOptions) { + this._viewElt = document.getElementById(aViewId); + this._rootElt = document.getElementById(aRootId); + this._viewElt._placesView = this; + this.options = aOptions; + + PlacesViewBase.call(this, aPlace, aOptions); +} + +PlacesPanelMenuView.prototype = { + __proto__: PlacesViewBase.prototype, + + QueryInterface: function PAMV_QueryInterface(aIID) { + return PlacesViewBase.prototype.QueryInterface.apply(this, arguments); + }, + + uninit: function PAMV_uninit() { + PlacesViewBase.prototype.uninit.apply(this, arguments); + }, + + _insertNewItem: + function PAMV__insertNewItem(aChild, aInsertionNode, aBefore = null) { + this._domNodes.delete(aChild); + + let type = aChild.type; + let button; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + button = document.createElement("toolbarseparator"); + button.setAttribute("class", "small-separator"); + } else { + button = document.createElement("toolbarbutton"); + button.className = "bookmark-item"; + if (typeof this.options.extraClasses.entry == "string") + button.classList.add(this.options.extraClasses.entry); + button.setAttribute("label", aChild.title || ""); + let icon = aChild.icon; + if (icon) + button.setAttribute("image", icon); + + if (PlacesUtils.containerTypes.includes(type)) { + button.setAttribute("container", "true"); + + if (PlacesUtils.nodeIsQuery(aChild)) { + button.setAttribute("query", "true"); + if (PlacesUtils.nodeIsTagQuery(aChild)) + button.setAttribute("tagContainer", "true"); + } else if (PlacesUtils.nodeIsFolder(aChild)) { + PlacesUtils.livemarks.getLivemark({ id: aChild.itemId }) + .then(aLivemark => { + button.setAttribute("livemark", "true"); + this.controller.cacheLivemarkInfo(aChild, aLivemark); + }, () => undefined); + } + } else if (PlacesUtils.nodeIsURI(aChild)) { + button.setAttribute("scheme", + PlacesUIUtils.guessUrlSchemeForUI(aChild.uri)); + } + } + + button._placesNode = aChild; + if (!this._domNodes.has(aChild)) + this._domNodes.set(aChild, button); + + aInsertionNode.insertBefore(button, aBefore); + return button; + }, + + nodeInserted: + function PAMV_nodeInserted(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt != this._rootElt) + return; + + let children = this._rootElt.childNodes; + this._insertNewItem(aPlacesNode, this._rootElt, + aIndex < children.length ? children[aIndex] : null); + }, + + nodeRemoved: + function PAMV_nodeRemoved(aParentPlacesNode, aPlacesNode, aIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aParentPlacesNode); + if (parentElt != this._rootElt) + return; + + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + this._removeChild(elt); + }, + + nodeMoved: + function PAMV_nodeMoved(aPlacesNode, + aOldParentPlacesNode, aOldIndex, + aNewParentPlacesNode, aNewIndex) { + let parentElt = this._getDOMNodeForPlacesNode(aNewParentPlacesNode); + if (parentElt != this._rootElt) + return; + + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + this._removeChild(elt); + this._rootElt.insertBefore(elt, this._rootElt.childNodes[aNewIndex]); + }, + + nodeAnnotationChanged: + function PAMV_nodeAnnotationChanged(aPlacesNode, aAnno) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + // There's no UI representation for the root node. + if (elt == this._rootElt) + return; + + if (elt.parentNode != this._rootElt) + return; + + // All livemarks have a feedURI, so use it as our indicator. + if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + elt.setAttribute("livemark", true); + + PlacesUtils.livemarks.getLivemark({ id: aPlacesNode.itemId }) + .then(aLivemark => { + this.controller.cacheLivemarkInfo(aPlacesNode, aLivemark); + this.invalidateContainer(aPlacesNode); + }, Cu.reportError); + } + }, + + nodeTitleChanged: function PAMV_nodeTitleChanged(aPlacesNode, aNewTitle) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + + // There's no UI representation for the root node. + if (elt == this._rootElt) + return; + + PlacesViewBase.prototype.nodeTitleChanged.apply(this, arguments); + }, + + invalidateContainer: function PAMV_invalidateContainer(aPlacesNode) { + let elt = this._getDOMNodeForPlacesNode(aPlacesNode); + if (elt != this._rootElt) + return; + + // Container is the toolbar itself. + while (this._rootElt.hasChildNodes()) { + this._rootElt.firstChild.remove(); + } + + let fragment = document.createDocumentFragment(); + for (let i = 0; i < this._resultNode.childCount; ++i) { + this._insertNewItem(this._resultNode.getChild(i), fragment); + } + this._rootElt.appendChild(fragment); + } +}; + +this.PlacesPanelview = class extends PlacesViewBase { + constructor(container, panelview, place, options = {}) { + options.rootElt = container; + options.viewElt = panelview; + super(place, options); + this._viewElt._placesView = this; + // We're simulating a popup show, because a panelview may only be shown when + // its containing popup is already shown. + this._onPopupShowing({ originalTarget: this._rootElt }); + this._addEventListeners(window, ["unload"]); + this._rootElt.setAttribute("context", "placesContext"); + } + + get events() { + if (this._events) + return this._events; + return this._events = ["click", "command", "dragend", "dragstart", "ViewHiding", "ViewShown"]; + } + + handleEvent(event) { + switch (event.type) { + case "click": + // For middle clicks, fall through to the command handler. + if (event.button != 1) { + break; + } + case "command": + this._onCommand(event); + break; + case "dragend": + this._onDragEnd(event); + break; + case "dragstart": + this._onDragStart(event); + break; + case "unload": + this.uninit(event); + break; + case "ViewHiding": + this._onPopupHidden(event); + break; + case "ViewShown": + this._onViewShown(event); + break; + } + } + + _onCommand(event) { + let button = event.originalTarget; + if (!button._placesNode) + return; + + let modifKey = AppConstants.platform === "macosx" ? event.metaKey + : event.ctrlKey; + if (!PlacesUIUtils.openInTabClosesMenu && modifKey) { + // If 'Recent Bookmarks' in Bookmarks Panel. + if (button.parentNode.id == "panelMenu_bookmarksMenu") { + button.setAttribute("closemenu", "none"); + } + } else { + button.removeAttribute("closemenu"); + } + PlacesUIUtils.openNodeWithEvent(button._placesNode, event); + // Unlike left-click, middle-click requires manual menu closing. + if (button.parentNode.id != "panelMenu_bookmarksMenu" || + (event.type == "click" && event.button == 1 && PlacesUIUtils.openInTabClosesMenu)) { + this.panelMultiView.closest("panel").hidePopup(); + } + } + + _onDragEnd() { + this._draggedElt = null; + } + + _onDragStart(event) { + let draggedElt = event.originalTarget; + if (draggedElt.parentNode != this._rootElt || !draggedElt._placesNode) + return; + + // Activate the view and cache the dragged element. + this._draggedElt = draggedElt._placesNode; + this._rootElt.focus(); + + this._controller.setDataTransfer(event); + event.stopPropagation(); + } + + uninit(event) { + this._removeEventListeners(this.panelMultiView, this.events); + this._removeEventListeners(window, ["unload"]); + delete this.panelMultiView; + super.uninit(event); + } + + _createDOMNodeForPlacesNode(placesNode) { + this._domNodes.delete(placesNode); + + let element; + let type = placesNode.type; + if (type == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + element = document.createElement("toolbarseparator"); + } else { + if (type != Ci.nsINavHistoryResultNode.RESULT_TYPE_URI) + throw "Unexpected node"; + + element = document.createElement("toolbarbutton"); + element.classList.add("subviewbutton", "subviewbutton-iconic", "bookmark-item"); + element.setAttribute("scheme", PlacesUIUtils.guessUrlSchemeForUI(placesNode.uri)); + element.setAttribute("label", PlacesUIUtils.getBestTitle(placesNode)); + + let icon = placesNode.icon; + if (icon) + element.setAttribute("image", icon); + } + + element._placesNode = placesNode; + if (!this._domNodes.has(placesNode)) + this._domNodes.set(placesNode, element); + + return element; + } + + _setEmptyPopupStatus(panelview, empty = false) { + if (!panelview._emptyMenuitem) { + let label = PlacesUIUtils.getString("bookmarksMenuEmptyFolder"); + panelview._emptyMenuitem = document.createElement("toolbarbutton"); + panelview._emptyMenuitem.setAttribute("label", label); + panelview._emptyMenuitem.setAttribute("disabled", true); + panelview._emptyMenuitem.className = "subviewbutton"; + if (typeof this.options.extraClasses.entry == "string") + panelview._emptyMenuitem.classList.add(this.options.extraClasses.entry); + } + + if (empty) { + panelview.setAttribute("emptyplacesresult", "true"); + // Don't add the menuitem if there is static content. + // We also support external usage for custom crafted panels - which'll have + // no markers present. + if (!panelview._startMarker || + (!panelview._startMarker.previousSibling && !panelview._endMarker.nextSibling)) { + panelview.insertBefore(panelview._emptyMenuitem, panelview._endMarker); + } + } else { + panelview.removeAttribute("emptyplacesresult"); + try { + panelview.removeChild(panelview._emptyMenuitem); + } catch (ex) {} + } + } + + _isPopupOpen() { + return PanelView.forNode(this._viewElt).active; + } + + _onPopupHidden(event) { + let panelview = event.originalTarget; + let placesNode = panelview._placesNode; + // Avoid handling ViewHiding of inner views + if (placesNode && PlacesUIUtils.getViewForNode(panelview) == this) { + // UI performance: folder queries are cheap, keep the resultnode open + // so we don't rebuild its contents whenever the popup is reopened. + // Though, we want to always close feed containers so their expiration + // status will be checked at next opening. + if (!PlacesUtils.nodeIsFolder(placesNode) || + this.controller.hasCachedLivemarkInfo(placesNode)) { + placesNode.containerOpen = false; + } + } + } + + _onPopupShowing(event) { + // If the event came from the root element, this is the first time + // we ever get here. + if (event.originalTarget == this._rootElt) { + // Start listening for events from all panels inside the panelmultiview. + this.panelMultiView = this._viewElt.panelMultiView; + this._addEventListeners(this.panelMultiView, this.events); + } + super._onPopupShowing(event); + } + + _onViewShown(event) { + if (event.originalTarget != this._viewElt) + return; + + // Because PanelMultiView reparents the panelview internally, the controller + // may get lost. In that case we'll append it again, because we certainly + // need it later! + if (!this.controllers.getControllerCount() && this._controller) + this.controllers.appendController(this._controller); + } +}; diff --git a/comm/suite/components/places/content/controller.js b/comm/suite/components/places/content/controller.js new file mode 100644 index 0000000000..1bf51db463 --- /dev/null +++ b/comm/suite/components/places/content/controller.js @@ -0,0 +1,1442 @@ +/* -*- 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/. */ + +ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Represents an insertion point within a container where we can insert + * items. + * @param {object} an object containing the following properties: + * - parentId + * The identifier of the parent container + * - parentGuid + * The unique identifier of the parent container + * - index + * The index within the container where to insert, defaults to appending + * - orientation + * The orientation of the insertion. NOTE: the adjustments to the + * insertion point to accommodate the orientation should be done by + * the person who constructs the IP, not the user. The orientation + * is provided for informational purposes only! Defaults to DROP_ON. + * - tagName + * The tag name if this IP is set to a tag, null otherwise. + * - dropNearNode + * When defined index will be calculated based on this node + */ +function PlacesInsertionPoint({ parentId, parentGuid, + index = PlacesUtils.bookmarks.DEFAULT_INDEX, + orientation = Ci.nsITreeView.DROP_ON, + tagName = null, + dropNearNode = null }) { + this.itemId = parentId; + this.guid = parentGuid; + this._index = index; + this.orientation = orientation; + this.tagName = tagName; + this.dropNearNode = dropNearNode; +} + +PlacesInsertionPoint.prototype = { + set index(val) { + return this._index = val; + }, + + async getIndex() { + if (this.dropNearNode) { + // If dropNearNode is set up we must calculate the index of the item near + // which we will drop. + let index = (await PlacesUtils.bookmarks.fetch(this.dropNearNode.bookmarkGuid)).index; + return this.orientation == Ci.nsITreeView.DROP_BEFORE ? index : index + 1; + } + return this._index; + }, + + get isTag() { + return typeof(this.tagName) == "string"; + } +}; + +/** + * Places Controller + */ + +function PlacesController(aView) { + this._view = aView; + XPCOMUtils.defineLazyServiceGetter(this, "clipboard", + "@mozilla.org/widget/clipboard;1", + "nsIClipboard"); + XPCOMUtils.defineLazyGetter(this, "profileName", function() { + return Services.dirsvc.get("ProfD", Ci.nsIFile).leafName; + }); + + this._cachedLivemarkInfoObjects = new Map(); +} + +PlacesController.prototype = { + /** + * The places view. + */ + _view: null, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIClipboardOwner + ]), + + // nsIClipboardOwner + LosingOwnership: function PC_LosingOwnership(aXferable) { + this.cutNodes = []; + }, + + terminate: function PC_terminate() { + this._releaseClipboardOwnership(); + }, + + supportsCommand: function PC_supportsCommand(aCommand) { + // Non-Places specific commands that we also support + switch (aCommand) { + case "cmd_undo": + case "cmd_redo": + case "cmd_cut": + case "cmd_copy": + case "cmd_paste": + case "cmd_delete": + case "cmd_selectAll": + return true; + } + + // All other Places Commands are prefixed with "placesCmd_" ... this + // filters out other commands that we do _not_ support (see 329587). + const CMD_PREFIX = "placesCmd_"; + return (aCommand.substr(0, CMD_PREFIX.length) == CMD_PREFIX); + }, + + isCommandEnabled: function PC_isCommandEnabled(aCommand) { + switch (aCommand) { + case "cmd_undo": + return PlacesTransactions.topUndoEntry != null; + case "cmd_redo": + return PlacesTransactions.topRedoEntry != null; + case "cmd_cut": + case "placesCmd_cut": + for (let node of this._view.selectedNodes) { + // If selection includes history nodes or tags-as-bookmark, disallow + // cutting. + if (node.itemId == -1 || + (node.parent && PlacesUtils.nodeIsTagQuery(node.parent))) { + return false; + } + } + // Otherwise fall through the cmd_delete check. + case "cmd_delete": + case "placesCmd_delete": + case "placesCmd_deleteDataHost": + return this._hasRemovableSelection(); + case "cmd_copy": + case "placesCmd_copy": + return this._view.hasSelection; + case "cmd_paste": + case "placesCmd_paste": + return this._canInsert(true) && this._isClipboardDataPasteable(); + case "cmd_selectAll": + if (this._view.selType != "single") { + let rootNode = this._view.result.root; + if (rootNode.containerOpen && rootNode.childCount > 0) + return true; + } + return false; + case "placesCmd_open": + case "placesCmd_open:window": + case "placesCmd_open:privatewindow": + case "placesCmd_open:tab": { + let selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.nodeIsURI(selectedNode); + } + case "placesCmd_new:folder": + return this._canInsert(); + case "placesCmd_new:bookmark": + return this._canInsert(); + case "placesCmd_new:separator": + return this._canInsert() && + !PlacesUtils.asQuery(this._view.result.root).queryOptions.excludeItems && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + case "placesCmd_show:info": { + let selectedNode = this._view.selectedNode; + return selectedNode && PlacesUtils.getConcreteItemId(selectedNode) != -1; + } + case "placesCmd_reload": { + // Livemark containers + let selectedNode = this._view.selectedNode; + return selectedNode && this.hasCachedLivemarkInfo(selectedNode); + } + case "placesCmd_sortBy:name": { + let selectedNode = this._view.selectedNode; + return selectedNode && + PlacesUtils.nodeIsFolder(selectedNode) && + !PlacesUIUtils.isFolderReadOnly(selectedNode, this._view) && + this._view.result.sortingMode == + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + } + case "placesCmd_createBookmark": + var node = this._view.selectedNode; + return node && PlacesUtils.nodeIsURI(node) && node.itemId == -1; + default: + return false; + } + }, + + doCommand: function PC_doCommand(aCommand) { + switch (aCommand) { + case "cmd_undo": + PlacesTransactions.undo().catch(Cu.reportError); + break; + case "cmd_redo": + PlacesTransactions.redo().catch(Cu.reportError); + break; + case "cmd_cut": + case "placesCmd_cut": + this.cut(); + break; + case "cmd_copy": + case "placesCmd_copy": + this.copy(); + break; + case "cmd_paste": + case "placesCmd_paste": + this.paste().catch(Cu.reportError); + break; + case "cmd_delete": + case "placesCmd_delete": + this.remove("Remove Selection").catch(Cu.reportError); + break; + case "placesCmd_deleteDataHost": + var host; + if (PlacesUtils.nodeIsHost(this._view.selectedNode)) { + var queries = this._view.selectedNode.getQueries(); + host = queries[0].domain; + } else + host = Services.io.newURI(this._view.selectedNode.uri).host; + ForgetAboutSite.removeDataFromDomain(host) + .catch(Cu.reportError); + break; + case "cmd_selectAll": + this.selectAll(); + break; + case "placesCmd_open": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "current", this._view); + break; + case "placesCmd_open:window": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view); + break; + case "placesCmd_open:privatewindow": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "window", this._view, true); + break; + case "placesCmd_open:tab": + PlacesUIUtils.openNodeIn(this._view.selectedNode, "tab", this._view); + break; + case "placesCmd_new:folder": + this.newItem("folder").catch(Cu.reportError); + break; + case "placesCmd_new:bookmark": + this.newItem("bookmark").catch(Cu.reportError); + break; + case "placesCmd_new:separator": + this.newSeparator().catch(Cu.reportError); + break; + case "placesCmd_show:info": + this.showBookmarkPropertiesForSelection(); + break; + case "placesCmd_reload": + this.reloadSelectedLivemark(); + break; + case "placesCmd_sortBy:name": + this.sortFolderByName().catch(Cu.reportError); + break; + case "placesCmd_createBookmark": + let node = this._view.selectedNode; + PlacesUIUtils.showBookmarkDialog({ action: "add", + type: "bookmark", + hiddenRows: [ "description", + "keyword", + "location", + "loadInSidebar" ], + uri: Services.io.newURI(node.uri), + title: node.title + }, window.top); + break; + } + }, + + onEvent: function PC_onEvent(eventName) { }, + + + /** + * Determine whether or not the selection can be removed, either by the + * delete or cut operations based on whether or not any of its contents + * are non-removable. We don't need to worry about recursion here since it + * is a policy decision that a removable item not be placed inside a non- + * removable item. + * + * @return true if all nodes in the selection can be removed, + * false otherwise. + */ + _hasRemovableSelection() { + var ranges = this._view.removableSelectionRanges; + if (!ranges.length) + return false; + + var root = this._view.result.root; + + for (var j = 0; j < ranges.length; j++) { + var nodes = ranges[j]; + for (var i = 0; i < nodes.length; ++i) { + // Disallow removing the view's root node + if (nodes[i] == root) + return false; + + if (!PlacesUIUtils.canUserRemove(nodes[i], this._view)) + return false; + } + } + + return true; + }, + + /** + * Determines whether or not nodes can be inserted relative to the selection. + */ + _canInsert: function PC__canInsert(isPaste) { + var ip = this._view.insertionPoint; + return ip != null && (isPaste || ip.isTag != true); + }, + + /** + * Looks at the data on the clipboard to see if it is paste-able. + * Paste-able data is: + * - in a format that the view can receive + * @return true if: - clipboard data is of a TYPE_X_MOZ_PLACE_* flavor, + * - clipboard data is of type TEXT_UNICODE and + * is a valid URI. + */ + _isClipboardDataPasteable: function PC__isClipboardDataPasteable() { + // if the clipboard contains TYPE_X_MOZ_PLACE_* data, it is definitely + // pasteable, with no need to unwrap all the nodes. + + var flavors = PlacesUIUtils.PLACES_FLAVORS; + var clipboard = this.clipboard; + var hasPlacesData = + clipboard.hasDataMatchingFlavors(flavors, + Ci.nsIClipboard.kGlobalClipboard); + if (hasPlacesData) + return this._view.insertionPoint != null; + + // if the clipboard doesn't have TYPE_X_MOZ_PLACE_* data, we also allow + // pasting of valid "text/unicode" and "text/x-moz-url" data + var xferable = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + xferable.init(null); + + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_URL); + xferable.addDataFlavor(PlacesUtils.TYPE_UNICODE); + clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + try { + // getAnyTransferData will throw if no data is available. + var data = { }, type = { }; + xferable.getAnyTransferData(type, data, { }); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + if (type.value != PlacesUtils.TYPE_X_MOZ_URL && + type.value != PlacesUtils.TYPE_UNICODE) + return false; + + // unwrapNodes() will throw if the data blob is malformed. + PlacesUtils.unwrapNodes(data, type.value); + return this._view.insertionPoint != null; + } catch (e) { + // getAnyTransferData or unwrapNodes failed + return false; + } + }, + + /** + * Gathers information about the selected nodes according to the following + * rules: + * "link" node is a URI + * "bookmark" node is a bookmark + * "livemarkChild" node is a child of a livemark + * "tagChild" node is a child of a tag + * "folder" node is a folder + * "query" node is a query + * "separator" node is a separator line + * "host" node is a host + * + * @return an array of objects corresponding the selected nodes. Each + * object has each of the properties above set if its corresponding + * node matches the rule. In addition, the annotations names for each + * node are set on its corresponding object as properties. + * Notes: + * 1) This can be slow, so don't call it anywhere performance critical! + */ + _buildSelectionMetadata: function PC__buildSelectionMetadata() { + var metadata = []; + var nodes = this._view.selectedNodes; + + for (var i = 0; i < nodes.length; i++) { + var nodeData = {}; + var node = nodes[i]; + var nodeType = node.type; + var uri = null; + + // We don't use the nodeIs* methods here to avoid going through the type + // property way too often + switch (nodeType) { + case Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY: + nodeData.query = true; + if (node.parent) { + switch (PlacesUtils.asQuery(node.parent).queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + nodeData.host = true; + break; + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + nodeData.day = true; + break; + } + } + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER: + case Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT: + nodeData.folder = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR: + nodeData.separator = true; + break; + case Ci.nsINavHistoryResultNode.RESULT_TYPE_URI: + nodeData.link = true; + uri = Services.io.newURI(node.uri); + if (PlacesUtils.nodeIsBookmark(node)) { + nodeData.bookmark = true; + var parentNode = node.parent; + if (parentNode) { + if (PlacesUtils.nodeIsTagQuery(parentNode)) + nodeData.tagChild = true; + else if (this.hasCachedLivemarkInfo(parentNode)) + nodeData.livemarkChild = true; + } + } + break; + } + + // annotations + if (uri) { + let names = PlacesUtils.annotations.getPageAnnotationNames(uri); + for (let j = 0; j < names.length; ++j) + nodeData[names[j]] = true; + } + + // For items also include the item-specific annotations + if (node.itemId != -1) { + let names = PlacesUtils.annotations + .getItemAnnotationNames(node.itemId); + for (let j = 0; j < names.length; ++j) + nodeData[names[j]] = true; + } + metadata.push(nodeData); + } + + return metadata; + }, + + /** + * Determines if a context-menu item should be shown + * @param aMenuItem + * the context menu item + * @param aMetaData + * meta data about the selection + * @return true if the conditions (see buildContextMenu) are satisfied + * and the item can be displayed, false otherwise. + */ + _shouldShowMenuItem: function PC__shouldShowMenuItem(aMenuItem, aMetaData) { + var selectiontype = aMenuItem.getAttribute("selectiontype"); + if (!selectiontype) { + selectiontype = "single|multiple"; + } + var selectionTypes = selectiontype.split("|"); + if (selectionTypes.includes("any")) { + return true; + } + var count = aMetaData.length; + if (count > 1 && !selectionTypes.includes("multiple")) + return false; + if (count == 1 && !selectionTypes.includes("single")) + return false; + // NB: if there is no selection, we show the item if and only if + // the selectiontype includes 'none' - the metadata list will be + // empty so none of the other criteria will apply anyway. + if (count == 0) + return selectionTypes.includes("none"); + + var forceHideAttr = aMenuItem.getAttribute("forcehideselection"); + if (forceHideAttr) { + var forceHideRules = forceHideAttr.split("|"); + for (let i = 0; i < aMetaData.length; ++i) { + for (let j = 0; j < forceHideRules.length; ++j) { + if (forceHideRules[j] in aMetaData[i]) + return false; + } + } + } + + var selectionAttr = aMenuItem.getAttribute("selection"); + if (!selectionAttr) { + return !aMenuItem.hidden; + } + + if (selectionAttr == "any") + return true; + + var showRules = selectionAttr.split("|"); + var anyMatched = false; + function metaDataNodeMatches(metaDataNode, rules) { + for (var i = 0; i < rules.length; i++) { + if (rules[i] in metaDataNode) + return true; + } + return false; + } + + for (var i = 0; i < aMetaData.length; ++i) { + if (metaDataNodeMatches(aMetaData[i], showRules)) + anyMatched = true; + else + return false; + } + return anyMatched; + }, + + /** + * Detects information (meta-data rules) about the current selection in the + * view (see _buildSelectionMetadata) and sets the visibility state for each + * of the menu-items in the given popup with the following rules applied: + * 0) The "ignoreitem" attribute may be set to "true" for this code not to + * handle that menuitem. + * 1) The "selectiontype" attribute may be set on a menu-item to "single" + * if the menu-item should be visible only if there is a single node + * selected, or to "multiple" if the menu-item should be visible only if + * multiple nodes are selected, or to "none" if the menuitems should be + * visible for if there are no selected nodes, or to a |-separated + * combination of these. + * If the attribute is not set or set to an invalid value, the menu-item + * may be visible irrespective of the selection. + * 2) The "selection" attribute may be set on a menu-item to the various + * meta-data rules for which it may be visible. The rules should be + * separated with the | character. + * 3) A menu-item may be visible only if at least one of the rules set in + * its selection attribute apply to each of the selected nodes in the + * view. + * 4) The "forcehideselection" attribute may be set on a menu-item to rules + * for which it should be hidden. This attribute takes priority over the + * selection attribute. A menu-item would be hidden if at least one of the + * given rules apply to one of the selected nodes. The rules should be + * separated with the | character. + * 5) The "hideifnoinsertionpoint" attribute may be set on a menu-item to + * true if it should be hidden when there's no insertion point + * 6) The visibility state of a menu-item is unchanged if none of these + * attribute are set. + * 7) These attributes should not be set on separators for which the + * visibility state is "auto-detected." + * 8) The "hideifprivatebrowsing" attribute may be set on a menu-item to + * true if it should be hidden inside the private browsing mode + * @param aPopup + * The menupopup to build children into. + * @return true if at least one item is visible, false otherwise. + */ + buildContextMenu: function PC_buildContextMenu(aPopup) { + var metadata = this._buildSelectionMetadata(); + var ip = this._view.insertionPoint; + var noIp = !ip || ip.isTag; + + var separator = null; + var visibleItemsBeforeSep = false; + var usableItemCount = 0; + for (var i = 0; i < aPopup.childNodes.length; ++i) { + var item = aPopup.childNodes[i]; + if (item.getAttribute("ignoreitem") == "true") { + continue; + } + if (item.localName != "menuseparator") { + // We allow pasting into tag containers, so special case that. + var hideIfNoIP = item.getAttribute("hideifnoinsertionpoint") == "true" && + noIp && !(ip && ip.isTag && item.id == "placesContext_paste"); + var hideIfPrivate = item.getAttribute("hideifprivatebrowsing") == "true" && + PrivateBrowsingUtils.isWindowPrivate(window); + var shouldHideItem = hideIfNoIP || hideIfPrivate || + !this._shouldShowMenuItem(item, metadata); + item.hidden = item.disabled = shouldHideItem; + + if (!item.hidden) { + visibleItemsBeforeSep = true; + usableItemCount++; + + // Show the separator above the menu-item if any + if (separator) { + separator.hidden = false; + separator = null; + } + } + } else { // menuseparator + // Initially hide it. It will be unhidden if there will be at least one + // visible menu-item above and below it. + item.hidden = true; + + // We won't show the separator at all if no items are visible above it + if (visibleItemsBeforeSep) + separator = item; + + // New separator, count again: + visibleItemsBeforeSep = false; + } + } + + // Set Open Folder/Links In Tabs items enabled state if they're visible + if (usableItemCount > 0) { + var openContainerInTabsItem = document.getElementById("placesContext_openContainer:tabs"); + if (!openContainerInTabsItem.hidden) { + var containerToUse = this._view.selectedNode || this._view.result.root; + if (PlacesUtils.nodeIsContainer(containerToUse)) { + if (!PlacesUtils.hasChildURIs(containerToUse)) { + openContainerInTabsItem.disabled = true; + // Ensure that we don't display the menu if nothing is enabled: + usableItemCount--; + } + } + } + } + + // Make sure to display the correct string when multiple pages are selected. + let stringId = metadata.length === 1 ? "SinglePage" : "MultiplePages"; + + let deleteHistoryItem = document.getElementById("placesContext_delete_history"); + deleteHistoryItem.label = PlacesUIUtils.getString(`cmd.delete${stringId}.label`); + deleteHistoryItem.accessKey = PlacesUIUtils.getString(`cmd.delete${stringId}.accesskey`); + + let createBookmarkItem = document.getElementById("placesContext_createBookmark"); + createBookmarkItem.label = PlacesUIUtils.getString(`cmd.bookmark${stringId}.label`); + createBookmarkItem.accessKey = PlacesUIUtils.getString(`cmd.bookmark${stringId}.accesskey`); + + return usableItemCount > 0; + }, + + /** + * Select all links in the current view. + */ + selectAll: function PC_selectAll() { + this._view.selectAll(); + }, + + /** + * Opens the bookmark properties for the selected URI Node. + */ + showBookmarkPropertiesForSelection() { + let node = this._view.selectedNode; + if (!node) + return; + + PlacesUIUtils.showBookmarkDialog({ action: "edit", + node, + hiddenRows: [ "folderPicker" ] + }, window.top); + }, + + /** + * Reloads the selected livemark if any. + */ + reloadSelectedLivemark: function PC_reloadSelectedLivemark() { + var selectedNode = this._view.selectedNode; + if (selectedNode) { + let itemId = selectedNode.itemId; + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + aLivemark.reload(true); + }, Cu.reportError); + } + }, + + /** + * Opens the links in the selected folder, or the selected links in new tabs. + */ + openSelectionInTabs: function PC_openLinksInTabs(aEvent) { + var node = this._view.selectedNode; + var nodes = this._view.selectedNodes; + // In the case of no selection, open the root node: + if (!node && !nodes.length) { + node = this._view.result.root; + } + if (node && PlacesUtils.nodeIsContainer(node)) + PlacesUIUtils.openContainerNodeInTabs(node, aEvent, this._view); + else + PlacesUIUtils.openURINodesInTabs(nodes, aEvent, this._view); + }, + + /** + * Shows the Add Bookmark UI for the current insertion point. + * + * @param aType + * the type of the new item (bookmark/livemark/folder) + */ + async newItem(aType) { + let ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let performed = + PlacesUIUtils.showBookmarkDialog({ action: "add", + type: aType, + defaultInsertionPoint: ip, + hiddenRows: [ "folderPicker" ] + }, window.top); + if (performed) { + // Select the new item. + // TODO (Bug 1425555): When we remove places transactions, we might be + // able to improve showBookmarkDialog to return the guid direct, and + // avoid the fetch. + let insertedNode = await PlacesUtils.bookmarks.fetch({ + parentGuid: ip.guid, + index: await ip.getIndex() + }); + + this._view.selectItems([insertedNode.guid], false); + } + }, + + /** + * Create a new Bookmark separator somewhere. + */ + async newSeparator() { + var ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let index = await ip.getIndex(); + let txn = PlacesTransactions.NewSeparator({ parentGuid: ip.guid, index }); + let guid = await txn.transact(); + // Select the new item. + this._view.selectItems([guid], false); + }, + + /** + * Sort the selected folder by name + */ + async sortFolderByName() { + let guid = PlacesUtils.getConcreteItemGuid(this._view.selectedNode); + await PlacesTransactions.SortByName(guid).transact(); + }, + + /** + * Walk the list of folders we're removing in this delete operation, and + * see if the selected node specified is already implicitly being removed + * because it is a child of that folder. + * @param node + * Node to check for containment. + * @param pastFolders + * List of folders the calling function has already traversed + * @return true if the node should be skipped, false otherwise. + */ + _shouldSkipNode: function PC_shouldSkipNode(node, pastFolders) { + /** + * Determines if a node is contained by another node within a resultset. + * @param node + * The node to check for containment for + * @param parent + * The parent container to check for containment in + * @return true if node is a member of parent's children, false otherwise. + */ + function isNodeContainedBy(parent) { + var cursor = node.parent; + while (cursor) { + if (cursor == parent) + return true; + cursor = cursor.parent; + } + return false; + } + + for (var j = 0; j < pastFolders.length; ++j) { + if (isNodeContainedBy(pastFolders[j])) + return true; + } + return false; + }, + + /** + * Creates a set of transactions for the removal of a range of items. + * A range is an array of adjacent nodes in a view. + * @param [in] range + * An array of nodes to remove. Should all be adjacent. + * @param [out] transactions + * An array of transactions. + * @param [optional] removedFolders + * An array of folder nodes that have already been removed. + * @return {Integer} The total number of items affected. + */ + async _removeRange(range, transactions, removedFolders) { + if (!(transactions instanceof Array)) + throw new Error("Must pass a transactions array"); + if (!removedFolders) + removedFolders = []; + + let bmGuidsToRemove = []; + let totalItems = 0; + + for (var i = 0; i < range.length; ++i) { + var node = range[i]; + if (this._shouldSkipNode(node, removedFolders)) + continue; + + totalItems++; + + if (PlacesUtils.nodeIsTagQuery(node.parent)) { + // This is a uri node inside a tag container. It needs a special + // untag transaction. + let tag = node.parent.title; + if (!tag) { + // TODO: Bug 1432405 Try using getConcreteItemGuid. + let tagItemId = PlacesUtils.getConcreteItemId(node.parent); + let tagGuid = await PlacesUtils.promiseItemGuid(tagItemId); + tag = (await PlacesUtils.bookmarks.fetch(tagGuid)).title; + } + transactions.push(PlacesTransactions.Untag({ urls: [node.uri], tag })); + } else if (PlacesUtils.nodeIsTagQuery(node) && node.parent && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.resultType == + Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY) { + // This is a tag container. + // Untag all URIs tagged with this tag only if the tag container is + // child of the "Tags" query in the library, in all other places we + // must only remove the query node. + let tag = node.title; + let URIs = PlacesUtils.tagging.getURIsForTag(tag); + transactions.push(PlacesTransactions.Untag({ tag, urls: URIs })); + } else if (PlacesUtils.nodeIsURI(node) && + PlacesUtils.nodeIsQuery(node.parent) && + PlacesUtils.asQuery(node.parent).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + // This is a uri node inside an history query. + PlacesUtils.history.remove(node.uri).catch(Cu.reportError); + // History deletes are not undoable, so we don't have a transaction. + } else if (node.itemId == -1 && + PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + // This is a dynamically generated history query, like queries + // grouped by site, time or both. Dynamically generated queries don't + // have an itemId even if they are descendants of a bookmark. + this._removeHistoryContainer(node); + // History deletes are not undoable, so we don't have a transaction. + } else { + // This is a common bookmark item. + if (PlacesUtils.nodeIsFolder(node)) { + // If this is a folder we add it to our array of folders, used + // to skip nodes that are children of an already removed folder. + removedFolders.push(node); + } + bmGuidsToRemove.push(node.bookmarkGuid); + } + } + if (bmGuidsToRemove.length) { + transactions.push(PlacesTransactions.Remove({ guids: bmGuidsToRemove })); + } + return totalItems; + }, + + async _removeRowsFromBookmarks() { + let ranges = this._view.removableSelectionRanges; + let transactions = []; + let removedFolders = []; + let totalItems = 0; + + for (let range of ranges) { + totalItems += await this._removeRange(range, transactions, removedFolders); + } + + if (transactions.length > 0) { + await PlacesUIUtils.batchUpdatesForNode(this._view.result, totalItems, async () => { + await PlacesTransactions.batch(transactions); + }); + } + }, + + /** + * Removes the set of selected ranges from history, asynchronously. + * + * @note history deletes are not undoable. + */ + _removeRowsFromHistory: function PC__removeRowsFromHistory() { + let nodes = this._view.selectedNodes; + let URIs = new Set(); + for (let i = 0; i < nodes.length; ++i) { + let node = nodes[i]; + if (PlacesUtils.nodeIsURI(node)) { + URIs.add(node.uri); + } else if (PlacesUtils.nodeIsQuery(node) && + PlacesUtils.asQuery(node).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + this._removeHistoryContainer(node); + } + } + + PlacesUtils.history.remove([...URIs]).catch(Cu.reportError); + }, + + /** + * Removes history visits for an history container node. + * @param [in] aContainerNode + * The container node to remove. + * + * @note history deletes are not undoable. + */ + _removeHistoryContainer: function PC__removeHistoryContainer(aContainerNode) { + if (PlacesUtils.nodeIsHost(aContainerNode)) { + // Site container. + PlacesUtils.history.removePagesFromHost(aContainerNode.title, true); + } else if (PlacesUtils.nodeIsDay(aContainerNode)) { + // Day container. + let query = aContainerNode.getQueries()[0]; + let beginTime = query.beginTime; + let endTime = query.endTime; + if (!query || !beginTime || !endTime) + throw new Error("A valid date container query should exist!"); + // We want to exclude beginTime from the removal because + // removePagesByTimeframe includes both extremes, while date containers + // exclude the lower extreme. So, if we would not exclude it, we would + // end up removing more history than requested. + PlacesUtils.history.removePagesByTimeframe(beginTime + 1, endTime); + } + }, + + /** + * Removes the selection + */ + async remove() { + if (!this._hasRemovableSelection()) + return; + + var root = this._view.result.root; + + if (PlacesUtils.nodeIsFolder(root)) { + await this._removeRowsFromBookmarks(); + } else if (PlacesUtils.nodeIsQuery(root)) { + var queryType = PlacesUtils.asQuery(root).queryOptions.queryType; + if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS) { + await this._removeRowsFromBookmarks(); + } else if (queryType == Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + this._removeRowsFromHistory(); + } else { + throw new Error("implement support for QUERY_TYPE_UNIFIED"); + } + } else + throw new Error("unexpected root"); + }, + + /** + * Fills a DataTransfer object with the content of the selection that can be + * dropped elsewhere. + * @param aEvent + * The dragstart event. + */ + setDataTransfer: function PC_setDataTransfer(aEvent) { + let dt = aEvent.dataTransfer; + + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + + function addData(type, index, feedURI) { + let wrapNode = PlacesUtils.wrapNode(node, type, feedURI); + dt.mozSetDataAt(type, wrapNode, index); + } + + function addURIData(index, feedURI) { + addData(PlacesUtils.TYPE_X_MOZ_URL, index, feedURI); + addData(PlacesUtils.TYPE_UNICODE, index, feedURI); + addData(PlacesUtils.TYPE_HTML, index, feedURI); + } + + try { + let nodes = this._view.draggableSelection; + for (let i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + + // This order is _important_! It controls how this and other + // applications select data to be inserted based on type. + addData(PlacesUtils.TYPE_X_MOZ_PLACE, i); + + // Drop the feed uri for livemark containers + let livemarkInfo = this.getCachedLivemarkInfo(node); + if (livemarkInfo) { + addURIData(i, livemarkInfo.feedURI.spec); + } else if (node.uri) { + addURIData(i); + } + } + } finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + }, + + get clipboardAction() { + let action = {}; + let actionOwner; + try { + let xferable = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + xferable.init(null); + xferable.addDataFlavor(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION); + this.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + xferable.getTransferData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, action, {}); + [action, actionOwner] = + action.value.QueryInterface(Ci.nsISupportsString).data.split(","); + } catch (ex) { + // Paste from external sources don't have any associated action, just + // fallback to a copy action. + return "copy"; + } + // For cuts also check who inited the action, since cuts across different + // instances should instead be handled as copies (The sources are not + // available for this instance). + if (action == "cut" && actionOwner != this.profileName) + action = "copy"; + + return action; + }, + + _releaseClipboardOwnership: function PC__releaseClipboardOwnership() { + if (this.cutNodes.length > 0) { + // This clears the logical clipboard, doesn't remove data. + this.clipboard.emptyClipboard(Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _clearClipboard: function PC__clearClipboard() { + let xferable = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + xferable.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + xferable.addDataFlavor(TYPE); + xferable.setTransferData(TYPE, PlacesUtils.toISupportsString(""), 0); + this.clipboard.setData(xferable, null, Ci.nsIClipboard.kGlobalClipboard); + }, + + _populateClipboard: function PC__populateClipboard(aNodes, aAction) { + // This order is _important_! It controls how this and other applications + // select data to be inserted based on type. + let contents = [ + { type: PlacesUtils.TYPE_X_MOZ_PLACE, entries: [] }, + { type: PlacesUtils.TYPE_X_MOZ_URL, entries: [] }, + { type: PlacesUtils.TYPE_HTML, entries: [] }, + { type: PlacesUtils.TYPE_UNICODE, entries: [] }, + ]; + + // Avoid handling descendants of a copied node, the transactions take care + // of them automatically. + let copiedFolders = []; + aNodes.forEach(function(node) { + if (this._shouldSkipNode(node, copiedFolders)) + return; + if (PlacesUtils.nodeIsFolder(node)) + copiedFolders.push(node); + + let livemarkInfo = this.getCachedLivemarkInfo(node); + let feedURI = livemarkInfo && livemarkInfo.feedURI.spec; + + contents.forEach(function(content) { + content.entries.push( + PlacesUtils.wrapNode(node, content.type, feedURI) + ); + }); + }, this); + + function addData(type, data) { + xferable.addDataFlavor(type); + xferable.setTransferData(type, PlacesUtils.toISupportsString(data), + data.length * 2); + } + + let xferable = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + xferable.init(null); + let hasData = false; + // This order matters here! It controls how this and other applications + // select data to be inserted based on type. + contents.forEach(function(content) { + if (content.entries.length > 0) { + hasData = true; + let glue = + content.type == PlacesUtils.TYPE_X_MOZ_PLACE ? "," : PlacesUtils.endl; + addData(content.type, content.entries.join(glue)); + } + }); + + // Track the exected action in the xferable. This must be the last flavor + // since it's the least preferred one. + // Enqueue a unique instance identifier to distinguish operations across + // concurrent instances of the application. + addData(PlacesUtils.TYPE_X_MOZ_PLACE_ACTION, aAction + "," + this.profileName); + + if (hasData) { + this.clipboard.setData(xferable, + this.cutNodes.length > 0 ? this : null, + Ci.nsIClipboard.kGlobalClipboard); + } + }, + + _cutNodes: [], + get cutNodes() { + return this._cutNodes; + }, + set cutNodes(aNodes) { + let self = this; + function updateCutNodes(aValue) { + self._cutNodes.forEach(function(aNode) { + self._view.toggleCutNode(aNode, aValue); + }); + } + + updateCutNodes(false); + this._cutNodes = aNodes; + updateCutNodes(true); + return aNodes; + }, + + /** + * Copy Bookmarks and Folders to the clipboard + */ + copy: function PC_copy() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + try { + this._populateClipboard(this._view.selectedNodes, "copy"); + } finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + }, + + /** + * Cut Bookmarks and Folders to the clipboard + */ + cut: function PC_cut() { + let result = this._view.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + try { + this._populateClipboard(this._view.selectedNodes, "cut"); + this.cutNodes = this._view.selectedNodes; + } finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + }, + + /** + * Paste Bookmarks and Folders from the clipboard + */ + async paste() { + // No reason to proceed if there isn't a valid insertion point. + let ip = this._view.insertionPoint; + if (!ip) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + let action = this.clipboardAction; + + let xferable = Cc["@mozilla.org/widget/transferable;1"] + .createInstance(Ci.nsITransferable); + xferable.init(null); + // This order matters here! It controls the preferred flavors for this + // paste operation. + [ PlacesUtils.TYPE_X_MOZ_PLACE, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.TYPE_UNICODE, + ].forEach(type => xferable.addDataFlavor(type)); + + this.clipboard.getData(xferable, Ci.nsIClipboard.kGlobalClipboard); + + // Now get the clipboard contents, in the best available flavor. + let data = {}, type = {}, items = []; + try { + xferable.getAnyTransferData(type, data, {}); + data = data.value.QueryInterface(Ci.nsISupportsString).data; + type = type.value; + items = PlacesUtils.unwrapNodes(data, type); + } catch (ex) { + // No supported data exists or nodes unwrap failed, just bail out. + return; + } + + let doCopy = action == "copy"; + let itemsToSelect = await PlacesUIUtils.handleTransferItems(items, ip, doCopy, this._view); + + // Cut/past operations are not repeatable, so clear the clipboard. + if (action == "cut") { + this._clearClipboard(); + } + + if (itemsToSelect.length > 0) + this._view.selectItems(itemsToSelect, false); + }, + + /** + * Cache the livemark info for a node. This allows the controller and the + * views to treat the given node as a livemark. + * @param aNode + * a places result node. + * @param aLivemarkInfo + * a mozILivemarkInfo object. + */ + cacheLivemarkInfo: function PC_cacheLivemarkInfo(aNode, aLivemarkInfo) { + this._cachedLivemarkInfoObjects.set(aNode, aLivemarkInfo); + }, + + /** + * Returns whether or not there's cached mozILivemarkInfo object for a node. + * @param aNode + * a places result node. + * @return true if there's a cached mozILivemarkInfo object for + * aNode, false otherwise. + */ + hasCachedLivemarkInfo: function PC_hasCachedLivemarkInfo(aNode) { + return this._cachedLivemarkInfoObjects.has(aNode); + }, + + /** + * Returns the cached livemark info for a node, if set by cacheLivemarkInfo, + * null otherwise. + * @param aNode + * a places result node. + * @return the mozILivemarkInfo object for aNode, if set, null otherwise. + */ + getCachedLivemarkInfo: function PC_getCachedLivemarkInfo(aNode) { + return this._cachedLivemarkInfoObjects.get(aNode, null); + }, + + /** + * Checks if we can insert into a container. + * @param container + * The container were we are want to drop + */ + disallowInsertion(container) { + if (!container) + throw new Error("empty container"); + // Allow dropping into Tag containers and editable folders. + return !PlacesUtils.nodeIsTagQuery(container) && + (!PlacesUtils.nodeIsFolder(container) || + PlacesUIUtils.isFolderReadOnly(container, this._view)); + }, + + /** + * Determines if a node can be moved. + * + * @param aNode + * A nsINavHistoryResultNode node. + * @return True if the node can be moved, false otherwise. + */ + canMoveNode(node) { + // Only bookmark items are movable. + if (node.itemId == -1) + return false; + + // Once tags and bookmarked are divorced, the tag-query check should be + // removed. + let parentNode = node.parent; + return parentNode != null && + PlacesUtils.nodeIsFolder(parentNode) && + !PlacesUIUtils.isFolderReadOnly(parentNode, this._view) && + !PlacesUtils.nodeIsTagQuery(parentNode); + }, +}; + +/** + * Handles drag and drop operations for views. Note that this is view agnostic! + * You should not use PlacesController._view within these methods, since + * the view that the item(s) have been dropped on was not necessarily active. + * Drop functions are passed the view that is being dropped on. + */ +var PlacesControllerDragHelper = { + /** + * DOM Element currently being dragged over + */ + currentDropTarget: null, + + /** + * Determines if the mouse is currently being dragged over a child node of + * this menu. This is necessary so that the menu doesn't close while the + * mouse is dragging over one of its submenus + * @param node + * The container node + * @return true if the user is dragging over a node within the hierarchy of + * the container, false otherwise. + */ + draggingOverChildNode: function PCDH_draggingOverChildNode(node) { + let currentNode = this.currentDropTarget; + while (currentNode) { + if (currentNode == node) + return true; + currentNode = currentNode.parentNode; + } + return false; + }, + + /** + * @return The current active drag session. Returns null if there is none. + */ + getSession: function PCDH__getSession() { + return this.dragService.getCurrentSession(); + }, + + /** + * Extract the first accepted flavor from a list of flavors. + * @param aFlavors + * The flavors list of type DOMStringList. + */ + getFirstValidFlavor: function PCDH_getFirstValidFlavor(aFlavors) { + for (let i = 0; i < aFlavors.length; i++) { + if (PlacesUIUtils.SUPPORTED_FLAVORS.includes(aFlavors[i])) + return aFlavors[i]; + } + + // If no supported flavor is found, check if data includes text/plain + // contents. If so, request them as text/unicode, a conversion will happen + // automatically. + if (aFlavors.contains("text/plain")) { + return PlacesUtils.TYPE_UNICODE; + } + + return null; + }, + + /** + * Determines whether or not the data currently being dragged can be dropped + * on a places view. + * @param ip + * The insertion point where the items should be dropped. + */ + canDrop: function PCDH_canDrop(ip, dt) { + let dropCount = dt.mozItemCount; + + // Check every dragged item. + for (let i = 0; i < dropCount; i++) { + let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i)); + if (!flavor) + return false; + + // Urls can be dropped on any insertionpoint. + // XXXmano: remember that this method is called for each dragover event! + // Thus we shouldn't use unwrapNodes here at all if possible. + // I think it would be OK to accept bogus data here (e.g. text which was + // somehow wrapped as TAB_DROP_TYPE, this is not in our control, and + // will just case the actual drop to be a no-op), and only rule out valid + // expected cases, which are either unsupported flavors, or items which + // cannot be dropped in the current insertionpoint. The last case will + // likely force us to use unwrapNodes for the private data types of + // places. + if (flavor == TAB_DROP_TYPE) + continue; + + let data = dt.mozGetDataAt(flavor, i); + let nodes; + try { + nodes = PlacesUtils.unwrapNodes(data, flavor); + } catch (e) { + return false; + } + + for (let dragged of nodes) { + // Only bookmarks and urls can be dropped into tag containers. + if (ip.isTag && + dragged.type != PlacesUtils.TYPE_X_MOZ_URL && + (dragged.type != PlacesUtils.TYPE_X_MOZ_PLACE || + (dragged.uri && dragged.uri.startsWith("place:")) )) + return false; + + // The following loop disallows the dropping of a folder on itself or + // on any of its descendants. + if (dragged.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER || + (dragged.uri && dragged.uri.startsWith("place:")) ) { + let parentId = ip.itemId; + while (parentId != PlacesUtils.placesRootId) { + if (dragged.concreteId == parentId || dragged.id == parentId) + return false; + parentId = PlacesUtils.bookmarks.getFolderIdForItem(parentId); + } + } + } + } + return true; + }, + + /** + * Handles the drop of one or more items onto a view. + * + * @param {Object} insertionPoint The insertion point where the items should + * be dropped. + * @param {Object} dt The dataTransfer information for the drop. + * @param {Object} view The view or the tree element. This allows + * batching to take place. + */ + async onDrop(insertionPoint, dt, view) { + let doCopy = ["copy", "link"].includes(dt.dropEffect); + + let dropCount = dt.mozItemCount; + + // Following flavors may contain duplicated data. + let duplicable = new Map(); + duplicable.set(PlacesUtils.TYPE_UNICODE, new Set()); + duplicable.set(PlacesUtils.TYPE_X_MOZ_URL, new Set()); + + // Collect all data from the DataTransfer before processing it, as the + // DataTransfer is only valid during the synchronous handling of the `drop` + // event handler callback. + let nodes = []; + for (let i = 0; i < dropCount; ++i) { + let flavor = this.getFirstValidFlavor(dt.mozTypesAt(i)); + if (!flavor) + return; + + let data = dt.mozGetDataAt(flavor, i); + if (duplicable.has(flavor)) { + let handled = duplicable.get(flavor); + if (handled.has(data)) + continue; + handled.add(data); + } + + if (flavor != TAB_DROP_TYPE) { + nodes = [...nodes, ...PlacesUtils.unwrapNodes(data, flavor)]; + } else if (data instanceof XULElement && data.localName == "tab" && + data.ownerGlobal.isChromeWindow) { + let uri = data.linkedBrowser.currentURI; + let spec = uri ? uri.spec : "about:blank"; + nodes.push({ + uri: spec, + title: data.label, + type: PlacesUtils.TYPE_X_MOZ_URL + }); + } else { + throw new Error("bogus data was passed as a tab"); + } + } + + await PlacesUIUtils.handleTransferItems(nodes, insertionPoint, doCopy, view); + }, + +XPCOMUtils.defineLazyServiceGetter(PlacesControllerDragHelper, "dragService", + "@mozilla.org/widget/dragservice;1", + "nsIDragService"); diff --git a/comm/suite/components/places/content/editBookmarkOverlay.js b/comm/suite/components/places/content/editBookmarkOverlay.js new file mode 100644 index 0000000000..5a4f9c23c3 --- /dev/null +++ b/comm/suite/components/places/content/editBookmarkOverlay.js @@ -0,0 +1,1129 @@ +/* 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 {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed"; +const MAX_FOLDER_ITEM_IN_MENU_LIST = 5; + +var gEditItemOverlay = { + _observersAdded: false, + _staticFoldersListBuilt: false, + + _paneInfo: null, + _setPaneInfo(aInitInfo) { + if (!aInitInfo) + return this._paneInfo = null; + + if ("uris" in aInitInfo && "node" in aInitInfo) + throw new Error("ambiguous pane info"); + if (!("uris" in aInitInfo) && !("node" in aInitInfo)) + throw new Error("Neither node nor uris set for pane info"); + + // We either pass a node or uris. + let node = "node" in aInitInfo ? aInitInfo.node : null; + + // Since there's no true UI for folder shortcuts (they show up just as their target + // folders), when the pane shows for them it's opened in read-only mode, showing the + // properties of the target folder. + let itemId = node ? node.itemId : -1; + let itemGuid = node ? PlacesUtils.getConcreteItemGuid(node) : null; + let isItem = itemId != -1; + let isFolderShortcut = isItem && + node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + let isTag = node && PlacesUtils.nodeIsTagQuery(node); + if (isTag) { + itemId = PlacesUtils.getConcreteItemId(node); + // For now we don't have access to the item guid synchronously for tags, + // so we'll need to fetch it later. + } + let isURI = node && PlacesUtils.nodeIsURI(node); + let uri = isURI ? Services.io.newURI(node.uri) : null; + let title = node ? node.title : null; + let isBookmark = isItem && isURI; + let bulkTagging = !node; + let uris = bulkTagging ? aInitInfo.uris : null; + let visibleRows = new Set(); + let isParentReadOnly = false; + let postData = aInitInfo.postData; + let parentId = -1; + let parentGuid = null; + + if (node && isItem) { + if (!node.parent || (node.parent.itemId > 0 && !node.parent.bookmarkGuid)) { + throw new Error("Cannot use an incomplete node to initialize the edit bookmark panel"); + } + let parent = node.parent; + isParentReadOnly = !PlacesUtils.nodeIsFolder(parent); + if (!isParentReadOnly) { + let folderId = PlacesUtils.getConcreteItemId(parent); + isParentReadOnly = folderId == PlacesUtils.placesRootId || + (!("get" in Object.getOwnPropertyDescriptor(PlacesUIUtils, "leftPaneFolderId")) && + (folderId == PlacesUIUtils.leftPaneFolderId)); + } + parentId = parent.itemId; + parentGuid = parent.bookmarkGuid; + } + + let focusedElement = aInitInfo.focusedElement; + let onPanelReady = aInitInfo.onPanelReady; + + return this._paneInfo = { itemId, itemGuid, parentId, parentGuid, isItem, + isURI, uri, title, + isBookmark, isFolderShortcut, isParentReadOnly, + bulkTagging, uris, + visibleRows, postData, isTag, focusedElement, + onPanelReady }; + }, + + get initialized() { + return this._paneInfo != null; + }, + + // Backwards-compatibility getters + get itemId() { + if (!this.initialized || this._paneInfo.bulkTagging) + return -1; + return this._paneInfo.itemId; + }, + + get uri() { + if (!this.initialized) + return null; + if (this._paneInfo.bulkTagging) + return this._paneInfo.uris[0]; + return this._paneInfo.uri; + }, + + get multiEdit() { + return this.initialized && this._paneInfo.bulkTagging; + }, + + // Check if the pane is initialized to show only read-only fields. + get readOnly() { + // TODO (Bug 1120314): Folder shortcuts are currently read-only due to some + // quirky implementation details (the most important being the "smart" + // semantics of node.title that makes hard to edit the right entry). + // This pane is read-only if: + // * the panel is not initialized + // * the node is a folder shortcut + // * the node is not bookmarked and not a tag container + // * the node is child of a read-only container and is not a bookmarked + // URI nor a tag container + return !this.initialized || + this._paneInfo.isFolderShortcut || + (!this._paneInfo.isItem && !this._paneInfo.isTag) || + (this._paneInfo.isParentReadOnly && !this._paneInfo.isBookmark && !this._paneInfo.isTag); + }, + + // the first field which was edited after this panel was initialized for + // a certain item + _firstEditedField: "", + + _initNamePicker() { + if (this._paneInfo.bulkTagging) + throw new Error("_initNamePicker called unexpectedly"); + + // title may by null, which, for us, is the same as an empty string. + this._initTextField(this._namePicker, this._paneInfo.title || ""); + }, + + _initLocationField() { + if (!this._paneInfo.isURI) + throw new Error("_initLocationField called unexpectedly"); + this._initTextField(this._locationField, this._paneInfo.uri.spec); + }, + + _initDescriptionField() { + if (!this._paneInfo.isItem) + throw new Error("_initDescriptionField called unexpectedly"); + + this._initTextField(this._descriptionField, + PlacesUIUtils.getItemDescription(this._paneInfo.itemId)); + }, + + async _initKeywordField(newKeyword = "") { + if (!this._paneInfo.isBookmark) { + throw new Error("_initKeywordField called unexpectedly"); + } + + // Reset the field status synchronously now, eventually we'll reinit it + // later if we find an existing keyword. This way we can ensure to be in a + // consistent status when reusing the panel across different bookmarks. + this._keyword = newKeyword; + this._initTextField(this._keywordField, newKeyword); + + if (!newKeyword) { + let entries = []; + await PlacesUtils.keywords.fetch({ url: this._paneInfo.uri.spec }, + e => entries.push(e)); + if (entries.length > 0) { + // We show an existing keyword if either POST data was not provided, or + // if the POST data is the same. + let existingKeyword = entries[0].keyword; + let postData = this._paneInfo.postData; + if (postData) { + let sameEntry = entries.find(e => e.postData === postData); + existingKeyword = sameEntry ? sameEntry.keyword : ""; + } + if (existingKeyword) { + this._keyword = existingKeyword; + // Update the text field to the existing keyword. + this._initTextField(this._keywordField, this._keyword); + } + } + } + }, + + _initLoadInSidebar() { + if (!this._paneInfo.isBookmark) + throw new Error("_initLoadInSidebar called unexpectedly"); + + this._loadInSidebarCheckbox.checked = + PlacesUtils.annotations.itemHasAnnotation( + this._paneInfo.itemId, PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO); + }, + + /** + * Initialize the panel. + * + * @param aInfo + * An object having: + * 1. one of the following properties: + * - node: either a result node or a node-like object representing the + * item to be edited. A node-like object must have the following + * properties (with values that match exactly those a result node + * would have): itemId, bookmarkGuid, uri, title, type. + * - uris: an array of uris for bulk tagging. + * + * 2. any of the following optional properties: + * - hiddenRows (Strings array): list of rows to be hidden regardless + * of the item edited. Possible values: "title", "location", + * "description", "keyword", "loadInSidebar", "feedLocation", + * "siteLocation", folderPicker" + */ + initPanel(aInfo) { + if (typeof(aInfo) != "object" || aInfo === null) + throw new Error("aInfo must be an object."); + if ("node" in aInfo) { + try { + aInfo.node.type; + } catch (e) { + // If the lazy loader for |type| generates an exception, it means that + // this bookmark could not be loaded. This sometimes happens when tests + // create a bookmark by clicking the bookmark star, then try to cleanup + // before the bookmark panel has finished opening. Either way, if we + // cannot retrieve the bookmark information, we cannot open the panel. + return; + } + } + + // For sanity ensure that the implementer has uninited the panel before + // trying to init it again, or we could end up leaking due to observers. + if (this.initialized) + this.uninitPanel(false); + + let { parentId, isItem, isURI, + isBookmark, bulkTagging, uris, + visibleRows, focusedElement, + onPanelReady } = this._setPaneInfo(aInfo); + + let showOrCollapse = + (rowId, isAppropriateForInput, nameInHiddenRows = null) => { + let visible = isAppropriateForInput; + if (visible && "hiddenRows" in aInfo && nameInHiddenRows) + visible &= !aInfo.hiddenRows.includes(nameInHiddenRows); + if (visible) + visibleRows.add(rowId); + return !(this._element(rowId).collapsed = !visible); + }; + + if (showOrCollapse("nameRow", !bulkTagging, "name")) { + this._initNamePicker(); + this._namePicker.readOnly = this.readOnly; + } + + // In some cases we want to hide the location field, since it's not + // human-readable, but we still want to initialize it. + showOrCollapse("locationRow", isURI, "location"); + if (isURI) { + this._initLocationField(); + this._locationField.readOnly = this.readOnly; + } + + // hide the description field for + if (showOrCollapse("descriptionRow", isItem && !this.readOnly, + "description")) { + this._initDescriptionField(); + this._descriptionField.readOnly = this.readOnly; + } + + if (showOrCollapse("keywordRow", isBookmark, "keyword")) { + this._initKeywordField().catch(Cu.reportError); + this._keywordField.readOnly = this.readOnly; + } + + // Collapse the tag selector if the item does not accept tags. + if (showOrCollapse("tagsRow", isURI || bulkTagging, "tags")) + this._initTagsField(); + else if (!this._element("tagsSelectorRow").collapsed) + this.toggleTagsSelector(); + + // Load in sidebar. + if (showOrCollapse("loadInSidebarCheckbox", isBookmark, "loadInSidebar")) { + this._initLoadInSidebar(); + } + + // Folder picker. + // Technically we should check that the item is not moveable, but that's + // not cheap (we don't always have the parent), and there's no use case for + // this (it's only the Star UI that shows the folderPicker) + if (showOrCollapse("folderRow", isItem, "folderPicker")) { + this._initFolderMenuList(parentId).catch(Cu.reportError); + } + + // Selection count. + if (showOrCollapse("selectionCount", bulkTagging)) { + this._element("itemsCountText").value = + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + uris.length, + [uris.length]); + } + + // Observe changes. + if (!this._observersAdded) { + PlacesUtils.bookmarks.addObserver(this); + window.addEventListener("unload", this); + this._observersAdded = true; + } + + let focusElement = () => { + // The focusedElement possible values are: + // * preferred: focus the field that the user touched first the last + // time the pane was shown (either namePicker or tagsField) + // * first: focus the first non collapsed textbox + // Note: since all controls are collapsed by default, we don't get the + // default XUL dialog behavior, that selects the first control, so we set + // the focus explicitly. + let elt; + if (focusedElement === "preferred") { + /* eslint-disable no-undef */ + elt = this._element(Services.prefs.getCharPref("browser.bookmarks.editDialog.firstEditField")); + /* eslint-enable no-undef */ + } else if (focusedElement === "first") { + elt = document.querySelector("textbox:not([collapsed=true])"); + } + if (elt) { + elt.focus(); + elt.select(); + } + }; + + if (onPanelReady) { + onPanelReady(focusElement); + } else { + focusElement(); + } + }, + + /** + * Finds tags that are in common among this._currentInfo.uris; + */ + _getCommonTags() { + if ("_cachedCommonTags" in this._paneInfo) + return this._paneInfo._cachedCommonTags; + + let uris = [...this._paneInfo.uris]; + let firstURI = uris.shift(); + let commonTags = new Set(PlacesUtils.tagging.getTagsForURI(firstURI)); + if (commonTags.size == 0) + return this._cachedCommonTags = []; + + for (let uri of uris) { + let curentURITags = PlacesUtils.tagging.getTagsForURI(uri); + for (let tag of commonTags) { + if (!curentURITags.includes(tag)) { + commonTags.delete(tag); + if (commonTags.size == 0) + return this._paneInfo.cachedCommonTags = []; + } + } + } + return this._paneInfo._cachedCommonTags = [...commonTags]; + }, + + _initTextField(aElement, aValue) { + if (aElement.value != aValue) { + aElement.value = aValue; + + // Clear the editor's undo stack, but note that editor may be null here. + aElement.editor?.clearUndoRedo(); + } + }, + + /** + * Appends a menu-item representing a bookmarks folder to a menu-popup. + * @param aMenupopup + * The popup to which the menu-item should be added. + * @param aFolderId + * The identifier of the bookmarks folder. + * @param aTitle + * The title to use as a label. + * @return the new menu item. + */ + _appendFolderItemToMenupopup(aMenupopup, aFolderId, aTitle) { + // First make sure the folders-separator is visible + this._element("foldersSeparator").hidden = false; + + var folderMenuItem = document.createElement("menuitem"); + var folderTitle = aTitle; + folderMenuItem.folderId = aFolderId; + folderMenuItem.setAttribute("label", folderTitle); + folderMenuItem.className = "menuitem-iconic folder-icon"; + aMenupopup.appendChild(folderMenuItem); + return folderMenuItem; + }, + + async _initFolderMenuList(aSelectedFolder) { + // clean up first + var menupopup = this._folderMenuList.menupopup; + while (menupopup.childNodes.length > 6) + menupopup.removeChild(menupopup.lastChild); + + // Build the static list + if (!this._staticFoldersListBuilt) { + let unfiledItem = this._element("unfiledRootItem"); + unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle"); + unfiledItem.folderId = PlacesUtils.unfiledBookmarksFolderId; + let bmMenuItem = this._element("bmRootItem"); + bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle"); + bmMenuItem.folderId = PlacesUtils.bookmarksMenuFolderId; + let toolbarItem = this._element("toolbarFolderItem"); + toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle"); + toolbarItem.folderId = PlacesUtils.toolbarFolderId; + this._staticFoldersListBuilt = true; + } + + // List of recently used folders: + var folderIds = + PlacesUtils.annotations.getItemsWithAnnotation(LAST_USED_ANNO); + + /** + * The value of the LAST_USED_ANNO annotation is the time (in the form of + * Date.getTime) at which the folder has been last used. + * + * First we build the annotated folders array, each item has both the + * folder identifier and the time at which it was last-used by this dialog + * set. Then we sort it descendingly based on the time field. + */ + this._recentFolders = []; + for (let folderId of folderIds) { + var lastUsed = + PlacesUtils.annotations.getItemAnnotation(folderId, LAST_USED_ANNO); + let guid = await PlacesUtils.promiseItemGuid(folderId); + let bm = await PlacesUtils.bookmarks.fetch(guid); + // Since this could be a root mobile folder, we should get the proper + // title. + let title = PlacesUtils.bookmarks.getLocalizedTitle(bm); + this._recentFolders.push({ folderId, guid, title, lastUsed }); + } + this._recentFolders.sort(function(a, b) { + if (b.lastUsed < a.lastUsed) + return -1; + if (b.lastUsed > a.lastUsed) + return 1; + return 0; + }); + + var numberOfItems = Math.min(MAX_FOLDER_ITEM_IN_MENU_LIST, + this._recentFolders.length); + for (let i = 0; i < numberOfItems; i++) { + await this._appendFolderItemToMenupopup(menupopup, + this._recentFolders[i].folderId, + this._recentFolders[i].title); + } + + let selectedFolderGuid = await PlacesUtils.promiseItemGuid(aSelectedFolder); + let title = (await PlacesUtils.bookmarks.fetch(selectedFolderGuid)).title; + var defaultItem = this._getFolderMenuItem(aSelectedFolder, title); + this._folderMenuList.selectedItem = defaultItem; + + // Set a selectedIndex attribute to show special icons + this._folderMenuList.setAttribute("selectedIndex", + this._folderMenuList.selectedIndex); + + // Hide the folders-separator if no folder is annotated as recently-used + this._element("foldersSeparator").hidden = (menupopup.childNodes.length <= 6); + this._folderMenuList.disabled = this.readOnly; + }, + + QueryInterface: + XPCOMUtils.generateQI([Ci.nsIDOMEventListener, + Ci.nsINavBookmarkObserver]), + + _element(aID) { + return document.getElementById("editBMPanel_" + aID); + }, + + uninitPanel(aHideCollapsibleElements) { + if (aHideCollapsibleElements) { + // Hide the folder tree if it was previously visible. + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) + this.toggleFolderTreeVisibility(); + + // Hide the tag selector if it was previously visible. + var tagsSelectorRow = this._element("tagsSelectorRow"); + if (!tagsSelectorRow.collapsed) + this.toggleTagsSelector(); + } + + if (this._observersAdded) { + PlacesUtils.bookmarks.removeObserver(this); + this._observersAdded = false; + } + + this._setPaneInfo(null); + this._firstEditedField = ""; + }, + + onTagsFieldChange() { + // Check for _paneInfo existing as the dialog may be closing but receiving + // async updates from unresolved promises. + if (this._paneInfo && + (this._paneInfo.isURI || this._paneInfo.bulkTagging)) { + this._updateTags().then( + anyChanges => { + // Check _paneInfo here as we might be closing the dialog. + if (anyChanges && this._paneInfo) + this._mayUpdateFirstEditField("tagsField"); + }, Cu.reportError); + } + }, + + /** + * For a given array of currently-set tags and the tags-input-field + * value, returns which tags should be removed and which should be added in + * the form of { removedTags: [...], newTags: [...] }. + */ + _getTagsChanges(aCurrentTags) { + let inputTags = this._getTagsArrayFromTagsInputField(); + + // Optimize the trivial cases (which are actually the most common). + if (inputTags.length == 0 && aCurrentTags.length == 0) + return { newTags: [], removedTags: [] }; + if (inputTags.length == 0) + return { newTags: [], removedTags: aCurrentTags }; + if (aCurrentTags.length == 0) + return { newTags: inputTags, removedTags: [] }; + + // Do not remove tags that may be reinserted with a different + // case, since the tagging service may handle those more efficiently. + let lcInputTags = inputTags.map(t => t.toLowerCase()); + let removedTags = aCurrentTags.filter(t => !lcInputTags.includes(t.toLowerCase())); + let newTags = inputTags.filter(t => !aCurrentTags.includes(t)); + return { removedTags, newTags }; + }, + + // Adds and removes tags for one or more uris. + _setTagsFromTagsInputField(aCurrentTags, aURIs) { + let { removedTags, newTags } = this._getTagsChanges(aCurrentTags); + if (removedTags.length + newTags.length == 0) + return false; + + let setTags = async function() { + if (removedTags.length > 0) { + await PlacesTransactions.Untag({ urls: aURIs, tags: removedTags }) + .transact(); + } + if (newTags.length > 0) { + await PlacesTransactions.Tag({ urls: aURIs, tags: newTags }) + .transact(); + } + }; + + // Only in the library info-pane it's safe (and necessary) to batch these. + // TODO bug 1093030: cleanup this mess when the bookmarksProperties dialog + // and star UI code don't "run a batch in the background". + if (window.document.documentElement.id == "places") + PlacesTransactions.batch(setTags).catch(Cu.reportError); + else + setTags().catch(Cu.reportError); + return true; + }, + + async _updateTags() { + let uris = this._paneInfo.bulkTagging ? + this._paneInfo.uris : [this._paneInfo.uri]; + let currentTags = this._paneInfo.bulkTagging ? + await this._getCommonTags() : + PlacesUtils.tagging.getTagsForURI(uris[0]); + let anyChanges = this._setTagsFromTagsInputField(currentTags, uris); + if (!anyChanges) + return false; + + // The panel could have been closed in the meanwhile. + if (!this._paneInfo) + return false; + + // Ensure the tagsField is in sync, clean it up from empty tags + currentTags = this._paneInfo.bulkTagging ? + this._getCommonTags() : + PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri); + this._initTextField(this._tagsField, currentTags.join(", "), false); + return true; + }, + + /** + * Stores the first-edit field for this dialog, if the passed-in field + * is indeed the first edited field + * @param aNewField + * the id of the field that may be set (without the "editBMPanel_" + * prefix) + */ + _mayUpdateFirstEditField(aNewField) { + // * The first-edit-field behavior is not applied in the multi-edit case + // * if this._firstEditedField is already set, this is not the first field, + // so there's nothing to do + if (this._paneInfo.bulkTagging || this._firstEditedField) + return; + + this._firstEditedField = aNewField; + + // set the pref + Services.prefs.setCharPref("browser.bookmarks.editDialog.firstEditField", aNewField); + }, + + async onNamePickerChange() { + if (this.readOnly || !(this._paneInfo.isItem || this._paneInfo.isTag)) + return; + + // Here we update either the item title or its cached static title + let newTitle = this._namePicker.value; + if (!newTitle && this._paneInfo.isTag) { + // We don't allow setting an empty title for a tag, restore the old one. + this._initNamePicker(); + } else { + this._mayUpdateFirstEditField("namePicker"); + + let guid = this._paneInfo.isTag + ? (await PlacesUtils.promiseItemGuid(this._paneInfo.itemId)) + : this._paneInfo.itemGuid; + await PlacesTransactions.EditTitle({ guid, title: newTitle }).transact(); + } + }, + + onDescriptionFieldChange() { + if (this.readOnly || !this._paneInfo.isItem) + return; + + let description = this._element("descriptionField").value; + if (description != PlacesUIUtils.getItemDescription(this._paneInfo.itemId)) { + let annotation = + { name: PlacesUIUtils.DESCRIPTION_ANNO, value: description }; + let guid = this._paneInfo.itemGuid; + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Cu.reportError); + } + }, + + onLocationFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) + return; + + let newURI; + try { + newURI = PlacesUIUtils.createFixedURI(this._locationField.value); + } catch (ex) { + // TODO: Bug 1089141 - Provide some feedback about the invalid url. + return; + } + + if (this._paneInfo.uri.equals(newURI)) + return; + + let guid = this._paneInfo.itemGuid; + PlacesTransactions.EditUrl({ guid, url: newURI }) + .transact().catch(Cu.reportError); + }, + + onKeywordFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) + return; + + let oldKeyword = this._keyword; + let keyword = this._keyword = this._keywordField.value; + let postData = this._paneInfo.postData; + let guid = this._paneInfo.itemGuid; + PlacesTransactions.EditKeyword({ guid, keyword, postData, oldKeyword }) + .transact().catch(Cu.reportError); + }, + + onLoadInSidebarCheckboxCommand() { + if (!this.initialized || !this._paneInfo.isBookmark) + return; + + let annotation = { name: PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO }; + if (this._loadInSidebarCheckbox.checked) + annotation.value = true; + + let guid = this._paneInfo.itemGuid; + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Cu.reportError); + }, + + toggleFolderTreeVisibility() { + var expander = this._element("foldersExpander"); + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) { + expander.className = "expander-down"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextdown")); + folderTreeRow.collapsed = true; + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = false; + } else { + expander.className = "expander-up"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextup")); + folderTreeRow.collapsed = false; + + // XXXmano: Ideally we would only do this once, but for some odd reason, + // the editable mode set on this tree, together with its collapsed state + // breaks the view. + const FOLDER_TREE_PLACE_URI = + "place:excludeItems=1&excludeQueries=1&excludeReadOnlyFolders=1&type=" + + Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY; + this._folderTree.place = FOLDER_TREE_PLACE_URI; + + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = true; + this._folderTree.selectItems([this._paneInfo.parentGuid]); + this._folderTree.focus(); + } + }, + + /** + * Get the corresponding menu-item in the folder-menu-list for a bookmarks + * folder if such an item exists. Otherwise, this creates a menu-item for the + * folder. If the items-count limit (see MAX_FOLDERS_IN_MENU_LIST) is reached, + * the new item replaces the last menu-item. + * @param aFolderId + * The identifier of the bookmarks folder. + * @param aTitle + * The title to use in case of menuitem creation. + * @return handle to the menuitem. + */ + _getFolderMenuItem(aFolderId, aTitle) { + let menupopup = this._folderMenuList.menupopup; + let menuItem = Array.prototype.find.call( + menupopup.childNodes, item => item.folderId === aFolderId); + if (menuItem !== undefined) + return menuItem; + + // 3 special folders + separator + folder-items-count limit + if (menupopup.childNodes.length == 4 + MAX_FOLDER_ITEM_IN_MENU_LIST) + menupopup.removeChild(menupopup.lastChild); + + return this._appendFolderItemToMenupopup(menupopup, aFolderId, aTitle); + }, + + async onFolderMenuListCommand(aEvent) { + // Check for _paneInfo existing as the dialog may be closing but receiving + // async updates from unresolved promises. + if (!this._paneInfo) { + return; + } + // Set a selectedIndex attribute to show special icons + this._folderMenuList.setAttribute("selectedIndex", + this._folderMenuList.selectedIndex); + + if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") { + // reset the selection back to where it was and expand the tree + // (this menu-item is hidden when the tree is already visible + let item = this._getFolderMenuItem(this._paneInfo.parentId, + this._paneInfo.title); + this._folderMenuList.selectedItem = item; + // XXXmano HACK: setTimeout 100, otherwise focus goes back to the + // menulist right away + setTimeout(() => this.toggleFolderTreeVisibility(), 100); + return; + } + + // Move the item + let containerId = this._folderMenuList.selectedItem.folderId; + if (this._paneInfo.parentId != containerId && + this._paneInfo.itemId != containerId) { + let newParentGuid = await PlacesUtils.promiseItemGuid(containerId); + let guid = this._paneInfo.itemGuid; + await PlacesTransactions.Move({ guid, newParentGuid }).transact(); + + // Mark the containing folder as recently-used if it isn't in the + // static list + if (containerId != PlacesUtils.unfiledBookmarksFolderId && + containerId != PlacesUtils.toolbarFolderId && + containerId != PlacesUtils.bookmarksMenuFolderId) { + this._markFolderAsRecentlyUsed(containerId) + .catch(Cu.reportError); + } + + // Auto-show the bookmarks toolbar when adding / moving an item there. + if (containerId == PlacesUtils.toolbarFolderId) { + Services.obs.notifyObservers(null, "autoshow-bookmarks-toolbar"); + } + } + + // Update folder-tree selection + var folderTreeRow = this._element("folderTreeRow"); + if (!folderTreeRow.collapsed) { + var selectedNode = this._folderTree.selectedNode; + if (!selectedNode || + PlacesUtils.getConcreteItemId(selectedNode) != containerId) + this._folderTree.selectItems([containerId]); + } + }, + + onFolderTreeSelect() { + var selectedNode = this._folderTree.selectedNode; + + // Disable the "New Folder" button if we cannot create a new folder + this._element("newFolderButton") + .disabled = !this._folderTree.insertionPoint || !selectedNode; + + if (!selectedNode) + return; + + var folderId = PlacesUtils.getConcreteItemId(selectedNode); + if (this._folderMenuList.selectedItem.folderId == folderId) + return; + + var folderItem = this._getFolderMenuItem(folderId, selectedNode.title); + this._folderMenuList.selectedItem = folderItem; + folderItem.doCommand(); + }, + + async _markFolderAsRecentlyUsed(aFolderId) { + // Expire old unused recent folders. + let guids = []; + while (this._recentFolders.length > MAX_FOLDER_ITEM_IN_MENU_LIST) { + let folderId = this._recentFolders.pop().folderId; + let guid = await PlacesUtils.promiseItemGuid(folderId); + guids.push(guid); + } + if (guids.length > 0) { + let annotation = this._getLastUsedAnnotationObject(false); + PlacesTransactions.Annotate({ guids, annotation }) + .transact().catch(Cu.reportError); + } + + // Mark folder as recently used + let annotation = this._getLastUsedAnnotationObject(true); + let guid = await PlacesUtils.promiseItemGuid(aFolderId); + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Cu.reportError); + }, + + /** + * Returns an object which could then be used to set/unset the + * LAST_USED_ANNO annotation for a folder. + * + * @param aLastUsed + * Whether to set or unset the LAST_USED_ANNO annotation. + * @returns an object representing the annotation which could then be used + * with the transaction manager. + */ + _getLastUsedAnnotationObject(aLastUsed) { + return { name: LAST_USED_ANNO, + value: aLastUsed ? new Date().getTime() : null }; + }, + + _rebuildTagsSelectorList() { + let tagsSelector = this._element("tagsSelector"); + let tagsSelectorRow = this._element("tagsSelectorRow"); + if (tagsSelectorRow.collapsed) + return; + + // Save the current scroll position and restore it after the rebuild. + let firstIndex = tagsSelector.getIndexOfFirstVisibleRow(); + let selectedIndex = tagsSelector.selectedIndex; + let selectedTag = selectedIndex >= 0 ? tagsSelector.selectedItem.label + : null; + + while (tagsSelector.hasChildNodes()) { + tagsSelector.removeChild(tagsSelector.lastChild); + } + + let tagsInField = this._getTagsArrayFromTagsInputField(); + let allTags = PlacesUtils.tagging.allTags; + for (let tag of allTags) { + let elt = document.createElement("listitem"); + elt.setAttribute("type", "checkbox"); + elt.setAttribute("label", tag); + if (tagsInField.includes(tag)) + elt.setAttribute("checked", "true"); + tagsSelector.appendChild(elt); + if (selectedTag === tag) + selectedIndex = tagsSelector.getIndexOfItem(elt); + } + + // Restore position. + // The listbox allows to scroll only if the required offset doesn't + // overflow its capacity, thus need to adjust the index for removals. + firstIndex = + Math.min(firstIndex, + tagsSelector.itemCount - tagsSelector.getNumberOfVisibleRows()); + tagsSelector.scrollToIndex(firstIndex); + if (selectedIndex >= 0 && tagsSelector.itemCount > 0) { + selectedIndex = Math.min(selectedIndex, tagsSelector.itemCount - 1); + tagsSelector.selectedIndex = selectedIndex; + tagsSelector.ensureIndexIsVisible(selectedIndex); + } + }, + + toggleTagsSelector() { + var tagsSelector = this._element("tagsSelector"); + var tagsSelectorRow = this._element("tagsSelectorRow"); + var expander = this._element("tagsSelectorExpander"); + if (tagsSelectorRow.collapsed) { + expander.className = "expander-up"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextup")); + tagsSelectorRow.collapsed = false; + this._rebuildTagsSelectorList(); + + // This is a no-op if we've added the listener. + tagsSelector.addEventListener("CheckboxStateChange", this); + } else { + expander.className = "expander-down"; + expander.setAttribute("tooltiptext", + expander.getAttribute("tooltiptextdown")); + tagsSelectorRow.collapsed = true; + } + }, + + /** + * Splits "tagsField" element value, returning an array of valid tag strings. + * + * @return Array of tag strings found in the field value. + */ + _getTagsArrayFromTagsInputField() { + let tags = this._element("tagsField").value; + return tags.trim() + .split(/\s*,\s*/) // Split on commas and remove spaces. + .filter(tag => tag.length > 0); // Kill empty tags. + }, + + async newFolder() { + let ip = this._folderTree.insertionPoint; + + // default to the bookmarks menu folder + if (!ip) { + ip = new PlacesInsertionPoint({ + parentId: PlacesUtils.bookmarksMenuFolderId, + parentGuid: PlacesUtils.bookmarks.menuGuid + }); + } + + // XXXmano: add a separate "New Folder" string at some point... + let title = this._element("newFolderButton").label; + await PlacesTransactions.NewFolder({ parentGuid: ip.guid, title, + index: await ip.getIndex() }) + .transact().catch(Cu.reportError); + + this._folderTree.focus(); + this._folderTree.selectItems([ip.itemId]); + PlacesUtils.asContainer(this._folderTree.selectedNode).containerOpen = true; + this._folderTree.selectItems([this._lastNewItem]); + this._folderTree.startEditing(this._folderTree.view.selection.currentIndex, + this._folderTree.columns.getFirstColumn()); + }, + + // nsIDOMEventListener + handleEvent(aEvent) { + switch (aEvent.type) { + case "CheckboxStateChange": + // Update the tags field when items are checked/unchecked in the listbox + let tags = this._getTagsArrayFromTagsInputField(); + let tagCheckbox = aEvent.target; + + let curTagIndex = tags.indexOf(tagCheckbox.label); + let tagsSelector = this._element("tagsSelector"); + tagsSelector.selectedItem = tagCheckbox; + + if (tagCheckbox.checked) { + if (curTagIndex == -1) + tags.push(tagCheckbox.label); + } else if (curTagIndex != -1) { + tags.splice(curTagIndex, 1); + } + this._element("tagsField").value = tags.join(", "); + this._updateTags(); + break; + case "unload": + this.uninitPanel(false); + break; + } + }, + + _initTagsField() { + let tags; + if (this._paneInfo.isURI) + tags = PlacesUtils.tagging.getTagsForURI(this._paneInfo.uri); + else if (this._paneInfo.bulkTagging) + tags = this._getCommonTags(); + else + throw new Error("_promiseTagsStr called unexpectedly"); + + this._initTextField(this._tagsField, tags.join(", ")); + }, + + async _onTagsChange(guid, changedURI = null) { + let paneInfo = this._paneInfo; + let updateTagsField = false; + if (paneInfo.isURI) { + if (paneInfo.isBookmark && guid == paneInfo.itemGuid) { + updateTagsField = true; + } else if (!paneInfo.isBookmark) { + if (!changedURI) { + let href = (await PlacesUtils.bookmarks.fetch(guid)).url.href; + changedURI = Services.io.newURI(href); + } + updateTagsField = changedURI.equals(paneInfo.uri); + } + } else if (paneInfo.bulkTagging) { + if (!changedURI) { + let href = (await PlacesUtils.bookmarks.fetch(guid)).url.href; + changedURI = Services.io.newURI(href); + } + if (paneInfo.uris.some(uri => uri.equals(changedURI))) { + updateTagsField = true; + delete this._paneInfo._cachedCommonTags; + } + } else { + throw new Error("_onTagsChange called unexpectedly"); + } + + if (updateTagsField) { + this._initTagsField(); + // Any tags change should be reflected in the tags selector. + if (this._element("tagsSelector")) { + this._rebuildTagsSelectorList(); + } + } + }, + + _onItemTitleChange(aItemId, aNewTitle) { + if (aItemId == this._paneInfo.itemId) { + this._paneInfo.title = aNewTitle; + this._initTextField(this._namePicker, aNewTitle); + } else if (this._paneInfo.visibleRows.has("folderRow")) { + // If the title of a folder which is listed within the folders + // menulist has been changed, we need to update the label of its + // representing element. + let menupopup = this._folderMenuList.menupopup; + for (let menuitem of menupopup.childNodes) { + if ("folderId" in menuitem && menuitem.folderId == aItemId) { + menuitem.label = aNewTitle; + break; + } + } + } + // We need to also update title of recent folders. + if (this._recentFolders) { + for (let folder of this._recentFolders) { + if (folder.folderId == aItemId) { + folder.title = aNewTitle; + break; + } + } + } + }, + + // nsINavBookmarkObserver + onItemChanged(aItemId, aProperty, aIsAnnotationProperty, aValue, + aLastModified, aItemType, aParentId, aGuid) { + if (aProperty == "tags" && this._paneInfo.visibleRows.has("tagsRow")) { + this._onTagsChange(aGuid).catch(Cu.reportError); + return; + } + if (aProperty == "title" && (this._paneInfo.isItem || this._paneInfo.isTag)) { + // This also updates titles of folders in the folder menu list. + this._onItemTitleChange(aItemId, aValue); + return; + } + + if (!this._paneInfo.isItem || this._paneInfo.itemId != aItemId) { + return; + } + + switch (aProperty) { + case "uri": + let newURI = Services.io.newURI(aValue); + if (!newURI.equals(this._paneInfo.uri)) { + this._paneInfo.uri = newURI; + if (this._paneInfo.visibleRows.has("locationRow")) + this._initLocationField(); + + if (this._paneInfo.visibleRows.has("tagsRow")) { + delete this._paneInfo._cachedCommonTags; + this._onTagsChange(aGuid, newURI).catch(Cu.reportError); + } + } + break; + case "keyword": + if (this._paneInfo.visibleRows.has("keywordRow")) + this._initKeywordField(aValue).catch(Cu.reportError); + break; + case PlacesUIUtils.DESCRIPTION_ANNO: + if (this._paneInfo.visibleRows.has("descriptionRow")) + this._initDescriptionField(); + break; + case PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO: + if (this._paneInfo.visibleRows.has("loadInSidebarCheckbox")) + this._initLoadInSidebar(); + break; + } + }, + + onItemMoved(id, oldParentId, oldIndex, newParentId, newIndex, type, guid, + oldParentGuid, newParentGuid) { + if (!this._paneInfo.isItem || this._paneInfo.itemId != id) { + return; + } + + this._paneInfo.parentId = newParentId; + this._paneInfo.parentGuid = newParentGuid; + + if (!this._paneInfo.visibleRows.has("folderRow") || + newParentId == this._folderMenuList.selectedItem.folderId) { + return; + } + + // Just setting selectItem _does not_ trigger oncommand, so we don't + // recurse. + PlacesUtils.bookmarks.fetch(newParentGuid).then(bm => { + this._folderMenuList.selectedItem = this._getFolderMenuItem(newParentId, + bm.title); + }); + }, + + onItemAdded(aItemId, aParentId, aIndex, aItemType, aURI) { + this._lastNewItem = aItemId; + }, + + onItemRemoved() { }, + onBeginUpdateBatch() { }, + onEndUpdateBatch() { }, + onItemVisited() { }, +}; + + +for (let elt of ["folderMenuList", "folderTree", "namePicker", + "locationField", "descriptionField", "keywordField", + "tagsField", "loadInSidebarCheckbox"]) { + let eltScoped = elt; + XPCOMUtils.defineLazyGetter(gEditItemOverlay, `_${eltScoped}`, + () => gEditItemOverlay._element(eltScoped)); +} diff --git a/comm/suite/components/places/content/editBookmarkOverlay.xul b/comm/suite/components/places/content/editBookmarkOverlay.xul new file mode 100644 index 0000000000..c4ff90a2bf --- /dev/null +++ b/comm/suite/components/places/content/editBookmarkOverlay.xul @@ -0,0 +1,191 @@ +<!-- 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/. --> + +<!DOCTYPE overlay [ +<!ENTITY % editBookmarkOverlayDTD SYSTEM "chrome://communicator/locale/places/editBookmarkOverlay.dtd"> +%editBookmarkOverlayDTD; +]> + +<?xml-stylesheet href="chrome://communicator/skin/places/editBookmarkOverlay.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/bookmarks.css"?> + +<overlay id="editBookmarkOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="editBookmarkPanelContent" flex="1"> + <hbox id="editBMPanel_selectionCount" pack="center"> + <label id="editBMPanel_itemsCountText"/> + </hbox> + + <grid id="editBookmarkPanelGrid" flex="1"> + <columns id="editBMPanel_columns"> + <column id="editBMPanel_labelColumn" /> + <column flex="1" id="editBMPanel_editColumn" /> + </columns> + <rows id="editBMPanel_rows"> + <row id="editBMPanel_nameRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.name.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.name.accesskey;" + control="editBMPanel_namePicker"/> + <textbox id="editBMPanel_namePicker" + onchange="gEditItemOverlay.onNamePickerChange().catch(Cu.reportError);"/> + </row> + + <row id="editBMPanel_locationRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.location.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.location.accesskey;" + control="editBMPanel_locationField"/> + <textbox id="editBMPanel_locationField" + class="uri-element" + onchange="gEditItemOverlay.onLocationFieldChange();"/> + </row> + + <row id="editBMPanel_folderRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.folder.label;" + class="editBMPanel_rowLabel" + control="editBMPanel_folderMenuList"/> + <hbox flex="1" align="center"> + <menulist id="editBMPanel_folderMenuList" + class="folder-icon" + flex="1" + oncommand="gEditItemOverlay.onFolderMenuListCommand(event).catch(Cu.reportError);"> + <menupopup> + <!-- Static item for special folders --> + <menuitem id="editBMPanel_toolbarFolderItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_bmRootItem" + class="menuitem-iconic folder-icon"/> + <menuitem id="editBMPanel_unfiledRootItem" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_chooseFolderSeparator"/> + <menuitem id="editBMPanel_chooseFolderMenuItem" + label="&editBookmarkOverlay.choose.label;" + class="menuitem-iconic folder-icon"/> + <menuseparator id="editBMPanel_foldersSeparator" hidden="true"/> + </menupopup> + </menulist> + <button id="editBMPanel_foldersExpander" + class="expander-down" + tooltiptext="&editBookmarkOverlay.foldersExpanderDown.tooltip;" + tooltiptextdown="&editBookmarkOverlay.foldersExpanderDown.tooltip;" + tooltiptextup="&editBookmarkOverlay.expanderUp.tooltip;" + oncommand="gEditItemOverlay.toggleFolderTreeVisibility();"/> + </hbox> + </row> + + <row id="editBMPanel_folderTreeRow" + collapsed="true" + flex="1"> + <spacer/> + <vbox flex="1"> + <tree id="editBMPanel_folderTree" + flex="1" + class="placesTree" + type="places" + treelines="true" + height="150" + minheight="150" + editable="true" + onselect="gEditItemOverlay.onFolderTreeSelect();" + hidecolumnpicker="true"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <hbox id="editBMPanel_newFolderBox"> + <button label="&editBookmarkOverlay.newFolderButton.label;" + id="editBMPanel_newFolderButton" + accesskey="&editBookmarkOverlay.newFolderButton.accesskey;" + oncommand="gEditItemOverlay.newFolder().catch(Cu.reportError);"/> + </hbox> + </vbox> + </row> + + <row id="editBMPanel_tagsRow" + align="center" + collapsed="true"> + <label value="&editBookmarkOverlay.tags.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.tags.accesskey;" + control="editBMPanel_tagsField"/> + <hbox flex="1" align="center"> + <textbox id="editBMPanel_tagsField" + type="autocomplete" + flex="1" + autocompletesearch="places-tag-autocomplete" + autocompletepopup="PopupAutoComplete" + completedefaultindex="true" + tabscrolling="true" + placeholder="&editBookmarkOverlay.tagsEmptyDesc.label;" + onchange="gEditItemOverlay.onTagsFieldChange();"/> + <button id="editBMPanel_tagsSelectorExpander" + class="expander-down" + tooltiptext="&editBookmarkOverlay.tagsExpanderDown.tooltip;" + tooltiptextdown="&editBookmarkOverlay.tagsExpanderDown.tooltip;" + tooltiptextup="&editBookmarkOverlay.expanderUp.tooltip;" + oncommand="gEditItemOverlay.toggleTagsSelector();"/> + </hbox> + </row> + + <row id="editBMPanel_tagsSelectorRow" + align="center" + collapsed="true"> + <spacer/> + <listbox id="editBMPanel_tagsSelector" + height="150"/> + </row> + + <row id="editBMPanel_keywordRow" + align="center" + collapsed="true"> + <observes element="additionalInfoBroadcaster" attribute="hidden"/> + <label value="&editBookmarkOverlay.keyword.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.keyword.accesskey;" + control="editBMPanel_keywordField"/> + <textbox id="editBMPanel_keywordField" + onchange="gEditItemOverlay.onKeywordFieldChange();"/> + </row> + + <row id="editBMPanel_descriptionRow" + collapsed="true"> + <observes element="additionalInfoBroadcaster" attribute="hidden"/> + <label value="&editBookmarkOverlay.description.label;" + class="editBMPanel_rowLabel" + accesskey="&editBookmarkOverlay.description.accesskey;" + control="editBMPanel_descriptionField"/> + <textbox id="editBMPanel_descriptionField" + multiline="true" + rows="4" + onchange="gEditItemOverlay.onDescriptionFieldChange();"/> + </row> + </rows> + </grid> + + <checkbox id="editBMPanel_loadInSidebarCheckbox" + collapsed="true" + hidden="true" + disabled="true" + label="&editBookmarkOverlay.loadInSidebar.label;" + accesskey="&editBookmarkOverlay.loadInSidebar.accesskey;" + oncommand="gEditItemOverlay.onLoadInSidebarCheckboxCommand();"> + <!-- Not yet supported + <observes element="additionalInfoBroadcaster" attribute="hidden"/> --> + </checkbox> + + <!-- If the ids are changing or additional fields are being added, be sure + to sync the values in places.js --> + <broadcaster id="additionalInfoBroadcaster"/> + </vbox> +</overlay> diff --git a/comm/suite/components/places/content/history-panel.js b/comm/suite/components/places/content/history-panel.js new file mode 100644 index 0000000000..6353b51c66 --- /dev/null +++ b/comm/suite/components/places/content/history-panel.js @@ -0,0 +1,86 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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 gHistoryTree; +var gSearchBox; +var gHistoryGrouping = ""; +var gSearching = false; + +function HistorySidebarInit() { + gHistoryTree = document.getElementById("historyTree"); + gSearchBox = document.getElementById("search-box"); + + gHistoryGrouping = document.getElementById("viewButton"). + getAttribute("selectedsort"); + + if (gHistoryGrouping == "site") + document.getElementById("bysite").setAttribute("checked", "true"); + else if (gHistoryGrouping == "visited") + document.getElementById("byvisited").setAttribute("checked", "true"); + else if (gHistoryGrouping == "lastvisited") + document.getElementById("bylastvisited").setAttribute("checked", "true"); + else if (gHistoryGrouping == "dayandsite") + document.getElementById("bydayandsite").setAttribute("checked", "true"); + else + document.getElementById("byday").setAttribute("checked", "true"); + + searchHistory(""); +} + +function GroupBy(groupingType) { + gHistoryGrouping = groupingType; + searchHistory(gSearchBox.value); +} + +function searchHistory(aInput) { + var query = PlacesUtils.history.getNewQuery(); + var options = PlacesUtils.history.getNewQueryOptions(); + + const NHQO = Ci.nsINavHistoryQueryOptions; + var sortingMode; + var resultType; + + switch (gHistoryGrouping) { + case "visited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + break; + case "lastvisited": + resultType = NHQO.RESULTS_AS_URI; + sortingMode = NHQO.SORT_BY_DATE_DESCENDING; + break; + case "dayandsite": + resultType = NHQO.RESULTS_AS_DATE_SITE_QUERY; + break; + case "site": + resultType = NHQO.RESULTS_AS_SITE_QUERY; + sortingMode = NHQO.SORT_BY_TITLE_ASCENDING; + break; + case "day": + default: + resultType = NHQO.RESULTS_AS_DATE_QUERY; + break; + } + + if (aInput) { + query.searchTerms = aInput; + if (gHistoryGrouping != "visited" && gHistoryGrouping != "lastvisited") { + sortingMode = NHQO.SORT_BY_FRECENCY_DESCENDING; + resultType = NHQO.RESULTS_AS_URI; + } + } + + options.sortingMode = sortingMode; + options.resultType = resultType; + options.includeHidden = !!aInput; + + // call load() on the tree manually + // instead of setting the place attribute in history-panel.xul + // otherwise, we will end up calling load() twice + gHistoryTree.load([query], options); +} + +window.addEventListener("SidebarFocused", + () => gSearchBox.focus()); diff --git a/comm/suite/components/places/content/history-panel.xul b/comm/suite/components/places/content/history-panel.xul new file mode 100644 index 0000000000..cb3b77ad17 --- /dev/null +++ b/comm/suite/components/places/content/history-panel.xul @@ -0,0 +1,95 @@ +<?xml version="1.0"?> <!-- -*- Mode: SGML; indent-tabs-mode: nil; -*- --> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://communicator/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://communicator/skin/sidebar/sidebarListView.css" type="text/css"?> +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/bookmarks.css"?> + +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/utilityOverlay.xul"?> + +<!DOCTYPE page [ +<!ENTITY % placesDTD SYSTEM "chrome://communicator/locale/places/places.dtd"> +%placesDTD; +<!ENTITY % editMenuOverlayDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuOverlayDTD; +]> + +<!-- we need to keep id="history-panel" for upgrade and switching + between versions of the browser --> + +<page id="history-panel" orient="vertical" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="HistorySidebarInit();" + onunload="SidebarUtils.setMouseoverURL('');"> + + <script src="chrome://communicator/content/bookmarks/sidebarUtils.js"/> + <script src="chrome://communicator/content/places/history-panel.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <commandset id="placesCommands"/> + +#include ../../../../../toolkit/content/editMenuKeys.inc.xhtml +#ifdef XP_MACOSX + <keyset id="editMenuKeysExtra"> + <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/> + </keyset> +#endif + + <!-- required to overlay the context menu --> + <menupopup id="placesContext"/> + + <!-- Bookmarks and history tooltip --> + <tooltip id="bhTooltip"/> + + <hbox id="sidebar-search-container"> + <textbox id="search-box" flex="1" type="search" + placeholder="&search.placeholder;" + aria-controls="historyTree" + oncommand="searchHistory(this.value);"/> + <button id="viewButton" style="min-width:0px !important;" type="menu" + label="&view.label;" accesskey="&view.accesskey;" selectedsort="day" + persist="selectedsort"> + <menupopup> + <menuitem id="bydayandsite" label="&byDayAndSite.label;" + accesskey="&byDayAndSite.accesskey;" type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'dayandsite'); GroupBy('dayandsite');"/> + <menuitem id="bysite" label="&bySite.label;" + accesskey="&bySite.accesskey;" type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'site'); GroupBy('site');"/> + <menuitem id="byday" label="&byDate.label;" + accesskey="&byDate.accesskey;" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'day'); GroupBy('day');"/> + <menuitem id="byvisited" label="&byMostVisited.label;" + accesskey="&byMostVisited.accesskey;" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'visited'); GroupBy('visited');"/> + <menuitem id="bylastvisited" label="&byLastVisited.label;" + accesskey="&byLastVisited.accesskey;" + type="radio" + oncommand="this.parentNode.parentNode.setAttribute('selectedsort', 'lastvisited'); GroupBy('lastvisited');"/> + </menupopup> + </button> + </hbox> + + <tree id="historyTree" + class="sidebar-placesTree" + flex="1" + type="places" + treelines="true" + context="placesContext" + hidecolumnpicker="true" + onkeypress="SidebarUtils.handleTreeKeyPress(event);" + onclick="SidebarUtils.handleTreeClick(this, event, true);" + onmousemove="SidebarUtils.handleTreeMouseMove(event);" + onmouseout="SidebarUtils.setMouseoverURL('');"> + <treecols> + <treecol id="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren class="sidebar-placesTreechildren" flex="1" tooltip="bhTooltip"/> + </tree> +</page> diff --git a/comm/suite/components/places/content/menu.xml b/comm/suite/components/places/content/menu.xml new file mode 100644 index 0000000000..24af8629bb --- /dev/null +++ b/comm/suite/components/places/content/menu.xml @@ -0,0 +1,624 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<bindings id="placesMenuBindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="places-popup-base" + extends="chrome://global/content/bindings/popup.xml#popup"> + <content> + <xul:hbox flex="1"> + <xul:vbox class="menupopup-drop-indicator-bar" hidden="true"> + <xul:image class="menupopup-drop-indicator" mousethrough="always"/> + </xul:vbox> + <xul:arrowscrollbox class="popup-internal-box" flex="1" orient="vertical" + smoothscroll="false"> + <children/> + </xul:arrowscrollbox> + </xul:hbox> + </content> + + <implementation> + + <field name="AppConstants" readonly="true"> + (ChromeUtils.import("resource://gre/modules/AppConstants.jsm", {})).AppConstants; + </field> + + <field name="_indicatorBar"> + document.getAnonymousElementByAttribute(this, "class", + "menupopup-drop-indicator-bar"); + </field> + + <field name="_scrollBox"> + document.getAnonymousElementByAttribute(this, "class", + "popup-internal-box"); + </field> + + <!-- This is the view that manage the popup --> + <field name="_rootView">PlacesUIUtils.getViewForNode(this);</field> + + <!-- Check if we should hide the drop indicator for the target --> + <method name="_hideDropIndicator"> + <parameter name="aEvent"/> + <body><![CDATA[ + let target = aEvent.target; + + // Don't draw the drop indicator outside of markers or if current + // node is not a Places node. + let betweenMarkers = + (this._startMarker.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_FOLLOWING) && + (this._endMarker.compareDocumentPosition(target) & Node.DOCUMENT_POSITION_PRECEDING); + + // Hide the dropmarker if current node is not a Places node. + return !(target && target._placesNode && betweenMarkers); + ]]></body> + </method> + + <!-- This function returns information about where to drop when + dragging over this popup insertion point --> + <method name="_getDropPoint"> + <parameter name="aEvent"/> + <body><![CDATA[ + // Can't drop if the menu isn't a folder + let resultNode = this._placesNode; + + if (!PlacesUtils.nodeIsFolder(resultNode) || + this._rootView.controller.disallowInsertion(resultNode)) { + return null; + } + + var dropPoint = { ip: null, folderElt: null }; + + // The element we are dragging over + let elt = aEvent.target; + if (elt.localName == "menupopup") + elt = elt.parentNode; + + // Calculate positions taking care of arrowscrollbox + let scrollbox = this._scrollBox; + let eventY = aEvent.layerY + (scrollbox.boxObject.y - this.boxObject.y); + let scrollboxOffset = scrollbox.scrollBoxObject.y - + (scrollbox.boxObject.y - this.boxObject.y); + let eltY = elt.boxObject.y - scrollboxOffset; + let eltHeight = elt.boxObject.height; + + if (!elt._placesNode) { + // If we are dragging over a non places node drop at the end. + dropPoint.ip = new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode) + }); + // We can set folderElt if we are dropping over a static menu that + // has an internal placespopup. + let isMenu = elt.localName == "menu" || + (elt.localName == "toolbarbutton" && + elt.getAttribute("type") == "menu"); + if (isMenu && elt.lastChild && + elt.lastChild.hasAttribute("placespopup")) + dropPoint.folderElt = elt; + return dropPoint; + } + + let tagName = PlacesUtils.nodeIsTagQuery(elt._placesNode) ? + elt._placesNode.title : null; + if ((PlacesUtils.nodeIsFolder(elt._placesNode) && + !PlacesUIUtils.isFolderReadOnly(elt._placesNode, this._rootView)) || + PlacesUtils.nodeIsTagQuery(elt._placesNode)) { + // This is a folder or a tag container. + if (eventY - eltY < eltHeight * 0.20) { + // If mouse is in the top part of the element, drop above folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + tagName, + dropNearNode: elt._placesNode + }); + return dropPoint; + } else if (eventY - eltY < eltHeight * 0.80) { + // If mouse is in the middle of the element, drop inside folder. + dropPoint.ip = new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(elt._placesNode), + parentGuid: PlacesUtils.getConcreteItemGuid(elt._placesNode), + tagName + }); + dropPoint.folderElt = elt; + return dropPoint; + } + } else if (eventY - eltY <= eltHeight / 2) { + // This is a non-folder node or a readonly folder. + // If the mouse is above the middle, drop above this item. + dropPoint.ip = new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_BEFORE, + tagName, + dropNearNode: elt._placesNode + }); + return dropPoint; + } + + // Drop below the item. + dropPoint.ip = new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(resultNode), + parentGuid: PlacesUtils.getConcreteItemGuid(resultNode), + orientation: Ci.nsITreeView.DROP_AFTER, + tagName, + dropNearNode: elt._placesNode, + }); + return dropPoint; + ]]></body> + </method> + + <!-- Sub-menus should be opened when the mouse drags over them, and closed + when the mouse drags off. The overFolder object manages opening and + closing of folders when the mouse hovers. --> + <field name="_overFolder"><![CDATA[({ + _self: this, + _folder: {elt: null, + openTimer: null, + hoverTime: 350, + closeTimer: null}, + _closeMenuTimer: null, + + get elt() { + return this._folder.elt; + }, + set elt(val) { + return this._folder.elt = val; + }, + + get openTimer() { + return this._folder.openTimer; + }, + set openTimer(val) { + return this._folder.openTimer = val; + }, + + get hoverTime() { + return this._folder.hoverTime; + }, + set hoverTime(val) { + return this._folder.hoverTime = val; + }, + + get closeTimer() { + return this._folder.closeTimer; + }, + set closeTimer(val) { + return this._folder.closeTimer = val; + }, + + get closeMenuTimer() { + return this._closeMenuTimer; + }, + set closeMenuTimer(val) { + return this._closeMenuTimer = val; + }, + + setTimer: function OF__setTimer(aTime) { + var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback(this, aTime, timer.TYPE_ONE_SHOT); + return timer; + }, + + notify: function OF__notify(aTimer) { + // Function to process all timer notifications. + + if (aTimer == this._folder.openTimer) { + // Timer to open a submenu that's being dragged over. + this._folder.elt.lastChild.setAttribute("autoopened", "true"); + this._folder.elt.lastChild.showPopup(this._folder.elt); + this._folder.openTimer = null; + } else if (aTimer == this._folder.closeTimer) { + // Timer to close a submenu that's been dragged off of. + // Only close the submenu if the mouse isn't being dragged over any + // of its child menus. + var draggingOverChild = PlacesControllerDragHelper + .draggingOverChildNode(this._folder.elt); + if (draggingOverChild) + this._folder.elt = null; + this.clear(); + + // Close any parent folders which aren't being dragged over. + // (This is necessary because of the above code that keeps a folder + // open while its children are being dragged over.) + if (!draggingOverChild) + this.closeParentMenus(); + } else if (aTimer == this.closeMenuTimer) { + // Timer to close this menu after the drag exit. + var popup = this._self; + // if we are no more dragging we can leave the menu open to allow + // for better D&D bookmark organization + if (PlacesControllerDragHelper.getSession() && + !PlacesControllerDragHelper.draggingOverChildNode(popup.parentNode)) { + popup.hidePopup(); + // Close any parent menus that aren't being dragged over; + // otherwise they'll stay open because they couldn't close + // while this menu was being dragged over. + this.closeParentMenus(); + } + this._closeMenuTimer = null; + } + }, + + // Helper function to close all parent menus of this menu, + // as long as none of the parent's children are currently being + // dragged over. + closeParentMenus: function OF__closeParentMenus() { + var popup = this._self; + var parent = popup.parentNode; + while (parent) { + if (parent.localName == "menupopup" && parent._placesNode) { + if (PlacesControllerDragHelper.draggingOverChildNode(parent.parentNode)) + break; + parent.hidePopup(); + } + parent = parent.parentNode; + } + }, + + // The mouse is no longer dragging over the stored menubutton. + // Close the menubutton, clear out drag styles, and clear all + // timers for opening/closing it. + clear: function OF__clear() { + if (this._folder.elt && this._folder.elt.lastChild) { + if (!this._folder.elt.lastChild.hasAttribute("dragover")) + this._folder.elt.lastChild.hidePopup(); + // remove menuactive style + this._folder.elt.removeAttribute("_moz-menuactive"); + this._folder.elt = null; + } + if (this._folder.openTimer) { + this._folder.openTimer.cancel(); + this._folder.openTimer = null; + } + if (this._folder.closeTimer) { + this._folder.closeTimer.cancel(); + this._folder.closeTimer = null; + } + } + })]]></field> + + <method name="_cleanupDragDetails"> + <body><![CDATA[ + // Called on dragend and drop. + PlacesControllerDragHelper.currentDropTarget = null; + this._rootView._draggedElt = null; + this.removeAttribute("dragover"); + this.removeAttribute("dragstart"); + this._indicatorBar.hidden = true; + ]]></body> + </method> + + </implementation> + + <handlers> + <handler event="DOMMenuItemActive"><![CDATA[ + let elt = event.target; + if (elt.parentNode != this) + return; + + if (this.AppConstants.platform === "macosx") { + // XXX: The following check is a temporary hack until bug 420033 is + // resolved. + let parentElt = elt.parent; + while (parentElt) { + if (parentElt.id == "bookmarksMenuPopup" || + parentElt.id == "goPopup") + return; + + parentElt = parentElt.parentNode; + } + } + + if (window.XULBrowserWindow) { + let placesNode = elt._placesNode; + + var linkURI; + if (placesNode && PlacesUtils.nodeIsURI(placesNode)) + linkURI = placesNode.uri; + else if (elt.hasAttribute("targetURI")) + linkURI = elt.getAttribute("targetURI"); + + if (linkURI) + window.XULBrowserWindow.setOverLink(linkURI, null); + } + ]]></handler> + + <handler event="DOMMenuItemInactive"><![CDATA[ + let elt = event.target; + if (elt.parentNode != this) + return; + + if (window.XULBrowserWindow) + window.XULBrowserWindow.setOverLink("", null); + ]]></handler> + + <handler event="dragstart"><![CDATA[ + let elt = event.target; + if (!elt._placesNode) + return; + + let draggedElt = elt._placesNode; + + // Force a copy action if parent node is a query or we are dragging a + // not-removable node. + if (!this._rootView.controller.canMoveNode(draggedElt)) + event.dataTransfer.effectAllowed = "copyLink"; + + // Activate the view and cache the dragged element. + this._rootView._draggedElt = draggedElt; + this._rootView.controller.setDataTransfer(event); + this.setAttribute("dragstart", "true"); + event.stopPropagation(); + ]]></handler> + + <handler event="drop"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = event.target; + + let dropPoint = this._getDropPoint(event); + if (dropPoint && dropPoint.ip) { + PlacesControllerDragHelper.onDrop(dropPoint.ip, event.dataTransfer) + .catch(Cu.reportError); + event.preventDefault(); + } + + this._cleanupDragDetails(); + event.stopPropagation(); + ]]></handler> + + <handler event="dragover"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = event.target; + let dt = event.dataTransfer; + + let dropPoint = this._getDropPoint(event); + if (!dropPoint || !dropPoint.ip || + !PlacesControllerDragHelper.canDrop(dropPoint.ip, dt)) { + this._indicatorBar.hidden = true; + event.stopPropagation(); + return; + } + + // Mark this popup as being dragged over. + this.setAttribute("dragover", "true"); + + if (dropPoint.folderElt) { + // We are dragging over a folder. + // _overFolder should take the care of opening it on a timer. + if (this._overFolder.elt && + this._overFolder.elt != dropPoint.folderElt) { + // We are dragging over a new folder, let's clear old values + this._overFolder.clear(); + } + if (!this._overFolder.elt) { + this._overFolder.elt = dropPoint.folderElt; + // Create the timer to open this folder. + this._overFolder.openTimer = this._overFolder + .setTimer(this._overFolder.hoverTime); + } + // Since we are dropping into a folder set the corresponding style. + dropPoint.folderElt.setAttribute("_moz-menuactive", true); + } else { + // We are not dragging over a folder. + // Clear out old _overFolder information. + this._overFolder.clear(); + } + + // Autoscroll the popup strip if we drag over the scroll buttons. + let anonid = event.originalTarget.getAttribute("anonid"); + let scrollDir = 0; + if (anonid == "scrollbutton-up") { + scrollDir = -1; + } else if (anonid == "scrollbutton-down") { + scrollDir = 1; + } + if (scrollDir != 0) { + this._scrollBox.scrollByIndex(scrollDir, true); + } + + // Check if we should hide the drop indicator for this target. + if (dropPoint.folderElt || this._hideDropIndicator(event)) { + this._indicatorBar.hidden = true; + event.preventDefault(); + event.stopPropagation(); + return; + } + + // We should display the drop indicator relative to the arrowscrollbox. + let sbo = this._scrollBox.scrollBoxObject; + let newMarginTop = 0; + if (scrollDir == 0) { + let elt = this.firstChild; + while (elt && event.screenY > elt.boxObject.screenY + + elt.boxObject.height / 2) + elt = elt.nextSibling; + newMarginTop = elt ? elt.boxObject.screenY - sbo.screenY : + sbo.height; + } else if (scrollDir == 1) + newMarginTop = sbo.height; + + // Set the new marginTop based on arrowscrollbox. + newMarginTop += sbo.y - this._scrollBox.boxObject.y; + this._indicatorBar.firstChild.style.marginTop = newMarginTop + "px"; + this._indicatorBar.hidden = false; + + event.preventDefault(); + event.stopPropagation(); + ]]></handler> + + <handler event="dragexit"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = null; + this.removeAttribute("dragover"); + + // If we have not moved to a valid new target clear the drop indicator + // this happens when moving out of the popup. + let target = event.relatedTarget; + if (!target || !this.contains(target)) + this._indicatorBar.hidden = true; + + // Close any folder being hovered over + if (this._overFolder.elt) { + this._overFolder.closeTimer = this._overFolder + .setTimer(this._overFolder.hoverTime); + } + + // The autoopened attribute is set when this folder was automatically + // opened after the user dragged over it. If this attribute is set, + // auto-close the folder on drag exit. + // We should also try to close this popup if the drag has started + // from here, the timer will check if we are dragging over a child. + if (this.hasAttribute("autoopened") || + this.hasAttribute("dragstart")) { + this._overFolder.closeMenuTimer = this._overFolder + .setTimer(this._overFolder.hoverTime); + } + + event.stopPropagation(); + ]]></handler> + + <handler event="dragend"><![CDATA[ + this._cleanupDragDetails(); + ]]></handler> + + </handlers> + </binding> + + <!-- Most of this is copied from the arrowpanel binding in popup.xml --> + <binding id="places-popup-arrow" + extends="chrome://communicator/content/places/menu.xml#places-popup-base"> + <content flip="both" side="top" position="bottomcenter topright"> + <xul:vbox anonid="container" class="panel-arrowcontainer" flex="1" + xbl:inherits="side,panelopen"> + <xul:box anonid="arrowbox" class="panel-arrowbox"> + <xul:image anonid="arrow" class="panel-arrow" xbl:inherits="side"/> + </xul:box> + <xul:box class="panel-arrowcontent" xbl:inherits="side,align,dir,orient,pack" flex="1"> + <xul:vbox class="menupopup-drop-indicator-bar" hidden="true"> + <xul:image class="menupopup-drop-indicator" mousethrough="always"/> + </xul:vbox> + <xul:arrowscrollbox class="popup-internal-box" flex="1" orient="vertical" + smoothscroll="false"> + <children/> + </xul:arrowscrollbox> + </xul:box> + </xul:vbox> + </content> + + <implementation> + <constructor><![CDATA[ + this.style.pointerEvents = "none"; + ]]></constructor> + <method name="adjustArrowPosition"> + <body><![CDATA[ + var arrow = document.getAnonymousElementByAttribute(this, "anonid", "arrow"); + + var anchor = this.anchorNode; + if (!anchor) { + arrow.hidden = true; + return; + } + + var container = document.getAnonymousElementByAttribute(this, "anonid", "container"); + var arrowbox = document.getAnonymousElementByAttribute(this, "anonid", "arrowbox"); + + var position = this.alignmentPosition; + var offset = this.alignmentOffset; + + this.setAttribute("arrowposition", position); + + // if this panel has a "sliding" arrow, we may have previously set margins... + arrowbox.style.removeProperty("transform"); + if (position.indexOf("start_") == 0 || position.indexOf("end_") == 0) { + container.orient = "horizontal"; + arrowbox.orient = "vertical"; + if (position.indexOf("_after") > 0) { + arrowbox.pack = "end"; + } else { + arrowbox.pack = "start"; + } + arrowbox.style.transform = "translate(0, " + -offset + "px)"; + + // The assigned side stays the same regardless of direction. + var isRTL = (window.getComputedStyle(this).direction == "rtl"); + + if (position.indexOf("start_") == 0) { + container.dir = "reverse"; + this.setAttribute("side", isRTL ? "left" : "right"); + } else { + container.dir = ""; + this.setAttribute("side", isRTL ? "right" : "left"); + } + } else if (position.indexOf("before_") == 0 || position.indexOf("after_") == 0) { + container.orient = ""; + arrowbox.orient = ""; + if (position.indexOf("_end") > 0) { + arrowbox.pack = "end"; + } else { + arrowbox.pack = "start"; + } + arrowbox.style.transform = "translate(" + -offset + "px, 0)"; + + if (position.indexOf("before_") == 0) { + container.dir = "reverse"; + this.setAttribute("side", "bottom"); + } else { + container.dir = ""; + this.setAttribute("side", "top"); + } + } + + arrow.hidden = false; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="popupshowing" phase="target"><![CDATA[ + this.adjustArrowPosition(); + this.setAttribute("animate", "open"); + ]]></handler> + <handler event="popupshown" phase="target"><![CDATA[ + this.setAttribute("panelopen", "true"); + let disablePointerEvents; + if (!this.hasAttribute("disablepointereventsfortransition")) { + let container = document.getAnonymousElementByAttribute(this, "anonid", "container"); + let cs = getComputedStyle(container); + let transitionProp = cs.transitionProperty; + let transitionTime = parseFloat(cs.transitionDuration); + disablePointerEvents = (transitionProp.includes("transform") || + transitionProp == "all") && + transitionTime > 0; + this.setAttribute("disablepointereventsfortransition", disablePointerEvents); + } else { + disablePointerEvents = this.getAttribute("disablepointereventsfortransition") == "true"; + } + if (!disablePointerEvents) { + this.style.removeProperty("pointer-events"); + } + ]]></handler> + <handler event="transitionend"><![CDATA[ + if (event.originalTarget.getAttribute("anonid") == "container" && + (event.propertyName == "transform" || event.propertyName == "-moz-window-transform")) { + this.style.removeProperty("pointer-events"); + } + ]]></handler> + <handler event="popuphiding" phase="target"><![CDATA[ + this.setAttribute("animate", "cancel"); + ]]></handler> + <handler event="popuphidden" phase="target"><![CDATA[ + this.removeAttribute("panelopen"); + if (this.getAttribute("disablepointereventsfortransition") == "true") { + this.style.pointerEvents = "none"; + } + this.removeAttribute("animate"); + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/comm/suite/components/places/content/organizer.css b/comm/suite/components/places/content/organizer.css new file mode 100644 index 0000000000..47b1832c16 --- /dev/null +++ b/comm/suite/components/places/content/organizer.css @@ -0,0 +1,7 @@ +/* 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/. */ + +#searchFilter { + width: 23em; +} diff --git a/comm/suite/components/places/content/places.css b/comm/suite/components/places/content/places.css new file mode 100644 index 0000000000..598e60b256 --- /dev/null +++ b/comm/suite/components/places/content/places.css @@ -0,0 +1,37 @@ +/* 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/. */ + +tree[type="places"] { + -moz-binding: url("chrome://communicator/content/places/tree.xml#places-tree"); +} + +tree[type="places"] > treechildren::-moz-tree-cell { + /* ensure we use the direction of the website title / url instead of the + * browser locale */ + unicode-bidi: plaintext; +} + +#bhtTitleText { + /* ensure we use the direction of the website title instead of the + * browser locale */ + unicode-bidi: plaintext; +} + +.toolbar-drop-indicator { + position: relative; + z-index: 1; +} + +menupopup[placespopup="true"] { + -moz-binding: url("chrome://communicator/content/places/menu.xml#places-popup-base"); +} + +/* Apply crisp rendering for favicons at exactly 2dppx resolution */ +@media (resolution: 2dppx) { + #bookmarksChildren, + .sidebar-placesTreechildren, + .placesTree > treechildren { + image-rendering: -moz-crisp-edges; + } +} diff --git a/comm/suite/components/places/content/places.js b/comm/suite/components/places/content/places.js new file mode 100644 index 0000000000..f793bf4184 --- /dev/null +++ b/comm/suite/components/places/content/places.js @@ -0,0 +1,1366 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* import-globals-from editBookmarkOverlay.js */ +/* import-globals-from ../../../../../toolkit/content/contentAreaUtils.js */ + +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { AppConstants } = + ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); + +ChromeUtils.defineModuleGetter(this, "MigrationUtils", + "resource:///modules/MigrationUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "BookmarkJSONUtils", + "resource://gre/modules/BookmarkJSONUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "PlacesBackups", + "resource://gre/modules/PlacesBackups.jsm"); +ChromeUtils.defineModuleGetter(this, "DownloadUtils", + "resource://gre/modules/DownloadUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); + +const RESTORE_FILEPICKER_FILTER_EXT = "*.json;*.jsonlz4"; + +var PlacesOrganizer = { + _places: null, + + // IDs of fields from editBookmarkOverlay that should be hidden when infoBox + // is minimal. IDs should be kept in sync with the IDs of the elements + // observing additionalInfoBroadcaster. + _additionalInfoFields: [ + "editBMPanel_descriptionRow", + "editBMPanel_loadInSidebarCheckbox", + "editBMPanel_keywordRow", + ], + + _initFolderTree() { + var leftPaneRoot = PlacesUIUtils.leftPaneFolderId; + this._places.place = "place:excludeItems=1&expandQueries=0&folder=" + leftPaneRoot; + }, + + /** + * Selects a left pane built-in item. + * + * @param {String} item The built-in item to select, may be one of (case sensitive): + * AllBookmarks, BookmarksMenu, BookmarksToolbar, + * History, Tags, UnfiledBookmarks. + */ + selectLeftPaneBuiltIn(item) { + switch (item) { + case "AllBookmarks": + case "History": + case "Tags": { + var itemId = PlacesUIUtils.leftPaneQueries[item]; + this._places.selectItems([itemId]); + // Forcefully expand all-bookmarks + if (item == "AllBookmarks" || item == "History") + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + break; + } + case "BookmarksMenu": + this.selectLeftPaneContainerByHierarchy([ + PlacesUIUtils.leftPaneQueries.AllBookmarks, + PlacesUtils.bookmarks.virtualMenuGuid + ]); + break; + case "BookmarksToolbar": + this.selectLeftPaneContainerByHierarchy([ + PlacesUIUtils.leftPaneQueries.AllBookmarks, + PlacesUtils.bookmarks.virtualToolbarGuid + ]); + break; + case "UnfiledBookmarks": + this.selectLeftPaneContainerByHierarchy([ + PlacesUIUtils.leftPaneQueries.AllBookmarks, + PlacesUtils.bookmarks.virtualUnfiledGuid + ]); + break; + default: + throw new Error(`Unrecognized item ${item} passed to selectLeftPaneRootItem`); + } + }, + + /** + * Opens a given hierarchy in the left pane, stopping at the last reachable + * container. Note: item ids should be considered deprecated. + * + * @param aHierarchy A single container or an array of containers, sorted from + * the outmost to the innermost in the hierarchy. Each + * container may be either an item id, a Places URI string, + * or a named query, like: + * "BookmarksMenu", "BookmarksToolbar", "UnfiledBookmarks", "AllBookmarks". + * @see PlacesUIUtils.leftPaneQueries for supported named queries. + */ + selectLeftPaneContainerByHierarchy(aHierarchy) { + if (!aHierarchy) + throw new Error("Containers hierarchy not specified"); + let hierarchy = [].concat(aHierarchy); + let selectWasSuppressed = this._places.view.selection.selectEventsSuppressed; + if (!selectWasSuppressed) + this._places.view.selection.selectEventsSuppressed = true; + try { + for (let container of hierarchy) { + switch (typeof container) { + case "number": + this._places.selectItems([container], false); + break; + case "string": + try { + this.selectLeftPaneBuiltIn(container); + } catch (ex) { + if (container.substr(0, 6) == "place:") { + this._places.selectPlaceURI(container); + } else { + // May be a guid. + this._places.selectItems([container], false); + } + } + break; + default: + throw new Error("Invalid container type found: " + container); + } + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + } + } finally { + if (!selectWasSuppressed) + this._places.view.selection.selectEventsSuppressed = false; + } + }, + + init: function PO_init() { + ContentArea.init(); + + this._places = document.getElementById("placesList"); + this._initFolderTree(); + + var leftPaneSelection = "AllBookmarks"; // default to all-bookmarks + if (window.arguments && window.arguments[0]) + leftPaneSelection = window.arguments[0]; + + this.selectLeftPaneContainerByHierarchy(leftPaneSelection); + if (leftPaneSelection === "History") { + let historyNode = this._places.selectedNode; + if (historyNode.childCount > 0) + this._places.selectNode(historyNode.getChild(0)); + } + + // clear the back-stack + this._backHistory.splice(0, this._backHistory.length); + document.getElementById("OrganizerCommand:Back").setAttribute("disabled", true); + + // Set up the search UI. + PlacesSearchBox.init(); + + window.addEventListener("AppCommand", this, true); + + // remove the "Properties" context-menu item, we've our own details pane + document.getElementById("placesContext") + .removeChild(document.getElementById("placesContext_show:info")); + + ContentArea.focus(); + }, + + QueryInterface: function PO_QueryInterface(aIID) { + if (aIID.equals(Ci.nsIDOMEventListener) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_NOINTERFACE; + }, + + handleEvent: function PO_handleEvent(aEvent) { + if (aEvent.type != "AppCommand") + return; + + aEvent.stopPropagation(); + switch (aEvent.command) { + case "Back": + if (this._backHistory.length > 0) + this.back(); + break; + case "Forward": + if (this._forwardHistory.length > 0) + this.forward(); + break; + case "Search": + PlacesSearchBox.findAll(); + break; + } + }, + + destroy: function PO_destroy() { + }, + + _location: null, + get location() { + return this._location; + }, + + set location(aLocation) { + if (!aLocation || this._location == aLocation) + return aLocation; + + if (this.location) { + this._backHistory.unshift(this.location); + this._forwardHistory.splice(0, this._forwardHistory.length); + } + + this._location = aLocation; + this._places.selectPlaceURI(aLocation); + + if (!this._places.hasSelection) { + // If no node was found for the given place: uri, just load it directly + ContentArea.currentPlace = aLocation; + } + this.updateDetailsPane(); + + // update navigation commands + if (this._backHistory.length == 0) + document.getElementById("OrganizerCommand:Back").setAttribute("disabled", true); + else + document.getElementById("OrganizerCommand:Back").removeAttribute("disabled"); + if (this._forwardHistory.length == 0) + document.getElementById("OrganizerCommand:Forward").setAttribute("disabled", true); + else + document.getElementById("OrganizerCommand:Forward").removeAttribute("disabled"); + + return aLocation; + }, + + _backHistory: [], + _forwardHistory: [], + + back: function PO_back() { + this._forwardHistory.unshift(this.location); + var historyEntry = this._backHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + forward: function PO_forward() { + this._backHistory.unshift(this.location); + var historyEntry = this._forwardHistory.shift(); + this._location = null; + this.location = historyEntry; + }, + + /** + * Called when a place folder is selected in the left pane. + * @param resetSearchBox + * true if the search box should also be reset, false otherwise. + * The search box should be reset when a new folder in the left + * pane is selected; the search scope and text need to be cleared in + * preparation for the new folder. Note that if the user manually + * resets the search box, either by clicking its reset button or by + * deleting its text, this will be false. + */ + _cachedLeftPaneSelectedURI: null, + onPlaceSelected: function PO_onPlaceSelected(resetSearchBox) { + // Don't change the right-hand pane contents when there's no selection. + if (!this._places.hasSelection) + return; + + let node = this._places.selectedNode; + let placeURI = node.uri; + + // If either the place of the content tree in the right pane has changed or + // the user cleared the search box, update the place, hide the search UI, + // and update the back/forward buttons by setting location. + if (ContentArea.currentPlace != placeURI || !resetSearchBox) { + ContentArea.currentPlace = placeURI; + this.location = placeURI; + } + + // When we invalidate a container we use suppressSelectionEvent, when it is + // unset a select event is fired, in many cases the selection did not really + // change, so we should check for it, and return early in such a case. Note + // that we cannot return any earlier than this point, because when + // !resetSearchBox, we need to update location and hide the UI as above, + // even though the selection has not changed. + if (placeURI == this._cachedLeftPaneSelectedURI) + return; + this._cachedLeftPaneSelectedURI = placeURI; + + // At this point, resetSearchBox is true, because the left pane selection + // has changed; otherwise we would have returned earlier. + + PlacesSearchBox.searchFilter.reset(); + this._setSearchScopeForNode(node); + this.updateDetailsPane(); + }, + + /** + * Sets the search scope based on aNode's properties. + * @param aNode + * the node to set up scope from + */ + _setSearchScopeForNode: function PO__setScopeForNode(aNode) { + let itemId = aNode.itemId; + + if (PlacesUtils.nodeIsHistoryContainer(aNode) || + itemId == PlacesUIUtils.leftPaneQueries.History) { + PlacesQueryBuilder.setScope("history"); + } else { + // Default to All Bookmarks for all other nodes, per bug 469437. + PlacesQueryBuilder.setScope("bookmarks"); + } + }, + + /** + * Handle clicks on the places list. + * Single Left click, right click or modified click do not result in any + * special action, since they're related to selection. + * @param aEvent + * The mouse event. + */ + onPlacesListClick: function PO_onPlacesListClick(aEvent) { + // Only handle clicks on tree children. + if (aEvent.target.localName != "treechildren") + return; + + let node = this._places.selectedNode; + if (node) { + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openContainerNodeInTabs(node, aEvent, this._places); + } + } + }, + + /** + * Handle focus changes on the places list and the current content view. + */ + updateDetailsPane: function PO_updateDetailsPane() { + if (!ContentArea.currentViewOptions.showDetailsPane) + return; + let view = PlacesUIUtils.getViewForNode(document.activeElement); + if (view) { + let selectedNodes = view.selectedNode ? + [view.selectedNode] : view.selectedNodes; + this._fillDetailsPane(selectedNodes); + } + }, + + openFlatContainer: function PO_openFlatContainerFlatContainer(aContainer) { + if (aContainer.itemId != -1) { + PlacesUtils.asContainer(this._places.selectedNode).containerOpen = true; + this._places.selectItems([aContainer.itemId], false); + } else if (PlacesUtils.nodeIsQuery(aContainer)) { + this._places.selectPlaceURI(aContainer.uri); + } + }, + + /** + * Returns the options associated with the query currently loaded in the + * main places pane. + */ + getCurrentOptions: function PO_getCurrentOptions() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root).queryOptions; + }, + + /** + * Returns the queries associated with the query currently loaded in the + * main places pane. + */ + getCurrentQueries: function PO_getCurrentQueries() { + return PlacesUtils.asQuery(ContentArea.currentView.result.root).getQueries(); + }, + + /** + * Show the migration wizard for importing passwords, + * cookies, history, preferences, and bookmarks. + */ + importFromBrowser: function PO_importFromBrowser() { + // We pass in the type of source we're using for future use: + MigrationUtils.showMigrationWizard(window, [MigrationUtils.MIGRATION_ENTRYPOINT_PLACES]); + }, + + /** + * Open a file-picker and import the selected file into the bookmarks store + */ + importFromFile: function PO_importFromFile() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel && fp.fileURL) { + var {BookmarkHTMLUtils} = ChromeUtils.import("resource://gre/modules/BookmarkHTMLUtils.jsm"); + BookmarkHTMLUtils.importFromURL(fp.fileURL.spec, false) + .catch(Cu.reportError); + } + }; + + fp.init(window, PlacesUIUtils.getString("SelectImport"), + Ci.nsIFilePicker.modeOpen); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.open(fpCallback); + }, + + /** + * Allows simple exporting of bookmarks. + */ + exportBookmarks: function PO_exportBookmarks() { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + var {BookmarkHTMLUtils} = ChromeUtils.import("resource://gre/modules/BookmarkHTMLUtils.jsm"); + BookmarkHTMLUtils.exportToFile(fp.file.path) + .catch(Cu.reportError); + } + }; + + fp.init(window, PlacesUIUtils.getString("EnterExport"), + Ci.nsIFilePicker.modeSave); + fp.appendFilters(Ci.nsIFilePicker.filterHTML); + fp.defaultString = "bookmarks.html"; + fp.open(fpCallback); + }, + + /** + * Populates the restore menu with the dates of the backups available. + */ + populateRestoreMenu: function PO_populateRestoreMenu() { + let restorePopup = document.getElementById("fileRestorePopup"); + + const dtOptions = { + dateStyle: "long" + }; + let dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); + + // Remove existing menu items. Last item is the restoreFromFile item. + while (restorePopup.childNodes.length > 1) + restorePopup.firstChild.remove(); + + (async function() { + let backupFiles = await PlacesBackups.getBackupFiles(); + if (backupFiles.length == 0) + return; + + // Populate menu with backups. + for (let i = 0; i < backupFiles.length; i++) { + let fileSize = (await OS.File.stat(backupFiles[i])).size; + let [size, unit] = DownloadUtils.convertByteUnits(fileSize); + let sizeString = PlacesUtils.getFormattedString("backupFileSizeText", + [size, unit]); + let sizeInfo; + let bookmarkCount = PlacesBackups.getBookmarkCountForFile(backupFiles[i]); + if (bookmarkCount != null) { + sizeInfo = " (" + sizeString + " - " + + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + bookmarkCount, + [bookmarkCount]) + + ")"; + } else { + sizeInfo = " (" + sizeString + ")"; + } + + let backupDate = PlacesBackups.getDateForFile(backupFiles[i]); + let m = restorePopup.insertBefore(document.createElement("menuitem"), + document.getElementById("restoreFromFile")); + m.setAttribute("label", dateFormatter.format(backupDate) + sizeInfo); + m.setAttribute("value", OS.Path.basename(backupFiles[i])); + m.setAttribute("oncommand", + "PlacesOrganizer.onRestoreMenuItemClick(this);"); + } + + // Add the restoreFromFile item. + restorePopup.insertBefore(document.createElement("menuseparator"), + document.getElementById("restoreFromFile")); + })(); + }, + + /** + * Called when a menuitem is selected from the restore menu. + */ + async onRestoreMenuItemClick(aMenuItem) { + let backupName = aMenuItem.getAttribute("value"); + let backupFilePaths = await PlacesBackups.getBackupFiles(); + for (let backupFilePath of backupFilePaths) { + if (OS.Path.basename(backupFilePath) == backupName) { + PlacesOrganizer.restoreBookmarksFromFile(backupFilePath); + break; + } + } + }, + + /** + * Called when 'Choose File...' is selected from the restore menu. + * Prompts for a file and restores bookmarks to those in the file. + */ + onRestoreBookmarksFromFile: function PO_onRestoreBookmarksFromFile() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = aResult => { + if (aResult != Ci.nsIFilePicker.returnCancel) { + this.restoreBookmarksFromFile(fp.file.path); + } + }; + + fp.init(window, PlacesUIUtils.getString("bookmarksRestoreTitle"), + Ci.nsIFilePicker.modeOpen); + fp.appendFilter(PlacesUIUtils.getString("bookmarksRestoreFilterName"), + RESTORE_FILEPICKER_FILTER_EXT); + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + /** + * Restores bookmarks from a JSON file. + */ + restoreBookmarksFromFile: function PO_restoreBookmarksFromFile(aFilePath) { + // check file extension + if (!aFilePath.toLowerCase().endsWith("json") && + !aFilePath.toLowerCase().endsWith("jsonlz4")) { + this._showErrorAlert(PlacesUIUtils.getString("bookmarksRestoreFormatError")); + return; + } + + // confirm ok to delete existing bookmarks + if (!Services.prompt.confirm(null, + PlacesUIUtils.getString("bookmarksRestoreAlertTitle"), + PlacesUIUtils.getString("bookmarksRestoreAlert"))) + return; + + (async function() { + try { + await BookmarkJSONUtils.importFromFile(aFilePath, true); + } catch (ex) { + PlacesOrganizer._showErrorAlert(PlacesUIUtils.getString("bookmarksRestoreParseError")); + } + })(); + }, + + _showErrorAlert: function PO__showErrorAlert(aMsg) { + var brandShortName = document.getElementById("brandStrings"). + getString("brandShortName"); + + Services.prompt.alert(window, brandShortName, aMsg); + }, + + /** + * Backup bookmarks to desktop, auto-generate a filename with a date. + * The file is a JSON serialization of bookmarks, tags and any annotations + * of those items. + */ + backupBookmarks: function PO_backupBookmarks() { + let backupsDir = Services.dirsvc.get("Desk", Ci.nsIFile); + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + let fpCallback = function fpCallback_done(aResult) { + if (aResult != Ci.nsIFilePicker.returnCancel) { + // There is no OS.File version of the filepicker yet (Bug 937812). + PlacesBackups.saveBookmarksToJSONFile(fp.file.path) + .catch(Cu.reportError); + } + }; + + fp.init(window, PlacesUIUtils.getString("bookmarksBackupTitle"), + Ci.nsIFilePicker.modeSave); + fp.appendFilter(PlacesUIUtils.getString("bookmarksRestoreFilterName"), + RESTORE_FILEPICKER_FILTER_EXT); + fp.defaultString = PlacesBackups.getFilenameForDate(); + fp.defaultExtension = "json"; + fp.displayDirectory = backupsDir; + fp.open(fpCallback); + }, + + _detectAndSetDetailsPaneMinimalState: + function PO__detectAndSetDetailsPaneMinimalState(aNode) { + /** + * The details of simple folder-items (as opposed to livemarks) or the + * of livemark-children are not likely to fill the infoBox anyway, + * thus we remove the "More/Less" button and show all details. + * + * the wasminimal attribute here is used to persist the "more/less" + * state in a bookmark->folder->bookmark scenario. + */ + var infoBox = document.getElementById("infoBox"); + var infoBoxExpanderWrapper = document.getElementById("infoBoxExpanderWrapper"); + var additionalInfoBroadcaster = document.getElementById("additionalInfoBroadcaster"); + + if (!aNode) { + infoBoxExpanderWrapper.hidden = true; + return; + } + if (aNode.itemId != -1 && + PlacesUtils.nodeIsFolder(aNode) && !aNode._feedURI) { + if (infoBox.getAttribute("minimal") == "true") + infoBox.setAttribute("wasminimal", "true"); + infoBox.removeAttribute("minimal"); + infoBoxExpanderWrapper.hidden = true; + } else { + if (infoBox.getAttribute("wasminimal") == "true") + infoBox.setAttribute("minimal", "true"); + infoBox.removeAttribute("wasminimal"); + infoBoxExpanderWrapper.hidden = + this._additionalInfoFields.every(id => + document.getElementById(id).collapsed); + } + additionalInfoBroadcaster.hidden = infoBox.getAttribute("minimal") == "true"; + }, + + // NOT YET USED + updateThumbnailProportions: function PO_updateThumbnailProportions() { + var previewBox = document.getElementById("previewBox"); + var canvas = document.getElementById("itemThumbnail"); + var height = previewBox.boxObject.height; + var width = height * (screen.width / screen.height); + canvas.width = width; + canvas.height = height; + }, + + _fillDetailsPane: function PO__fillDetailsPane(aNodeList) { + var infoBox = document.getElementById("infoBox"); + var detailsDeck = document.getElementById("detailsDeck"); + + // Make sure the infoBox UI is visible if we need to use it, we hide it + // below when we don't. + infoBox.hidden = false; + let selectedNode = aNodeList.length == 1 ? aNodeList[0] : null; + + // If a textbox within a panel is focused, force-blur it so its contents + // are saved + if (gEditItemOverlay.itemId != -1) { + var focusedElement = document.commandDispatcher.focusedElement; + if ((focusedElement instanceof HTMLInputElement || + focusedElement instanceof HTMLTextAreaElement) && + /^editBMPanel.*/.test(focusedElement.parentNode.parentNode.id)) + focusedElement.blur(); + + // don't update the panel if we are already editing this node unless we're + // in multi-edit mode + if (selectedNode) { + let concreteId = PlacesUtils.getConcreteItemId(selectedNode); + var nodeIsSame = gEditItemOverlay.itemId == selectedNode.itemId || + gEditItemOverlay.itemId == concreteId || + (selectedNode.itemId == -1 && gEditItemOverlay.uri && + gEditItemOverlay.uri == selectedNode.uri); + if (nodeIsSame && detailsDeck.selectedIndex == 1 && + !gEditItemOverlay.multiEdit) + return; + } + } + + // Clean up the panel before initing it again. + gEditItemOverlay.uninitPanel(false); + + if (selectedNode && !PlacesUtils.nodeIsSeparator(selectedNode)) { + detailsDeck.selectedIndex = 1; + + gEditItemOverlay.initPanel({ node: selectedNode, + hiddenRows: ["folderPicker"] }); + + this._detectAndSetDetailsPaneMinimalState(selectedNode); + } else if (!selectedNode && aNodeList[0]) { + if (aNodeList.every(PlacesUtils.nodeIsURI)) { + let uris = aNodeList.map(node => Services.io.newURI(node.uri)); + detailsDeck.selectedIndex = 1; + gEditItemOverlay.initPanel({ uris, + hiddenRows: ["folderPicker", + "loadInSidebar", + "location", + "keyword", + "description", + "name"]}); + this._detectAndSetDetailsPaneMinimalState(selectedNode); + } else { + detailsDeck.selectedIndex = 0; + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + selectItemDesc.hidden = false; + itemsCountLabel.value = + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + aNodeList.length, [aNodeList.length]); + infoBox.hidden = true; + } + } else { + detailsDeck.selectedIndex = 0; + infoBox.hidden = true; + let selectItemDesc = document.getElementById("selectItemDescription"); + let itemsCountLabel = document.getElementById("itemsCountText"); + let itemsCount = 0; + if (ContentArea.currentView.result) { + let rootNode = ContentArea.currentView.result.root; + if (rootNode.containerOpen) + itemsCount = rootNode.childCount; + } + if (itemsCount == 0) { + selectItemDesc.hidden = true; + itemsCountLabel.value = PlacesUIUtils.getString("detailsPane.noItems"); + } else { + selectItemDesc.hidden = false; + itemsCountLabel.value = + PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel", + itemsCount, [itemsCount]); + } + } + }, + + // NOT YET USED + _updateThumbnail: function PO__updateThumbnail() { + var bo = document.getElementById("previewBox").boxObject; + var width = bo.width; + var height = bo.height; + + var canvas = document.getElementById("itemThumbnail"); + var ctx = canvas.getContext("2d"); + var notAvailableText = canvas.getAttribute("notavailabletext"); + ctx.save(); + ctx.fillStyle = "-moz-Dialog"; + ctx.fillRect(0, 0, width, height); + ctx.translate(width / 2, height / 2); + + ctx.fillStyle = "GrayText"; + ctx.mozTextStyle = "12pt sans serif"; + var len = ctx.mozMeasureText(notAvailableText); + ctx.translate(-len / 2, 0); + ctx.mozDrawText(notAvailableText); + ctx.restore(); + }, + + toggleAdditionalInfoFields: function PO_toggleAdditionalInfoFields() { + var infoBox = document.getElementById("infoBox"); + var infoBoxExpander = document.getElementById("infoBoxExpander"); + var infoBoxExpanderLabel = document.getElementById("infoBoxExpanderLabel"); + var additionalInfoBroadcaster = document.getElementById("additionalInfoBroadcaster"); + + if (infoBox.getAttribute("minimal") == "true") { + infoBox.removeAttribute("minimal"); + infoBoxExpanderLabel.value = infoBoxExpanderLabel.getAttribute("lesslabel"); + infoBoxExpanderLabel.accessKey = infoBoxExpanderLabel.getAttribute("lessaccesskey"); + infoBoxExpander.className = "expander-up"; + additionalInfoBroadcaster.removeAttribute("hidden"); + } else { + infoBox.setAttribute("minimal", "true"); + infoBoxExpanderLabel.value = infoBoxExpanderLabel.getAttribute("morelabel"); + infoBoxExpanderLabel.accessKey = infoBoxExpanderLabel.getAttribute("moreaccesskey"); + infoBoxExpander.className = "expander-down"; + additionalInfoBroadcaster.setAttribute("hidden", "true"); + } + }, +}; + +/** + * A set of utilities relating to search within Bookmarks and History. + */ +var PlacesSearchBox = { + + /** + * The Search text field + */ + get searchFilter() { + return document.getElementById("searchFilter"); + }, + + /** + * Folders to include when searching. + */ + _folders: [], + get folders() { + if (this._folders.length == 0) { + this._folders.push(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.toolbarFolderId, + PlacesUtils.mobileFolderId); + } + return this._folders; + }, + set folders(aFolders) { + this._folders = aFolders; + return aFolders; + }, + + /** + * Run a search for the specified text, over the collection specified by + * the dropdown arrow. The default is all bookmarks, but can be + * localized to the active collection. + * @param filterString + * The text to search for. + */ + search: function PSB_search(filterString) { + var PO = PlacesOrganizer; + // If the user empties the search box manually, reset it and load all + // contents of the current scope. + // XXX this might be to jumpy, maybe should search for "", so results + // are ungrouped, and search box not reset + if (filterString == "") { + PO.onPlaceSelected(false); + return; + } + + let currentView = ContentArea.currentView; + + // Search according to the current scope, which was set by + // PQB_setScope() + switch (PlacesSearchBox.filterCollection) { + case "bookmarks": + currentView.applyFilter(filterString, this.folders); + break; + case "history": { + let currentOptions = PO.getCurrentOptions(); + if (currentOptions.queryType != Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) { + let query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + let options = currentOptions.clone(); + // Make sure we're getting uri results. + options.resultType = currentOptions.RESULTS_AS_URI; + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY; + options.includeHidden = true; + currentView.load([query], options); + } else { + currentView.applyFilter(filterString, null, true); + } + break; + } + default: + throw "Invalid filterCollection on search"; + } + + // Update the details panel + PlacesOrganizer.updateDetailsPane(); + }, + + /** + * Finds across all history or all bookmarks. + */ + findAll: function PSB_findAll() { + switch (this.filterCollection) { + case "history": + PlacesQueryBuilder.setScope("history"); + break; + default: + PlacesQueryBuilder.setScope("bookmarks"); + break; + } + this.focus(); + }, + + /** + * Updates the display with the title of the current collection. + * @param aTitle + * The title of the current collection. + */ + updateCollectionTitle: function PSB_updateCollectionTitle(aTitle) { + let title = ""; + switch (this.filterCollection) { + case "history": + title = PlacesUIUtils.getString("searchHistory"); + break; + default: + title = PlacesUIUtils.getString("searchBookmarks"); + } + this.searchFilter.placeholder = title; + }, + + /** + * Gets/sets the active collection from the dropdown menu. + */ + get filterCollection() { + return this.searchFilter.getAttribute("collection"); + }, + set filterCollection(collectionName) { + if (collectionName == this.filterCollection) + return collectionName; + + this.searchFilter.setAttribute("collection", collectionName); + this.updateCollectionTitle(); + + return collectionName; + }, + + /** + * Focus the search box + */ + focus: function PSB_focus() { + this.searchFilter.focus(); + }, + + /** + * Set up the gray text in the search bar as the Places View loads. + */ + init: function PSB_init() { + if (Services.prefs.getBoolPref("browser.urlbar.clickSelectsAll", false)) { + this.searchFilter.setAttribute("clickSelectsAll", true); + } + this.updateCollectionTitle(); + }, + + /** + * Gets or sets the text shown in the Places Search Box + */ + get value() { + return this.searchFilter.value; + }, + set value(value) { + return this.searchFilter.value = value; + }, +}; + +/** + * Functions and data for advanced query builder + */ +var PlacesQueryBuilder = { + + queries: [], + queryOptions: null, + + /** + * Sets the search scope. This can be called when no search is active, and + * in that case, when the user does begin a search aScope will be used (see + * PSB_search()). If there is an active search, it's performed again to + * update the content tree. + * @param aScope + * The search scope: "bookmarks", "collection" or "history". + */ + setScope: function PQB_setScope(aScope) { + // Determine filterCollection, folders, and scopeButtonId based on aScope. + var filterCollection; + var folders = []; + switch (aScope) { + case "history": + filterCollection = "history"; + break; + case "bookmarks": + filterCollection = "bookmarks"; + folders.push(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.toolbarFolderId, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.mobileFolderId); + break; + default: + throw "Invalid search scope"; + } + + // Update the search box. Re-search if there's an active search. + PlacesSearchBox.filterCollection = filterCollection; + PlacesSearchBox.folders = folders; + var searchStr = PlacesSearchBox.searchFilter.value; + if (searchStr) + PlacesSearchBox.search(searchStr); + } +}; + +/** + * Population and commands for the View Menu. + */ +var ViewMenu = { + /** + * Removes content generated previously from a menupopup. + * @param popup + * The popup that contains the previously generated content. + * @param startID + * The id attribute of an element that is the start of the + * dynamically generated region - remove elements after this + * item only. + * Must be contained by popup. Can be null (in which case the + * contents of popup are removed). + * @param endID + * The id attribute of an element that is the end of the + * dynamically generated region - remove elements up to this + * item only. + * Must be contained by popup. Can be null (in which case all + * items until the end of the popup will be removed). Ignored + * if startID is null. + * @returns The element for the caller to insert new items before, + * null if the caller should just append to the popup. + */ + _clean: function VM__clean(popup, startID, endID) { + if (endID && !startID) + throw new Error("meaningless to have valid endID and null startID"); + if (startID) { + var startElement = document.getElementById(startID); + if (!startElement) + throw new Error("startID does not correspond to an existing element"); + if (startElement.parentNode != popup) + throw new Error("startElement is not in popup"); + var endElement = null; + if (endID) { + endElement = document.getElementById(endID); + if (!endElement) + throw new Error("endID does not correspond to an existing element"); + if (endElement.parentNode != popup) + throw new Error("endElement is not in popup"); + } + while (startElement.nextSibling != endElement) + popup.removeChild(startElement.nextSibling); + return endElement; + } + while (popup.hasChildNodes()) { + popup.firstChild.remove(); + } + return null; + }, + + /** + * Fills a menupopup with a list of columns + * @param event + * The popupshowing event that invoked this function. + * @param startID + * see _clean + * @param endID + * see _clean + * @param type + * the type of the menuitem, e.g. "radio" or "checkbox". + * Can be null (no-type). + * Checkboxes are checked if the column is visible. + * @param propertyPrefix + * If propertyPrefix is non-null: + * propertyPrefix + column ID + ".label" will be used to get the + * localized label string. + * propertyPrefix + column ID + ".accesskey" will be used to get the + * localized accesskey. + * If propertyPrefix is null, the column label is used as label and + * no accesskey is assigned. + */ + fillWithColumns: function VM_fillWithColumns(event, startID, endID, type, propertyPrefix) { + var popup = event.target; + var pivot = this._clean(popup, startID, endID); + + var content = document.getElementById("placeContent"); + var columns = content.columns; + for (var i = 0; i < columns.count; ++i) { + var column = columns.getColumnAt(i).element; + var menuitem = document.createElement("menuitem"); + menuitem.id = "menucol_" + column.id; + menuitem.column = column; + var label = column.getAttribute("label"); + if (propertyPrefix) { + var menuitemPrefix = propertyPrefix; + // for string properties, use "name" as the id, instead of "title" + // see bug #386287 for details + var columnId = column.getAttribute("anonid"); + menuitemPrefix += columnId == "title" ? "name" : columnId; + label = PlacesUIUtils.getString(menuitemPrefix + ".label"); + var accesskey = PlacesUIUtils.getString(menuitemPrefix + ".accesskey"); + menuitem.setAttribute("accesskey", accesskey); + } + menuitem.setAttribute("label", label); + if (type == "radio") { + menuitem.setAttribute("type", "radio"); + menuitem.setAttribute("name", "columns"); + // This column is the sort key. Its item is checked. + if (column.getAttribute("sortDirection") != "") { + menuitem.setAttribute("checked", "true"); + } + } else if (type == "checkbox") { + menuitem.setAttribute("type", "checkbox"); + // Cannot uncheck the primary column. + if (column.getAttribute("primary") == "true") + menuitem.setAttribute("disabled", "true"); + // Items for visible columns are checked. + if (!column.hidden) + menuitem.setAttribute("checked", "true"); + } + if (pivot) + popup.insertBefore(menuitem, pivot); + else + popup.appendChild(menuitem); + } + event.stopPropagation(); + }, + + /** + * Set up the content of the view menu. + */ + populateSortMenu: function VM_populateSortMenu(event) { + this.fillWithColumns(event, "viewUnsorted", "directionSeparator", "radio", "view.sortBy.1."); + + var sortColumn = this._getSortColumn(); + var viewSortAscending = document.getElementById("viewSortAscending"); + var viewSortDescending = document.getElementById("viewSortDescending"); + // We need to remove an existing checked attribute because the unsorted + // menu item is not rebuilt every time we open the menu like the others. + var viewUnsorted = document.getElementById("viewUnsorted"); + if (!sortColumn) { + viewSortAscending.removeAttribute("checked"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.setAttribute("checked", "true"); + } else if (sortColumn.getAttribute("sortDirection") == "ascending") { + viewSortAscending.setAttribute("checked", "true"); + viewSortDescending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } else if (sortColumn.getAttribute("sortDirection") == "descending") { + viewSortDescending.setAttribute("checked", "true"); + viewSortAscending.removeAttribute("checked"); + viewUnsorted.removeAttribute("checked"); + } + }, + + /** + * Shows/Hides a tree column. + * @param element + * The menuitem element for the column + */ + showHideColumn: function VM_showHideColumn(element) { + var column = element.column; + + var splitter = column.nextSibling; + if (splitter && splitter.localName != "splitter") + splitter = null; + + if (element.getAttribute("checked") == "true") { + column.setAttribute("hidden", "false"); + if (splitter) + splitter.removeAttribute("hidden"); + } else { + column.setAttribute("hidden", "true"); + if (splitter) + splitter.setAttribute("hidden", "true"); + } + }, + + /** + * Gets the last column that was sorted. + * @returns the currently sorted column, null if there is no sorted column. + */ + _getSortColumn: function VM__getSortColumn() { + var content = document.getElementById("placeContent"); + var cols = content.columns; + for (var i = 0; i < cols.count; ++i) { + var column = cols.getColumnAt(i).element; + var sortDirection = column.getAttribute("sortDirection"); + if (sortDirection == "ascending" || sortDirection == "descending") + return column; + } + return null; + }, + + /** + * Sorts the view by the specified column. + * @param aColumn + * The colum that is the sort key. Can be null - the + * current sort column or the title column will be used. + * @param aDirection + * The direction to sort - "ascending" or "descending". + * Can be null - the last direction or descending will be used. + * + * If both aColumnID and aDirection are null, the view will be unsorted. + */ + setSortColumn: function VM_setSortColumn(aColumn, aDirection) { + var result = document.getElementById("placeContent").result; + if (!aColumn && !aDirection) { + result.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + return; + } + + var columnId; + if (aColumn) { + columnId = aColumn.getAttribute("anonid"); + if (!aDirection) { + let sortColumn = this._getSortColumn(); + if (sortColumn) + aDirection = sortColumn.getAttribute("sortDirection"); + } + } else { + let sortColumn = this._getSortColumn(); + columnId = sortColumn ? sortColumn.getAttribute("anonid") : "title"; + } + + // This maps the possible values of columnId (i.e., anonid's of treecols in + // placeContent) to the default sortingMode and sortingAnnotation values for + // each column. + // key: Sort key in the name of one of the + // nsINavHistoryQueryOptions.SORT_BY_* constants + // dir: Default sort direction to use if none has been specified + // anno: The annotation to sort by, if key is "ANNOTATION" + var colLookupTable = { + title: { key: "TITLE", dir: "ascending" }, + tags: { key: "TAGS", dir: "ascending" }, + url: { key: "URI", dir: "ascending" }, + date: { key: "DATE", dir: "descending" }, + visitCount: { key: "VISITCOUNT", dir: "descending" }, + dateAdded: { key: "DATEADDED", dir: "descending" }, + lastModified: { key: "LASTMODIFIED", dir: "descending" }, + description: { key: "ANNOTATION", + dir: "ascending", + anno: PlacesUIUtils.DESCRIPTION_ANNO } + }; + + // Make sure we have a valid column. + if (!colLookupTable.hasOwnProperty(columnId)) + throw new Error("Invalid column"); + + // Use a default sort direction if none has been specified. If aDirection + // is invalid, result.sortingMode will be undefined, which has the effect + // of unsorting the tree. + aDirection = (aDirection || colLookupTable[columnId].dir).toUpperCase(); + + var sortConst = "SORT_BY_" + colLookupTable[columnId].key + "_" + aDirection; + result.sortingAnnotation = colLookupTable[columnId].anno || ""; + result.sortingMode = Ci.nsINavHistoryQueryOptions[sortConst]; + } +}; + +var ContentArea = { + _specialViews: new Map(), + + init: function CA_init() { + this._deck = document.getElementById("placesViewsDeck"); + this._toolbar = document.getElementById("placesToolbar"); + ContentTree.init(); + this._setupView(); + }, + + /** + * Gets the content view to be used for loading the given query. + * If a custom view was set by setContentViewForQueryString, that + * view would be returned, else the default tree view is returned + * + * @param aQueryString + * a query string + * @return the view to be used for loading aQueryString. + */ + getContentViewForQueryString: + function CA_getContentViewForQueryString(aQueryString) { + try { + if (this._specialViews.has(aQueryString)) { + let { view, options } = this._specialViews.get(aQueryString); + if (typeof view == "function") { + view = view(); + this._specialViews.set(aQueryString, { view, options }); + } + return view; + } + } catch (ex) { + Cu.reportError(ex); + } + return ContentTree.view; + }, + + /** + * Sets a custom view to be used rather than the default places tree + * whenever the given query is selected in the left pane. + * @param aQueryString + * a query string + * @param aView + * Either the custom view or a function that will return the view + * the first (and only) time it's called. + * @param [optional] aOptions + * Object defining special options for the view. + * @see ContentTree.viewOptions for supported options and default values. + */ + setContentViewForQueryString: + function CA_setContentViewForQueryString(aQueryString, aView, aOptions) { + if (!aQueryString || + typeof aView != "object" && typeof aView != "function") + throw new Error("Invalid arguments"); + + this._specialViews.set(aQueryString, { view: aView, + options: aOptions || {} }); + }, + + get currentView() { + return PlacesUIUtils.getViewForNode(this._deck.selectedPanel); + }, + set currentView(aNewView) { + let oldView = this.currentView; + if (oldView != aNewView) { + this._deck.selectedPanel = aNewView.associatedElement; + + // If the content area inactivated view was focused, move focus + // to the new view. + if (document.activeElement == oldView.associatedElement) + aNewView.associatedElement.focus(); + } + return aNewView; + }, + + get currentPlace() { + return this.currentView.place; + }, + set currentPlace(aQueryString) { + let oldView = this.currentView; + let newView = this.getContentViewForQueryString(aQueryString); + newView.place = aQueryString; + if (oldView != newView) { + oldView.active = false; + this.currentView = newView; + this._setupView(); + newView.active = true; + } + return aQueryString; + }, + + /** + * Applies view options. + */ + _setupView: function CA__setupView() { + let options = this.currentViewOptions; + + // showDetailsPane. + let detailsDeck = document.getElementById("detailsDeck"); + detailsDeck.hidden = !options.showDetailsPane; + + // toolbarSet. + for (let elt of this._toolbar.childNodes) { + elt.hidden = !options.toolbarSet.includes(elt.id); + } + }, + + /** + * Options for the current view. + * + * @see ContentTree.viewOptions for supported options and default values. + */ + get currentViewOptions() { + // Use ContentTree options as default. + let viewOptions = ContentTree.viewOptions; + if (this._specialViews.has(this.currentPlace)) { + let { options } = this._specialViews.get(this.currentPlace); + for (let option in options) { + viewOptions[option] = options[option]; + } + } + return viewOptions; + }, + + focus() { + this._deck.selectedPanel.focus(); + } +}; + +var ContentTree = { + init: function CT_init() { + this._view = document.getElementById("placeContent"); + }, + + get view() { + return this._view; + }, + + get viewOptions() { + return Object.seal({ + showDetailsPane: true, + toolbarSet: "placesMenu, toolbar-spacer, searchFilter" + }); + }, + + openSelectedNode: function CT_openSelectedNode(aEvent) { + let view = this.view; + PlacesUIUtils.openNodeWithEvent(view.selectedNode, aEvent, true); + }, + + onClick: function CT_onClick(aEvent) { + let node = this.view.selectedNode; + if (node) { + let doubleClick = aEvent.button == 0 && aEvent.detail == 2; + let middleClick = aEvent.button == 1 && aEvent.detail == 1; + if (PlacesUtils.nodeIsURI(node) && (doubleClick || middleClick)) { + // Open associated uri in the browser. + this.openSelectedNode(aEvent); + } else if (middleClick && PlacesUtils.nodeIsContainer(node)) { + // The command execution function will take care of seeing if the + // selection is a folder or a different container type, and will + // load its contents in tabs. + PlacesUIUtils.openContainerNodeInTabs(node, aEvent, this.view); + } + } + }, + + onKeyPress: function CT_onKeyPress(aEvent) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) + this.openSelectedNode(aEvent); + } +}; diff --git a/comm/suite/components/places/content/places.xul b/comm/suite/components/places/content/places.xul new file mode 100644 index 0000000000..d5acde063d --- /dev/null +++ b/comm/suite/components/places/content/places.xul @@ -0,0 +1,337 @@ +<?xml version="1.0"?> + +# 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/. + +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/content/places/organizer.css"?> + +<?xml-stylesheet href="chrome://communicator/skin/"?> +<?xml-stylesheet href="chrome://communicator/skin/places/bookmarks.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> + +<?xul-overlay href="chrome://communicator/content/places/editBookmarkOverlay.xul"?> + +<?xul-overlay href="chrome://communicator/content/utilityOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/tasksOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> + +<!DOCTYPE window [ +<!ENTITY % placesDTD SYSTEM "chrome://communicator/locale/places/places.dtd"> +%placesDTD; +<!ENTITY % editMenuOverlayDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuOverlayDTD; +<!ENTITY % navDTD SYSTEM "chrome://navigator/locale/navigator.dtd"> +%navDTD; +]> + +<window id="places" + title="&places.library.title;" + windowtype="Places:Organizer" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + onload="PlacesOrganizer.init();" + onunload="PlacesOrganizer.destroy();" + width="&places.library.width;" height="&places.library.height;" + screenX="10" screenY="10" + toggletoolbar="true" + persist="width height screenX screenY sizemode"> + + <script src="chrome://communicator/content/places/places.js"/> + <script src="chrome://communicator/content/places/editBookmarkOverlay.js"/> + <script src="chrome://global/content/editMenuOverlay.js"/> + + <stringbundleset id="placesStringSet"> + <stringbundle id="brandStrings" src="chrome://branding/locale/brand.properties"/> + </stringbundleset> + + <commandset id="placesCommands"/> + <commandset id="tasksCommands"/> + + <commandset id="organizerCommandSet"> + <command id="OrganizerCommand_find:all" + oncommand="PlacesSearchBox.findAll();"/> + <command id="OrganizerCommand_export" + oncommand="PlacesOrganizer.exportBookmarks();"/> + <command id="OrganizerCommand_import" + oncommand="PlacesOrganizer.importFromFile();"/> + <command id="OrganizerCommand_backup" + oncommand="PlacesOrganizer.backupBookmarks();"/> + <command id="OrganizerCommand_restoreFromFile" + oncommand="PlacesOrganizer.onRestoreBookmarksFromFile();"/> + <command id="OrganizerCommand_search:save" + oncommand="PlacesOrganizer.saveSearch();"/> + <command id="OrganizerCommand_search:moreCriteria" + oncommand="PlacesQueryBuilder.addRow();"/> + <command id="OrganizerCommand:Back" + oncommand="PlacesOrganizer.back();"/> + <command id="OrganizerCommand:Forward" + oncommand="PlacesOrganizer.forward();"/> + </commandset> + + <keyset id="placesOrganizerKeyset"> + <!-- Instantiation Keys --> + <key id="placesKey_close" key="&cmd.close.key;" modifiers="accel" + oncommand="close();"/> + + <!-- Command Keys --> + <key id="placesKey_find:all" + command="OrganizerCommand_find:all" + key="&cmd.find.key;" + modifiers="accel"/> + + <!-- Back/Forward Keys Support --> + <key id="placesKey_goBackKb" + keycode="VK_LEFT" + command="OrganizerCommand:Back" + modifiers="accel"/> + <key id="placesKey_goForwardKb" + keycode="VK_RIGHT" + command="OrganizerCommand:Forward" + modifiers="accel"/> + </keyset> + +#include ../../../../../toolkit/content/editMenuKeys.inc.xhtml +#ifdef XP_MACOSX + <keyset id="editMenuKeysExtra"> + <key id="key_delete2" keycode="VK_BACK" command="cmd_delete"/> + </keyset> +#endif + + <keyset id="tasksKeys"> + <key id="key_close2" disabled="true"/> + </keyset> + + <popupset id="placesPopupset"> + <menupopup id="placesContext"/> + <menupopup id="placesColumnsContext" + onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', null);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> + </popupset> + + <toolbox id="placesToolbox"> + <toolbar id="placesToolbar" + class="chromeclass-toolbar" + align="center"> + <menubar id="placesMenu"> + <menu id="menu_File"> + <menupopup id="menu_FilePopup"> + <menuitem id="newbookmark" + command="placesCmd_new:bookmark" + label="&cmd.new_bookmark.label;" + accesskey="&cmd.new_bookmark.accesskey;"/> + <menuitem id="newfolder" + command="placesCmd_new:folder" + label="&cmd.new_folder.label;" + accesskey="&cmd.new_folder.accesskey;"/> + <menuitem id="newseparator" + command="placesCmd_new:separator" + label="&cmd.new_separator.label;" + accesskey="&cmd.new_separator.accesskey;"/> + <menuseparator id="fileNewSeparator"/> + <menuitem id="orgClose" + key="placesKey_close" + label="&file.close.label;" + accesskey="&file.close.accesskey;" + oncommand="close();"/> + </menupopup> + </menu> + + <menu id="menu_Edit"> + <menupopup id="menu_EditPopup"> + <menuitem id="menu_undo"/> + <menuitem id="menu_redo"/> + + <menuseparator id="orgCutSeparator"/> + + <menuitem id="menu_cut" + selection="separator|link|folder|mixed"/> + <menuitem id="menu_copy" + selection="separator|link|folder|mixed"/> + <menuitem id="menu_paste" + selection="mutable"/> + <menuitem id="menu_delete"/> + + <menuseparator id="selectAllSeparator"/> + + <menuitem id="menu_selectAll"/> + </menupopup> + </menu> + + <menu id="menu_View"> + <menupopup id="menu_ViewPopup" + onpopupshowing="onViewToolbarsPopupShowing(event)" + oncommand="onViewToolbarCommand(event);"> + <menuseparator id="toolbarmode-sep"/> + <menu id="viewColumns" + label="&view.columns.label;" accesskey="&view.columns.accesskey;"> + <menupopup onpopupshowing="ViewMenu.fillWithColumns(event, null, null, 'checkbox', null);" + oncommand="ViewMenu.showHideColumn(event.target); event.stopPropagation();"/> + </menu> + + <menu id="viewSort" label="&view.sort.label;" + accesskey="&view.sort.accesskey;"> + <menupopup onpopupshowing="ViewMenu.populateSortMenu(event);" + oncommand="ViewMenu.setSortColumn(event.target.column, null);"> + <menuitem id="viewUnsorted" type="radio" name="columns" + label="&view.unsorted.label;" accesskey="&view.unsorted.accesskey;" + oncommand="ViewMenu.setSortColumn(null, null);"/> + <menuseparator id="directionSeparator"/> + <menuitem id="viewSortAscending" type="radio" name="direction" + label="&view.sortAscending.label;" accesskey="&view.sortAscending.accesskey;" + oncommand="ViewMenu.setSortColumn(null, 'ascending'); event.stopPropagation();"/> + <menuitem id="viewSortDescending" type="radio" name="direction" + label="&view.sortDescending.label;" accesskey="&view.sortDescending.accesskey;" + oncommand="ViewMenu.setSortColumn(null, 'descending'); event.stopPropagation();"/> + </menupopup> + </menu> + </menupopup> + </menu> + + <!-- tasks menu filled from tasksOverlay --> + <menu id="tasksMenu"> + <menupopup id="taskPopup"> + <menuitem id="backupBookmarks" + command="OrganizerCommand_backup" + label="&cmd.backup.label;" + accesskey="&cmd.backup.accesskey;"/> + <menu id="fileRestoreMenu" label="&cmd.restore2.label;" + accesskey="&cmd.restore2.accesskey;"> + <menupopup id="fileRestorePopup" onpopupshowing="PlacesOrganizer.populateRestoreMenu();"> + <menuitem id="restoreFromFile" + command="OrganizerCommand_restoreFromFile" + label="&cmd.restoreFromFile.label;" + accesskey="&cmd.restoreFromFile.accesskey;"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="fileImport" + command="OrganizerCommand_import" + label="&importBookmarksFromHTML.label;" + accesskey="&importBookmarksFromHTML.accesskey;"/> + <menuitem id="fileExport" + command="OrganizerCommand_export" + label="&exportBookmarksToHTML.label;" + accesskey="&exportBookmarksToHTML.accesskey;"/> + <menuseparator/> + </menupopup> + </menu> + + <!-- window menu filled from tasksOverlay --> + <menu id="windowMenu"/> + + <!-- help menu filled from globalOverlay --> + <menu id="menu_Help"/> + </menubar> + + <toolbarspring id="toolbar-spacer"/> + + <textbox id="searchFilter" + type="search" + aria-controls="placeContent" + oncommand="PlacesSearchBox.search(this.value);" + collection="bookmarks"> + </textbox> + </toolbar> + </toolbox> + + <hbox flex="1" id="placesView"> + <tree id="placesList" + class="plain placesTree" + type="places" + hidecolumnpicker="true" + treelines="true" + context="placesContext" + onselect="PlacesOrganizer.onPlaceSelected(true);" + onclick="PlacesOrganizer.onPlacesListClick(event);" + onfocus="PlacesOrganizer.updateDetailsPane(event);" + seltype="single" + persist="width" + width="200" + minwidth="100" + maxwidth="400"> + <treecols> + <treecol anonid="title" flex="1" primary="true" hideheader="true"/> + </treecols> + <treechildren flex="1"/> + </tree> + <splitter collapse="none" persist="state"></splitter> + <vbox id="contentView" flex="4"> + <deck id="placesViewsDeck" + selectedIndex="0" + flex="1"> + <tree id="placeContent" + class="plain placesTree" + treelines="true" + context="placesContext" + flex="1" + type="places" + selectfirstnode="true" + enableColumnDrag="true" + onfocus="PlacesOrganizer.updateDetailsPane(event)" + onselect="PlacesOrganizer.updateDetailsPane(event)" + onkeypress="ContentTree.onKeyPress(event);" + onopenflatcontainer="PlacesOrganizer.openFlatContainer(aContainer);"> + <treecols id="placeContentColumns" context="placesColumnsContext"> + <treecol label="&col.name.label;" id="placesContentTitle" anonid="title" flex="5" primary="true" ordinal="1" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.tags.label;" id="placesContentTags" anonid="tags" flex="2" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.url.label;" id="placesContentUrl" anonid="url" flex="5" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.mostrecentvisit.label;" id="placesContentDate" anonid="date" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.visitcount.label;" id="placesContentVisitCount" anonid="visitCount" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.description.label;" id="placesContentDescription" anonid="description" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.dateadded.label;" id="placesContentDateAdded" anonid="dateAdded" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + <splitter class="tree-splitter"/> + <treecol label="&col.lastmodified.label;" id="placesContentLastModified" anonid="lastModified" flex="1" hidden="true" + persist="width hidden ordinal sortActive sortDirection"/> + </treecols> + <treechildren flex="1" onclick="ContentTree.onClick(event);"/> + </tree> + </deck> + <deck id="detailsDeck" style="height: 11em;"> + <vbox id="itemsCountBox" align="center"> + <spacer flex="3"/> + <label id="itemsCountText"/> + <spacer flex="1"/> + <description id="selectItemDescription"> + &detailsPane.selectAnItemText.description; + </description> + <spacer flex="3"/> + </vbox> + <vbox id="infoBox" minimal="true"> + <vbox id="editBookmarkPanelContent" flex="1"/> + <hbox id="infoBoxExpanderWrapper" align="center"> + + <button type="image" id="infoBoxExpander" + class="expander-down" + oncommand="PlacesOrganizer.toggleAdditionalInfoFields();" + observes="paneElementsBroadcaster"/> + + <label id="infoBoxExpanderLabel" + lesslabel="&detailsPane.less.label;" + lessaccesskey="&detailsPane.less.accesskey;" + morelabel="&detailsPane.more.label;" + moreaccesskey="&detailsPane.more.accesskey;" + value="&detailsPane.more.label;" + accesskey="&detailsPane.more.accesskey;" + control="infoBoxExpander"/> + + </hbox> + </vbox> + </deck> + </vbox> + </hbox> +</window> diff --git a/comm/suite/components/places/content/placesOverlay.xul b/comm/suite/components/places/content/placesOverlay.xul new file mode 100644 index 0000000000..a71f1e2269 --- /dev/null +++ b/comm/suite/components/places/content/placesOverlay.xul @@ -0,0 +1,228 @@ +<!-- 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/. --> + +<!DOCTYPE overlay [ +<!ENTITY % placesDTD SYSTEM "chrome://communicator/locale/places/places.dtd"> +%placesDTD; +<!ENTITY % editMenuOverlayDTD SYSTEM "chrome://global/locale/editMenuOverlay.dtd"> +%editMenuOverlayDTD; +]> + +<overlay id="placesOverlay" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script src="chrome://global/content/globalOverlay.js"/> + <script> + <![CDATA[ + const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + ChromeUtils.defineModuleGetter(window, + "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); + ChromeUtils.defineModuleGetter(window, + "PlacesUIUtils", "resource:///modules/PlacesUIUtils.jsm"); + ChromeUtils.defineModuleGetter(window, + "PlacesTransactions", "resource://gre/modules/PlacesTransactions.jsm"); + ChromeUtils.defineModuleGetter(window, + "ForgetAboutSite", "resource://gre/modules/ForgetAboutSite.jsm"); + ChromeUtils.defineModuleGetter(window, + "PrivateBrowsingUtils", "resource://gre/modules/PrivateBrowsingUtils.jsm"); + + XPCOMUtils.defineLazyScriptGetter(window, "PlacesTreeView", + "chrome://communicator/content/places/treeView.js"); + XPCOMUtils.defineLazyScriptGetter(window, + ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"], + "chrome://communicator/content/places/controller.js"); + ]]></script> + + <!-- Bookmarks and history tooltip --> + <tooltip id="bhTooltip" noautohide="true" + onpopupshowing="return window.top.BookmarksEventHandler.fillInBHTooltip(document, event)"> + <vbox id="bhTooltipTextBox" flex="1"> + <label id="bhtTitleText" class="tooltip-label" /> + <label id="bhtUrlText" crop="center" class="tooltip-label uri-element" /> + </vbox> + </tooltip> + + <commandset id="placesCommands" + commandupdater="true" + events="focus,sort,places" + oncommandupdate="PlacesUIUtils.updateCommands(window);"> + <command id="placesCmd_open" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open');"/> + <command id="placesCmd_open:window" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:window');"/> + <command id="placesCmd_open:privatewindow" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:privatewindow');"/> + <command id="placesCmd_open:tab" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_open:tab');"/> + + <command id="placesCmd_new:bookmark" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:bookmark');"/> + <command id="placesCmd_new:folder" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:folder');"/> + <command id="placesCmd_new:separator" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_new:separator');"/> + <command id="placesCmd_show:info" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');"/> + <command id="placesCmd_rename" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_show:info');" + observes="placesCmd_show:info"/> + <command id="placesCmd_reload" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_reload');"/> + <command id="placesCmd_sortBy:name" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_sortBy:name');"/> + <command id="placesCmd_deleteDataHost" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_deleteDataHost');"/> + <command id="placesCmd_createBookmark" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_createBookmark');"/> + + <!-- Special versions of cut/copy/paste/delete which check for an open context menu. --> + <command id="placesCmd_cut" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_cut');"/> + <command id="placesCmd_copy" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_copy');"/> + <command id="placesCmd_paste" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_paste');"/> + <command id="placesCmd_delete" + oncommand="PlacesUIUtils.doCommand(window, 'placesCmd_delete');"/> + </commandset> + + <menupopup id="placesContext" + onpopupshowing="this._view = PlacesUIUtils.getViewForNode(document.popupNode); + if (!PlacesUIUtils.openInTabClosesMenu) { + document.getElementById ('placesContext_open:newtab') + .setAttribute('closemenu', 'single'); + } + return this._view.buildContextMenu(this);" + onpopuphiding="this._view.destroyContextMenu();"> + <menuitem id="placesContext_open" + command="placesCmd_open" + label="&cmd.open.label;" + accesskey="&cmd.open.accesskey;" + default="true" + selectiontype="single" + selection="link"/> + <menuitem id="placesContext_open:newtab" + command="placesCmd_open:tab" + label="&cmd.open_tab.label;" + accesskey="&cmd.open_tab.accesskey;" + selectiontype="single" + selection="link"/> + <menuitem id="placesContext_openContainer:tabs" + oncommand="var view = PlacesUIUtils.getViewForNode(document.popupNode); + view.controller.openSelectionInTabs(event);" + onclick="checkForMiddleClick(this, event);" + label="&cmd.open_all_in_tabs.label;" + accesskey="&cmd.open_all_in_tabs.accesskey;" + selectiontype="single|none" + selection="folder|host|query"/> + <menuitem id="placesContext_openLinks:tabs" + oncommand="var view = PlacesUIUtils.getViewForNode(document.popupNode); + view.controller.openSelectionInTabs(event);" + onclick="checkForMiddleClick(this, event);" + label="&cmd.open_all_in_tabs.label;" + accesskey="&cmd.open_all_in_tabs.accesskey;" + selectiontype="multiple" + selection="link"/> + <menuitem id="placesContext_open:newwindow" + command="placesCmd_open:window" + label="&cmd.open_window.label;" + accesskey="&cmd.open_window.accesskey;" + selectiontype="single" + selection="link"/> + <menuitem id="placesContext_open:newprivatewindow" + command="placesCmd_open:privatewindow" + label="&cmd.open_private_window.label;" + accesskey="&cmd.open_private_window.accesskey;" + selectiontype="single" + selection="link" + hideifprivatebrowsing="true"/> + <menuseparator id="placesContext_openSeparator"/> + <menuitem id="placesContext_new:bookmark" + command="placesCmd_new:bookmark" + label="&cmd.new_bookmark.label;" + accesskey="&cmd.new_bookmark.accesskey;" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuitem id="placesContext_new:folder" + command="placesCmd_new:folder" + label="&cmd.new_folder.label;" + accesskey="&cmd.context_new_folder.accesskey;" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuitem id="placesContext_new:separator" + command="placesCmd_new:separator" + label="&cmd.new_separator.label;" + accesskey="&cmd.new_separator.accesskey;" + closemenu="single" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuseparator id="placesContext_newSeparator"/> + <menuitem id="placesContext_createBookmark" + command="placesCmd_createBookmark" + selection="link" + forcehideselection="bookmark|tagChild"/> + <menuitem id="placesContext_cut" + command="placesCmd_cut" + label="&cutCmd.label;" + accesskey="&cutCmd.accesskey;" + closemenu="single" + selection="bookmark|folder|separator|query" + forcehideselection="tagChild|livemarkChild"/> + <menuitem id="placesContext_copy" + command="placesCmd_copy" + label="©Cmd.label;" + closemenu="single" + accesskey="©Cmd.accesskey;" + selection="any"/> + <menuitem id="placesContext_paste" + command="placesCmd_paste" + label="&pasteCmd.label;" + closemenu="single" + accesskey="&pasteCmd.accesskey;" + selectiontype="any" + hideifnoinsertionpoint="true"/> + <menuseparator id="placesContext_editSeparator"/> + <menuitem id="placesContext_delete" + command="placesCmd_delete" + label="&deleteCmd.label;" + accesskey="&deleteCmd.accesskey;" + closemenu="single" + selection="bookmark|tagChild|folder|query|dynamiccontainer|separator|host"/> + <menuitem id="placesContext_delete_history" + command="placesCmd_delete" + closemenu="single" + selection="link" + forcehideselection="bookmark"/> + <menuitem id="placesContext_deleteHost" + command="placesCmd_deleteDataHost" + label="&cmd.deleteDomainData.label;" + accesskey="&cmd.deleteDomainData.accesskey;" + closemenu="single" + selection="link|host" + selectiontype="single" + forcehideselection="bookmark"/> + <menuseparator id="placesContext_deleteSeparator"/> + <menuitem id="placesContext_sortBy:name" + command="placesCmd_sortBy:name" + label="&cmd.sortby_name.label;" + accesskey="&cmd.context_sortby_name.accesskey;" + closemenu="single" + selection="folder"/> + <menuitem id="placesContext_reload" + command="placesCmd_reload" + label="&cmd.reloadLivebookmark.label;" + accesskey="&cmd.reloadLivebookmark.accesskey;" + closemenu="single" + selection="livemark/feedURI"/> + <menuseparator id="placesContext_sortSeparator"/> + <menuitem id="placesContext_show:info" + command="placesCmd_show:info" + label="&cmd.properties.label;" + accesskey="&cmd.properties.accesskey;" + selection="bookmark|folder|query" + forcehideselection="livemarkChild"/> + </menupopup> + +</overlay> diff --git a/comm/suite/components/places/content/sidebarUtils.js b/comm/suite/components/places/content/sidebarUtils.js new file mode 100644 index 0000000000..b11f891187 --- /dev/null +++ b/comm/suite/components/places/content/sidebarUtils.js @@ -0,0 +1,105 @@ +/* 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/. */ + +const {AppConstants} = ChromeUtils.import("resource://gre/modules/AppConstants.jsm"); + +let uidensity = window.top.document.documentElement.getAttribute("uidensity"); +if (uidensity) { + document.documentElement.setAttribute("uidensity", uidensity); +} + +var SidebarUtils = { + handleTreeClick: function SU_handleTreeClick(aTree, aEvent, aGutterSelect) { + // right-clicks are not handled here + if (aEvent.button == 2) + return; + + var tbo = aTree.treeBoxObject; + var cell = tbo.getCellAt(aEvent.clientX, aEvent.clientY); + + if (cell.row == -1 || cell.childElt == "twisty") + return; + + var mouseInGutter = false; + if (aGutterSelect) { + var rect = tbo.getCoordsForCellItem(cell.row, cell.col, "image"); + // getCoordsForCellItem returns the x coordinate in logical coordinates + // (i.e., starting from the left and right sides in LTR and RTL modes, + // respectively.) Therefore, we make sure to exclude the blank area + // before the tree item icon (that is, to the left or right of it in + // LTR and RTL modes, respectively) from the click target area. + var isRTL = window.getComputedStyle(aTree).direction == "rtl"; + if (isRTL) + mouseInGutter = aEvent.clientX > rect.x; + else + mouseInGutter = aEvent.clientX < rect.x; + } + + var metaKey = AppConstants.platform === "macosx" ? aEvent.metaKey + : aEvent.ctrlKey; + var modifKey = metaKey || aEvent.shiftKey; + var isContainer = tbo.view.isContainer(cell.row); + var openInTabs = isContainer && + (aEvent.button == 1 || + (aEvent.button == 0 && modifKey)) && + PlacesUtils.hasChildURIs(aTree.view.nodeForTreeIndex(cell.row)); + + if (aEvent.button == 0 && isContainer && !openInTabs) { + tbo.view.toggleOpenState(cell.row); + } else if (!mouseInGutter && openInTabs && + aEvent.originalTarget.localName == "treechildren") { + tbo.view.selection.select(cell.row); + PlacesUIUtils.openContainerNodeInTabs(aTree.selectedNode, aEvent, aTree); + } else if (!mouseInGutter && !isContainer && + aEvent.originalTarget.localName == "treechildren") { + // Clear all other selection since we're loading a link now. We must + // do this *before* attempting to load the link since openURL uses + // selection as an indication of which link to load. + tbo.view.selection.select(cell.row); + PlacesUIUtils.openNodeWithEvent(aTree.selectedNode, aEvent); + } + }, + + handleTreeKeyPress: function SU_handleTreeKeyPress(aEvent) { + let node = aEvent.target.selectedNode; + if (node) { + if (aEvent.keyCode == KeyEvent.DOM_VK_RETURN) + PlacesUIUtils.openNodeWithEvent(node, aEvent); + } + }, + + /** + * The following function displays the URL of a node that is being + * hovered over. + */ + handleTreeMouseMove: function SU_handleTreeMouseMove(aEvent) { + if (aEvent.target.localName != "treechildren") + return; + + var tree = aEvent.target.parentNode; + var tbo = tree.treeBoxObject; + var cell = tbo.getCellAt(aEvent.clientX, aEvent.clientY); + + // cell.row is -1 when the mouse is hovering an empty area within the tree. + // To avoid showing a URL from a previously hovered node for a currently + // hovered non-url node, we must clear the moused-over URL in these cases. + if (cell.row != -1) { + var node = tree.view.nodeForTreeIndex(cell.row); + if (PlacesUtils.nodeIsURI(node)) + this.setMouseoverURL(node.uri); + else + this.setMouseoverURL(""); + } else + this.setMouseoverURL(""); + }, + + setMouseoverURL: function SU_setMouseoverURL(aURL) { + // When the browser window is closed with an open sidebar, the sidebar + // unload event happens after the browser's one. In this case + // top.XULBrowserWindow has been nullified already. + if (top.XULBrowserWindow) { + top.XULBrowserWindow.setOverLink(aURL, null); + } + } +}; diff --git a/comm/suite/components/places/content/tree.xml b/comm/suite/components/places/content/tree.xml new file mode 100644 index 0000000000..f033f193fa --- /dev/null +++ b/comm/suite/components/places/content/tree.xml @@ -0,0 +1,812 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<bindings id="placesTreeBindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="places-tree" extends="chrome://global/content/bindings/tree.xml#tree"> + <implementation> + <constructor><![CDATA[ + // Force an initial build. + if (this.place) + this.place = this.place; + ]]></constructor> + + <destructor><![CDATA[ + // Break the treeviewer->result->treeviewer cycle. + // Note: unsetting the result's viewer also unsets + // the viewer's reference to our treeBoxObject. + var result = this.result; + if (result) { + result.root.containerOpen = false; + } + + // Unregister the controllber before unlinking the view, otherwise it + // may still try to update commands on a view with a null result. + if (this._controller) { + this._controller.terminate(); + this.controllers.removeController(this._controller); + } + + if (this.view) { + this.view.uninit(); + } + this.view = null; + ]]></destructor> + + <property name="controller" + readonly="true" + onget="return this._controller"/> + + <!-- overriding --> + <property name="view"> + <getter><![CDATA[ + try { + return this.treeBoxObject.view.wrappedJSObject || null; + } catch (e) { + return null; + } + ]]></getter> + <setter><![CDATA[ + return this.treeBoxObject.view = val; + ]]></setter> + </property> + + <property name="associatedElement" + readonly="true" + onget="return this"/> + + <method name="applyFilter"> + <parameter name="filterString"/> + <parameter name="folderRestrict"/> + <parameter name="includeHidden"/> + <body><![CDATA[ + // preserve grouping + var queryNode = PlacesUtils.asQuery(this.result.root); + var options = queryNode.queryOptions.clone(); + + // Make sure we're getting uri results. + // We do not yet support searching into grouped queries or into + // tag containers, so we must fall to the default case. + if (PlacesUtils.nodeIsHistoryContainer(queryNode) || + options.resultType == options.RESULTS_AS_TAG_QUERY || + options.resultType == options.RESULTS_AS_TAG_CONTENTS || + options.resultType == options.RESULTS_AS_ROOTS_QUERY) + options.resultType = options.RESULTS_AS_URI; + + var query = PlacesUtils.history.getNewQuery(); + query.searchTerms = filterString; + + if (folderRestrict) { + query.setFolders(folderRestrict, folderRestrict.length); + options.queryType = options.QUERY_TYPE_BOOKMARKS; + } + + options.includeHidden = !!includeHidden; + + this.load([query], options); + ]]></body> + </method> + + <method name="load"> + <parameter name="queries"/> + <parameter name="options"/> + <body><![CDATA[ + let result = PlacesUtils.history + .executeQueries(queries, queries.length, + options); + let callback; + if (this.flatList) { + let onOpenFlatContainer = this.onOpenFlatContainer; + if (onOpenFlatContainer) + callback = new Function("aContainer", onOpenFlatContainer); + } + + if (!this._controller) { + this._controller = new PlacesController(this); + this.controllers.appendController(this._controller); + } + + let treeView = new PlacesTreeView(this.flatList, callback, this._controller); + + // Observer removal is done within the view itself. When the tree + // goes away, treeboxobject calls view.setTree(null), which then + // calls removeObserver. + result.addObserver(treeView); + this.view = treeView; + + if (this.getAttribute("selectfirstnode") == "true" && treeView.rowCount > 0) { + treeView.selection.select(0); + } + + this._cachedInsertionPoint = undefined; + ]]></body> + </method> + + <property name="flatList"> + <getter><![CDATA[ + return this.getAttribute("flatList") == "true"; + ]]></getter> + <setter><![CDATA[ + if (this.flatList != val) { + this.setAttribute("flatList", val); + // reload with the last place set + if (this.place) + this.place = this.place; + } + return val; + ]]></setter> + </property> + + <property name="onOpenFlatContainer"> + <getter><![CDATA[ + return this.getAttribute("onopenflatcontainer"); + ]]></getter> + <setter><![CDATA[ + if (this.onOpenFlatContainer != val) { + this.setAttribute("onopenflatcontainer", val); + // reload with the last place set + if (this.place) + this.place = this.place; + } + return val; + ]]></setter> + </property> + + <!-- + Causes a particular node represented by the specified placeURI to be + selected in the tree. All containers above the node in the hierarchy + will be opened, so that the node is visible. + --> + <method name="selectPlaceURI"> + <parameter name="placeURI"/> + <body><![CDATA[ + // Do nothing if a node matching the given uri is already selected + if (this.hasSelection && this.selectedNode.uri == placeURI) + return; + + function findNode(container, nodesURIChecked) { + var containerURI = container.uri; + if (containerURI == placeURI) + return container; + if (nodesURIChecked.includes(containerURI)) + return null; + + // never check the contents of the same query + nodesURIChecked.push(containerURI); + + var wasOpen = container.containerOpen; + if (!wasOpen) + container.containerOpen = true; + for (var i = 0; i < container.childCount; ++i) { + var child = container.getChild(i); + var childURI = child.uri; + if (childURI == placeURI) + return child; + else if (PlacesUtils.nodeIsContainer(child)) { + var nested = findNode(PlacesUtils.asContainer(child), nodesURIChecked); + if (nested) + return nested; + } + } + + if (!wasOpen) + container.containerOpen = false; + + return null; + } + + var container = this.result.root; + console.assert(container, "No result, cannot select place URI!"); + if (!container) + return; + + var child = findNode(container, []); + if (child) + this.selectNode(child); + else { + // If the specified child could not be located, clear the selection + var selection = this.view.selection; + selection.clearSelection(); + } + ]]></body> + </method> + + <!-- + Causes a particular node to be selected in the tree, resulting in all + containers above the node in the hierarchy to be opened, so that the + node is visible. + --> + <method name="selectNode"> + <parameter name="node"/> + <body><![CDATA[ + var view = this.view; + + var parent = node.parent; + if (parent && !parent.containerOpen) { + // Build a list of all of the nodes that are the parent of this one + // in the result. + var parents = []; + var root = this.result.root; + while (parent && parent != root) { + parents.push(parent); + parent = parent.parent; + } + + // Walk the list backwards (opening from the root of the hierarchy) + // opening each folder as we go. + for (var i = parents.length - 1; i >= 0; --i) { + let index = view.treeIndexForNode(parents[i]); + if (index != -1 && + view.isContainer(index) && !view.isContainerOpen(index)) + view.toggleOpenState(index); + } + // Select the specified node... + } + + let index = view.treeIndexForNode(node); + if (index == -1) + return; + + view.selection.select(index); + // ... and ensure it's visible, not scrolled off somewhere. + this.treeBoxObject.ensureRowIsVisible(index); + ]]></body> + </method> + + <!-- nsIPlacesView --> + <property name="result"> + <getter><![CDATA[ + try { + return this.view.QueryInterface(Ci.nsINavHistoryResultObserver).result; + } catch (e) { + return null; + } + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="place"> + <getter><![CDATA[ + return this.getAttribute("place"); + ]]></getter> + <setter><![CDATA[ + this.setAttribute("place", val); + + var queriesRef = { }; + var queryCountRef = { }; + var optionsRef = { }; + PlacesUtils.history.queryStringToQueries(val, queriesRef, queryCountRef, optionsRef); + if (queryCountRef.value == 0) + queriesRef.value = [PlacesUtils.history.getNewQuery()]; + if (!optionsRef.value) + optionsRef.value = PlacesUtils.history.getNewQueryOptions(); + + this.load(queriesRef.value, optionsRef.value); + + return val; + ]]></setter> + </property> + + <!-- nsIPlacesView --> + <property name="hasSelection"> + <getter><![CDATA[ + return this.view && this.view.selection.count >= 1; + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="selectedNodes"> + <getter><![CDATA[ + let nodes = []; + if (!this.hasSelection) + return nodes; + + let selection = this.view.selection; + let rc = selection.getRangeCount(); + let resultview = this.view; + for (let i = 0; i < rc; ++i) { + let min = { }, max = { }; + selection.getRangeAt(i, min, max); + for (let j = min.value; j <= max.value; ++j) { + nodes.push(resultview.nodeForTreeIndex(j)); + } + } + return nodes; + ]]></getter> + </property> + + <method name="toggleCutNode"> + <parameter name="aNode"/> + <parameter name="aValue"/> + <body><![CDATA[ + this.view.toggleCutNode(aNode, aValue); + ]]></body> + </method> + + <!-- nsIPlacesView --> + <property name="removableSelectionRanges"> + <getter><![CDATA[ + // This property exists in addition to selectedNodes because it + // encodes selection ranges (which only occur in list views) into + // the return value. For each removed range, the index at which items + // will be re-inserted upon the remove transaction being performed is + // the first index of the range, so that the view updates correctly. + // + // For example, if we remove rows 2,3,4 and 7,8 from a list, when we + // undo that operation, if we insert what was at row 3 at row 3 again, + // it will show up _after_ the item that was at row 5. So we need to + // insert all items at row 2, and the tree view will update correctly. + // + // Also, this function collapses the selection to remove redundant + // data, e.g. when deleting this selection: + // + // http://www.foo.com/ + // (-) Some Folder + // http://www.bar.com/ + // + // ... returning http://www.bar.com/ as part of the selection is + // redundant because it is implied by removing "Some Folder". We + // filter out all such redundancies since some partial amount of + // the folder's children may be selected. + // + let nodes = []; + if (!this.hasSelection) + return nodes; + + var selection = this.view.selection; + var rc = selection.getRangeCount(); + var resultview = this.view; + // This list is kept independently of the range selected (i.e. OUTSIDE + // the for loop) since the row index of a container is unique for the + // entire view, and we could have some really wacky selection and we + // don't want to blow up. + var containers = { }; + for (var i = 0; i < rc; ++i) { + var range = []; + var min = { }, max = { }; + selection.getRangeAt(i, min, max); + + for (var j = min.value; j <= max.value; ++j) { + if (this.view.isContainer(j)) + containers[j] = true; + if (!(this.view.getParentIndex(j) in containers)) + range.push(resultview.nodeForTreeIndex(j)); + } + nodes.push(range); + } + return nodes; + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="draggableSelection" + onget="return this.selectedNodes"/> + + <!-- nsIPlacesView --> + <property name="selectedNode"> + <getter><![CDATA[ + var view = this.view; + if (!view || view.selection.count != 1) + return null; + + var selection = view.selection; + var min = { }, max = { }; + selection.getRangeAt(0, min, max); + + return this.view.nodeForTreeIndex(min.value); + ]]></getter> + </property> + + <!-- nsIPlacesView --> + <property name="insertionPoint"> + <getter><![CDATA[ + // invalidated on selection and focus changes + if (this._cachedInsertionPoint !== undefined) + return this._cachedInsertionPoint; + + // there is no insertion point for history queries + // so bail out now and save a lot of work when updating commands + var resultNode = this.result.root; + if (PlacesUtils.nodeIsQuery(resultNode) && + PlacesUtils.asQuery(resultNode).queryOptions.queryType == + Ci.nsINavHistoryQueryOptions.QUERY_TYPE_HISTORY) + return this._cachedInsertionPoint = null; + + var orientation = Ci.nsITreeView.DROP_BEFORE; + // If there is no selection, insert at the end of the container. + if (!this.hasSelection) { + var index = this.view.rowCount - 1; + this._cachedInsertionPoint = + this._getInsertionPoint(index, orientation); + return this._cachedInsertionPoint; + } + + // This is a two-part process. The first part is determining the drop + // orientation. + // * The default orientation is to drop _before_ the selected item. + // * If the selected item is a container, the default orientation + // is to drop _into_ that container. + // + // Warning: It may be tempting to use tree indexes in this code, but + // you must not, since the tree is nested and as your tree + // index may change when folders before you are opened and + // closed. You must convert your tree index to a node, and + // then use getChildIndex to find your absolute index in + // the parent container instead. + // + var resultView = this.view; + var selection = resultView.selection; + var rc = selection.getRangeCount(); + var min = { }, max = { }; + selection.getRangeAt(rc - 1, min, max); + + // If the sole selection is a container, and we are not in + // a flatlist, insert into it. + // Note that this only applies to _single_ selections, + // if the last element within a multi-selection is a + // container, insert _adjacent_ to the selection. + // + // If the sole selection is the bookmarks toolbar folder, we insert + // into it even if it is not opened + if (selection.count == 1 && resultView.isContainer(max.value) && + !this.flatList) + orientation = Ci.nsITreeView.DROP_ON; + + this._cachedInsertionPoint = + this._getInsertionPoint(max.value, orientation); + return this._cachedInsertionPoint; + ]]></getter> + </property> + + <method name="_getInsertionPoint"> + <parameter name="index"/> + <parameter name="orientation"/> + <body><![CDATA[ + var result = this.result; + var resultview = this.view; + var container = result.root; + var dropNearNode = null; + console.assert(container, "null container"); + // When there's no selection, assume the container is the container + // the view is populated from (i.e. the result's itemId). + if (index != -1) { + var lastSelected = resultview.nodeForTreeIndex(index); + if (resultview.isContainer(index) && orientation == Ci.nsITreeView.DROP_ON) { + // If the last selected item is an open container, append _into_ + // it, rather than insert adjacent to it. + container = lastSelected; + index = -1; + } else if (lastSelected.containerOpen && + orientation == Ci.nsITreeView.DROP_AFTER && + lastSelected.hasChildren) { + // If the last selected item is an open container and the user is + // trying to drag into it as a first item, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // See comment in the treeView.js's copy of this method + if (!container || !container.containerOpen) + return null; + + // Avoid the potentially expensive call to getChildIndex + // if we know this container doesn't allow insertion + if (this.controller.disallowInsertion(container)) + return null; + + var queryOptions = PlacesUtils.asQuery(result.root).queryOptions; + if (queryOptions.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // If we are within a sorted view, insert at the end + index = -1; + } else if (queryOptions.excludeItems || + queryOptions.excludeQueries || + queryOptions.excludeReadOnlyFolders) { + // Some item may be invisible, insert near last selected one. + // We don't replace index here to avoid requests to the db, + // instead it will be calculated later by the controller. + index = -1; + dropNearNode = lastSelected; + } else { + var lsi = container.getChildIndex(lastSelected); + index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1; + } + } + } + + if (this.controller.disallowInsertion(container)) + return null; + + // TODO (Bug 1160193): properly support dropping on a tag root. + let tagName = null; + if (PlacesUtils.nodeIsTagQuery(container)) { + tagName = container.title; + if (!tagName) + return null; + } + + return new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(container), + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, orientation, tagName, dropNearNode + }); + ]]></body> + </method> + + <!-- nsIPlacesView --> + <method name="selectAll"> + <body><![CDATA[ + this.view.selection.selectAll(); + ]]></body> + </method> + + <!-- This method will select the first node in the tree that matches + each given item guid. It will open any folder nodes that it needs + to in order to show the selected items. + Note: An array of ids or guids (or a mixture) may be passed as aIDs. + Passing IDs should be considered deprecated. + --> + <method name="selectItems"> + <parameter name="aIDs"/> + <parameter name="aOpenContainers"/> + <body><![CDATA[ + // Never open containers in flat lists. + if (this.flatList) + aOpenContainers = false; + // By default, we do search and select within containers which were + // closed (note that containers in which nodes were not found are + // closed). + if (aOpenContainers === undefined) + aOpenContainers = true; + + var ids = aIDs; // don't manipulate the caller's array + + // Array of nodes found by findNodes which are to be selected + var nodes = []; + + // Array of nodes found by findNodes which should be opened + var nodesToOpen = []; + + // A set of GUIDs of container-nodes that were previously searched, + // and thus shouldn't be searched again. This is empty at the initial + // start of the recursion and gets filled in as the recursion + // progresses. + var checkedGuidsSet = new Set(); + + /** + * Recursively search through a node's children for items + * with the given IDs. When a matching item is found, remove its ID + * from the IDs array, and add the found node to the nodes dictionary. + * + * NOTE: This method will leave open any node that had matching items + * in its subtree. + */ + function findNodes(node) { + var foundOne = false; + // See if node matches an ID we wanted; add to results. + // For simple folder queries, check both itemId and the concrete + // item id. + var index = ids.indexOf(node.itemId); + if (index == -1 && + node.type == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) + index = ids.indexOf(PlacesUtils.asQuery(node).folderItemId); + + if (index == -1) { + index = ids.indexOf(node.bookmarkGuid); + if (index == -1) { + let concreteGuid = PlacesUtils.getConcreteItemGuid(node); + if (concreteGuid != node.bookmarkGuid) { + index = ids.indexOf(concreteGuid); + } + } + } + + if (index != -1) { + nodes.push(node); + foundOne = true; + ids.splice(index, 1); + } + + var concreteGuid = PlacesUtils.getConcreteItemGuid(node); + if (ids.length == 0 || !PlacesUtils.nodeIsContainer(node) || + checkedGuidsSet.has(concreteGuid)) + return foundOne; + + // Only follow a query if it has been been explicitly opened by the + // caller. We support the "AllBookmarks" case to allow callers to + // specify just the top-level bookmark folders. + let shouldOpen = aOpenContainers && (PlacesUtils.nodeIsFolder(node) || + (PlacesUtils.nodeIsQuery(node) && node.itemId == PlacesUIUtils.leftPaneQueries.AllBookmarks)); + + PlacesUtils.asContainer(node); + if (!node.containerOpen && !shouldOpen) + return foundOne; + + checkedGuidsSet.add(concreteGuid); + + // Remember the beginning state so that we can re-close + // this node if we don't find any additional results here. + var previousOpenness = node.containerOpen; + node.containerOpen = true; + for (var child = 0; child < node.childCount && ids.length > 0; child++) { + var childNode = node.getChild(child); + var found = findNodes(childNode); + if (!foundOne) + foundOne = found; + } + + // If we didn't find any additional matches in this node's + // subtree, revert the node to its previous openness. + if (foundOne) + nodesToOpen.unshift(node); + node.containerOpen = previousOpenness; + return foundOne; + } + + // Disable notifications while looking for nodes. + let result = this.result; + let didSuppressNotifications = result.suppressNotifications; + if (!didSuppressNotifications) + result.suppressNotifications = true; + try { + findNodes(this.result.root); + } finally { + if (!didSuppressNotifications) + result.suppressNotifications = false; + } + + // For all the nodes we've found, highlight the corresponding + // index in the tree. + var resultview = this.view; + var selection = this.view.selection; + selection.selectEventsSuppressed = true; + selection.clearSelection(); + // Open nodes containing found items + for (let i = 0; i < nodesToOpen.length; i++) { + nodesToOpen[i].containerOpen = true; + } + for (let i = 0; i < nodes.length; i++) { + var index = resultview.treeIndexForNode(nodes[i]); + if (index == -1) + continue; + selection.rangedSelect(index, index, true); + } + selection.selectEventsSuppressed = false; + ]]></body> + </method> + + <field name="_contextMenuShown">false</field> + + <method name="buildContextMenu"> + <parameter name="aPopup"/> + <body><![CDATA[ + this._contextMenuShown = true; + return this.controller.buildContextMenu(aPopup); + ]]></body> + </method> + + <method name="destroyContextMenu"> + <parameter name="aPopup"/> + this._contextMenuShown = false; + <body/> + </method> + + <property name="ownerWindow" + readonly="true" + onget="return window;"/> + + <field name="_active">true</field> + <property name="active" + onget="return this._active" + onset="return this._active = val"/> + + </implementation> + <handlers> + <handler event="focus"><![CDATA[ + this._cachedInsertionPoint = undefined; + + // See select handler. We need the sidebar's places commandset to be + // updated as well + document.commandDispatcher.updateCommands("focus"); + ]]></handler> + <handler event="select"><![CDATA[ + this._cachedInsertionPoint = undefined; + + // This additional complexity is here for the sidebars + var win = window; + while (true) { + win.document.commandDispatcher.updateCommands("focus"); + if (win == window.top) + break; + + win = win.parent; + } + ]]></handler> + + <handler event="dragstart"><![CDATA[ + if (event.target.localName != "treechildren") + return; + + let nodes = this.selectedNodes; + for (let i = 0; i < nodes.length; i++) { + let node = nodes[i]; + + // Disallow dragging the root node of a tree. + if (!node.parent) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + // If this node is child of a readonly container (e.g. a livemark) + // or cannot be moved, we must force a copy. + if (!this.controller.canMoveNode(node)) { + event.dataTransfer.effectAllowed = "copyLink"; + break; + } + } + + this._controller.setDataTransfer(event); + event.stopPropagation(); + ]]></handler> + + <handler event="dragover"><![CDATA[ + if (event.target.localName != "treechildren") + return; + + let cell = this.treeBoxObject.getCellAt(event.clientX, event.clientY); + let node = cell.row != -1 ? + this.view.nodeForTreeIndex(cell.row) : + this.result.root; + // cache the dropTarget for the view + PlacesControllerDragHelper.currentDropTarget = node; + + // We have to calculate the orientation since view.canDrop will use + // it and we want to be consistent with the dropfeedback. + let tbo = this.treeBoxObject; + let rowHeight = tbo.rowHeight; + let eventY = event.clientY - tbo.treeBody.boxObject.y - + rowHeight * (cell.row - tbo.getFirstVisibleRow()); + + let orientation = Ci.nsITreeView.DROP_BEFORE; + + if (cell.row == -1) { + // If the row is not valid we try to insert inside the resultNode. + orientation = Ci.nsITreeView.DROP_ON; + } else if (PlacesUtils.nodeIsContainer(node) && + eventY > rowHeight * 0.75) { + // If we are below the 75% of a container the treeview we try + // to drop after the node. + orientation = Ci.nsITreeView.DROP_AFTER; + } else if (PlacesUtils.nodeIsContainer(node) && + eventY > rowHeight * 0.25) { + // If we are below the 25% of a container the treeview we try + // to drop inside the node. + orientation = Ci.nsITreeView.DROP_ON; + } + + if (!this.view.canDrop(cell.row, orientation, event.dataTransfer)) + return; + + event.preventDefault(); + event.stopPropagation(); + ]]></handler> + + <handler event="dragend"><![CDATA[ + PlacesControllerDragHelper.currentDropTarget = null; + ]]></handler> + + </handlers> + </binding> + +</bindings> diff --git a/comm/suite/components/places/content/treeView.js b/comm/suite/components/places/content/treeView.js new file mode 100644 index 0000000000..209af6549a --- /dev/null +++ b/comm/suite/components/places/content/treeView.js @@ -0,0 +1,1823 @@ +/* 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 {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +const PTV_interfaces = [Ci.nsITreeView, + Ci.nsINavHistoryResultObserver, + Ci.nsISupportsWeakReference]; + +/** + * This returns the key for any node/details object. + * + * @param nodeOrDetails + * A node, or an object containing the following properties: + * - uri + * - time + * - itemId + * In case any of these is missing, an empty string will be returned. This is + * to facilitate easy delete statements which occur due to assignment to items in `this._rows`, + * since the item we are deleting may be undefined in the array. + * + * @return key or empty string. + */ +function makeNodeDetailsKey(nodeOrDetails) { + if (nodeOrDetails && + typeof nodeOrDetails === "object" && + "uri" in nodeOrDetails && + "time" in nodeOrDetails && + "itemId" in nodeOrDetails) { + return `${nodeOrDetails.uri}*${nodeOrDetails.time}*${nodeOrDetails.itemId}`; + } + return ""; +} + +function PlacesTreeView(aFlatList, aOnOpenFlatContainer, aController) { + this._tree = null; + this._result = null; + this._selection = null; + this._rootNode = null; + this._rows = []; + this._flatList = aFlatList; + this._nodeDetails = new Map(); + this._openContainerCallback = aOnOpenFlatContainer; + this._controller = aController; +} + +PlacesTreeView.prototype = { + get wrappedJSObject() { + return this; + }, + + __xulStore: null, + get _xulStore() { + if (!this.__xulStore) { + this.__xulStore = Cc["@mozilla.org/xul/xulstore;1"].getService(Ci.nsIXULStore); + } + return this.__xulStore; + }, + + QueryInterface: XPCOMUtils.generateQI(PTV_interfaces), + + // Bug 761494: + // ---------- + // Some addons use methods from nsINavHistoryResultObserver and + // nsINavHistoryResultTreeViewer, without QIing to these interfaces first. + // That's not a problem when the view is retrieved through the + // <tree>.view getter (which returns the wrappedJSObject of this object), + // it raises an issue when the view retrieved through the treeBoxObject.view + // getter. Thus, to avoid breaking addons, the interfaces are prefetched. + classInfo: XPCOMUtils.generateCI({ interfaces: PTV_interfaces }), + + /** + * This is called once both the result and the tree are set. + */ + _finishInit: function PTV__finishInit() { + let selection = this.selection; + if (selection) + selection.selectEventsSuppressed = true; + + if (!this._rootNode.containerOpen) { + // This triggers containerStateChanged which then builds the visible + // section. + this._rootNode.containerOpen = true; + } else + this.invalidateContainer(this._rootNode); + + // "Activate" the sorting column and update commands. + this.sortingChanged(this._result.sortingMode); + + if (selection) + selection.selectEventsSuppressed = false; + }, + + uninit() { + if (this._editingObservers) { + for (let observer of this._editingObservers.values()) { + observer.disconnect(); + } + delete this._editingObservers; + } + }, + + /** + * Plain Container: container result nodes which may never include sub + * hierarchies. + * + * When the rows array is constructed, we don't set the children of plain + * containers. Instead, we keep placeholders for these children. We then + * build these children lazily as the tree asks us for information about each + * row. Luckily, the tree doesn't ask about rows outside the visible area. + * + * @see _getNodeForRow and _getRowForNode for the actual magic. + * + * @note It's guaranteed that all containers are listed in the rows + * elements array. It's also guaranteed that separators (if they're not + * filtered, see below) are listed in the visible elements array, because + * bookmark folders are never built lazily, as described above. + * + * @param aContainer + * A container result node. + * + * @return true if aContainer is a plain container, false otherwise. + */ + _isPlainContainer: function PTV__isPlainContainer(aContainer) { + // Livemarks are always plain containers. + if (this._controller.hasCachedLivemarkInfo(aContainer)) + return true; + + // We don't know enough about non-query containers. + if (!(aContainer instanceof Ci.nsINavHistoryQueryResultNode)) + return false; + + switch (aContainer.queryOptions.resultType) { + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_DATE_SITE_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_QUERY: + case Ci.nsINavHistoryQueryOptions.RESULTS_AS_ROOTS_QUERY: + return false; + } + + // If it's a folder, it's not a plain container. + let nodeType = aContainer.type; + return nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER && + nodeType != Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + }, + + /** + * Gets the row number for a given node. Assumes that the given node is + * visible (i.e. it's not an obsolete node). + * + * @param aNode + * A result node. Do not pass an obsolete node, or any + * node which isn't supposed to be in the tree (e.g. separators in + * sorted trees). + * @param [optional] aForceBuild + * @see _isPlainContainer. + * If true, the row will be computed even if the node still isn't set + * in our rows array. + * @param [optional] aParentRow + * The row of aNode's parent. Ignored for the root node. + * @param [optional] aNodeIndex + * The index of aNode in its parent. Only used if aParentRow is + * set too. + * + * @throws if aNode is invisible. + * @note If aParentRow and aNodeIndex are passed and parent is a plain + * container, this method will just return a calculated row value, without + * making assumptions on existence of the node at that position. + * @return aNode's row if it's in the rows list or if aForceBuild is set, -1 + * otherwise. + */ + _getRowForNode: + function PTV__getRowForNode(aNode, aForceBuild, aParentRow, aNodeIndex) { + if (aNode == this._rootNode) + throw new Error("The root node is never visible"); + + // A node is removed form the view either if it has no parent or if its + // root-ancestor is not the root node (in which case that's the node + // for which nodeRemoved was called). + let ancestors = Array.from(PlacesUtils.nodeAncestors(aNode)); + if (ancestors.length == 0 || + ancestors[ancestors.length - 1] != this._rootNode) { + throw new Error("Removed node passed to _getRowForNode"); + } + + // Ensure that the entire chain is open, otherwise that node is invisible. + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) + throw new Error("Invisible node passed to _getRowForNode"); + } + + // Non-plain containers are initially built with their contents. + let parent = aNode.parent; + let parentIsPlain = this._isPlainContainer(parent); + if (!parentIsPlain) { + if (parent == this._rootNode) { + return this._rows.indexOf(aNode); + } + + return this._rows.indexOf(aNode, aParentRow); + } + + let row = -1; + let useNodeIndex = typeof(aNodeIndex) == "number"; + if (parent == this._rootNode) { + row = useNodeIndex ? aNodeIndex : this._rootNode.getChildIndex(aNode); + } else if (useNodeIndex && typeof(aParentRow) == "number") { + // If we have both the row of the parent node, and the node's index, we + // can avoid searching the rows array if the parent is a plain container. + row = aParentRow + aNodeIndex + 1; + } else { + // Look for the node in the nodes array. Start the search at the parent + // row. If the parent row isn't passed, we'll pass undefined to indexOf, + // which is fine. + row = this._rows.indexOf(aNode, aParentRow); + if (row == -1 && aForceBuild) { + let parentRow = typeof(aParentRow) == "number" ? aParentRow + : this._getRowForNode(parent); + row = parentRow + parent.getChildIndex(aNode) + 1; + } + } + + if (row != -1) { + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._rows[row] = aNode; + } + + return row; + }, + + /** + * Given a row, finds and returns the parent details of the associated node. + * + * @param aChildRow + * Row number. + * @return [parentNode, parentRow] + */ + _getParentByChildRow: function PTV__getParentByChildRow(aChildRow) { + let node = this._getNodeForRow(aChildRow); + let parent = (node === null) ? this._rootNode : node.parent; + + // The root node is never visible + if (parent == this._rootNode) + return [this._rootNode, -1]; + + let parentRow = this._rows.lastIndexOf(parent, aChildRow - 1); + return [parent, parentRow]; + }, + + /** + * Gets the node at a given row. + */ + _getNodeForRow: function PTV__getNodeForRow(aRow) { + if (aRow < 0) { + return null; + } + + let node = this._rows[aRow]; + if (node !== undefined) + return node; + + // Find the nearest node. + let rowNode, row; + for (let i = aRow - 1; i >= 0 && rowNode === undefined; i--) { + rowNode = this._rows[i]; + row = i; + } + + // If there's no container prior to the given row, it's a child of + // the root node (remember: all containers are listed in the rows array). + if (!rowNode) { + let newNode = this._rootNode.getChild(aRow); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return this._rows[aRow] = newNode; + } + + // Unset elements may exist only in plain containers. Thus, if the nearest + // node is a container, it's the row's parent, otherwise, it's a sibling. + if (rowNode instanceof Ci.nsINavHistoryContainerResultNode) { + let newNode = rowNode.getChild(aRow - row - 1); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return this._rows[aRow] = newNode; + } + + let [parent, parentRow] = this._getParentByChildRow(row); + let newNode = parent.getChild(aRow - parentRow - 1); + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[aRow])); + this._nodeDetails.set(makeNodeDetailsKey(newNode), newNode); + return this._rows[aRow] = newNode; + }, + + /** + * This takes a container and recursively appends our rows array per its + * contents. Assumes that the rows arrays has no rows for the given + * container. + * + * @param [in] aContainer + * A container result node. + * @param [in] aFirstChildRow + * The first row at which nodes may be inserted to the row array. + * In other words, that's aContainer's row + 1. + * @param [out] aToOpen + * An array of containers to open once the build is done. + * + * @return the number of rows which were inserted. + */ + _buildVisibleSection: + function PTV__buildVisibleSection(aContainer, aFirstChildRow, aToOpen) { + // There's nothing to do if the container is closed. + if (!aContainer.containerOpen) + return 0; + + // Inserting the new elements into the rows array in one shot (by + // Array.prototype.concat) is faster than resizing the array (by splice) on each loop + // iteration. + let cc = aContainer.childCount; + let newElements = new Array(cc); + // We need to clean up the node details from aFirstChildRow + 1 to the end of rows. + for (let i = aFirstChildRow + 1; i < this._rows.length; i++) { + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[i])); + } + this._rows = this._rows.splice(0, aFirstChildRow) + .concat(newElements, this._rows); + + if (this._isPlainContainer(aContainer)) + return cc; + + let sortingMode = this._result.sortingMode; + + let rowsInserted = 0; + for (let i = 0; i < cc; i++) { + let curChild = aContainer.getChild(i); + let curChildType = curChild.type; + + let row = aFirstChildRow + rowsInserted; + + // Don't display separators when sorted. + if (curChildType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) { + if (sortingMode != Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // Remove the element for the filtered separator. + // Notice that the rows array was initially resized to include all + // children. + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._rows.splice(row, 1); + continue; + } + } + + this._nodeDetails.delete(makeNodeDetailsKey(this._rows[row])); + this._nodeDetails.set(makeNodeDetailsKey(curChild), curChild); + this._rows[row] = curChild; + rowsInserted++; + + // Recursively do containers. + if (!this._flatList && + curChild instanceof Ci.nsINavHistoryContainerResultNode && + !this._controller.hasCachedLivemarkInfo(curChild)) { + let uri = curChild.uri; + let isopen = false; + + if (uri) { + let val = this._xulStore.getValue(document.documentURI, uri, "open"); + isopen = (val == "true"); + } + + if (isopen != curChild.containerOpen) + aToOpen.push(curChild); + else if (curChild.containerOpen && curChild.childCount > 0) + rowsInserted += this._buildVisibleSection(curChild, row + 1, aToOpen); + } + } + + return rowsInserted; + }, + + /** + * This counts how many rows a node takes in the tree. For containers it + * will count the node itself plus any child node following it. + */ + _countVisibleRowsForNodeAtRow: + function PTV__countVisibleRowsForNodeAtRow(aNodeRow) { + let node = this._rows[aNodeRow]; + + // If it's not listed yet, we know that it's a leaf node (instanceof also + // null-checks). + if (!(node instanceof Ci.nsINavHistoryContainerResultNode)) + return 1; + + let outerLevel = node.indentLevel; + for (let i = aNodeRow + 1; i < this._rows.length; i++) { + let rowNode = this._rows[i]; + if (rowNode && rowNode.indentLevel <= outerLevel) + return i - aNodeRow; + } + + // This node plus its children take up the bottom of the list. + return this._rows.length - aNodeRow; + }, + + _getSelectedNodesInRange: + function PTV__getSelectedNodesInRange(aFirstRow, aLastRow) { + let selection = this.selection; + let rc = selection.getRangeCount(); + if (rc == 0) + return []; + + // The visible-area borders are needed for checking whether a + // selected row is also visible. + let firstVisibleRow = this._tree.getFirstVisibleRow(); + let lastVisibleRow = this._tree.getLastVisibleRow(); + + let nodesInfo = []; + for (let rangeIndex = 0; rangeIndex < rc; rangeIndex++) { + let min = { }, max = { }; + selection.getRangeAt(rangeIndex, min, max); + + // If this range does not overlap the replaced chunk, we don't need to + // persist the selection. + if (max.value < aFirstRow || min.value > aLastRow) + continue; + + let firstRow = Math.max(min.value, aFirstRow); + let lastRow = Math.min(max.value, aLastRow); + for (let i = firstRow; i <= lastRow; i++) { + nodesInfo.push({ + node: this._rows[i], + oldRow: i, + wasVisible: i >= firstVisibleRow && i <= lastVisibleRow + }); + } + } + + return nodesInfo; + }, + + /** + * Tries to find an equivalent node for a node which was removed. We first + * look for the original node, in case it was just relocated. Then, if we + * that node was not found, we look for a node that has the same itemId, uri + * and time values. + * + * @param aUpdatedContainer + * An ancestor of the node which was removed. It does not have to be + * its direct parent. + * @param aOldNode + * The node which was removed. + * + * @return the row number of an equivalent node for aOldOne, if one was + * found, -1 otherwise. + */ + _getNewRowForRemovedNode: + function PTV__getNewRowForRemovedNode(aUpdatedContainer, aOldNode) { + let parent = aOldNode.parent; + if (parent) { + // If the node's parent is still set, the node is not obsolete + // and we should just find out its new position. + // However, if any of the node's ancestor is closed, the node is + // invisible. + let ancestors = PlacesUtils.nodeAncestors(aOldNode); + for (let ancestor of ancestors) { + if (!ancestor.containerOpen) { + return -1; + } + } + + return this._getRowForNode(aOldNode, true); + } + + // There's a broken edge case here. + // If a visit appears in two queries, and the second one was + // the old node, we'll select the first one after refresh. There's + // nothing we could do about that, because aOldNode.parent is + // gone by the time invalidateContainer is called. + let newNode = this._nodeDetails.get(makeNodeDetailsKey(aOldNode)); + + if (!newNode) + return -1; + + return this._getRowForNode(newNode, true); + }, + + /** + * Restores a given selection state as near as possible to the original + * selection state. + * + * @param aNodesInfo + * The persisted selection state as returned by + * _getSelectedNodesInRange. + * @param aUpdatedContainer + * The container which was updated. + */ + _restoreSelection: + function PTV__restoreSelection(aNodesInfo, aUpdatedContainer) { + if (aNodesInfo.length == 0) + return; + + let selection = this.selection; + + // Attempt to ensure that previously-visible selection will be visible + // if it's re-selected. However, we can only ensure that for one row. + let scrollToRow = -1; + for (let i = 0; i < aNodesInfo.length; i++) { + let nodeInfo = aNodesInfo[i]; + let row = this._getNewRowForRemovedNode(aUpdatedContainer, + nodeInfo.node); + // Select the found node, if any. + if (row != -1) { + selection.rangedSelect(row, row, true); + if (nodeInfo.wasVisible && scrollToRow == -1) + scrollToRow = row; + } + } + + // If only one node was previously selected and there's no selection now, + // select the node at its old row, if any. + if (aNodesInfo.length == 1 && selection.count == 0) { + let row = Math.min(aNodesInfo[0].oldRow, this._rows.length - 1); + if (row != -1) { + selection.rangedSelect(row, row, true); + if (aNodesInfo[0].wasVisible && scrollToRow == -1) + scrollToRow = aNodesInfo[0].oldRow; + } + } + + if (scrollToRow != -1) + this._tree.ensureRowIsVisible(scrollToRow); + }, + + _convertPRTimeToString: function PTV__convertPRTimeToString(aTime) { + const MS_PER_MINUTE = 60000; + const MS_PER_DAY = 86400000; + let timeMs = aTime / 1000; // PRTime is in microseconds + + // Date is calculated starting from midnight, so the modulo with a day are + // milliseconds from today's midnight. + // getTimezoneOffset corrects that based on local time, notice midnight + // can have a different offset during DST-change days. + let dateObj = new Date(); + let now = dateObj.getTime() - dateObj.getTimezoneOffset() * MS_PER_MINUTE; + let midnight = now - (now % MS_PER_DAY); + midnight += new Date(midnight).getTimezoneOffset() * MS_PER_MINUTE; + + let timeObj = new Date(timeMs); + return timeMs >= midnight ? this._todayFormatter.format(timeObj) + : this._dateFormatter.format(timeObj); + }, + + // We use a different formatter for times within the current day, + // so we cache both a "today" formatter and a general date formatter. + __todayFormatter: null, + get _todayFormatter() { + if (!this.__todayFormatter) { + const dtOptions = { timeStyle: "short" }; + this.__todayFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); + } + return this.__todayFormatter; + }, + + __dateFormatter: null, + get _dateFormatter() { + if (!this.__dateFormatter) { + const dtOptions = { + dateStyle: "short", + timeStyle: "short" + }; + this.__dateFormatter = new Services.intl.DateTimeFormat(undefined, dtOptions); + } + return this.__dateFormatter; + }, + + COLUMN_TYPE_UNKNOWN: 0, + COLUMN_TYPE_TITLE: 1, + COLUMN_TYPE_URI: 2, + COLUMN_TYPE_DATE: 3, + COLUMN_TYPE_VISITCOUNT: 4, + COLUMN_TYPE_DESCRIPTION: 5, + COLUMN_TYPE_DATEADDED: 6, + COLUMN_TYPE_LASTMODIFIED: 7, + COLUMN_TYPE_TAGS: 8, + + _getColumnType: function PTV__getColumnType(aColumn) { + let columnType = aColumn.element.getAttribute("anonid") || aColumn.id; + + switch (columnType) { + case "title": + return this.COLUMN_TYPE_TITLE; + case "url": + return this.COLUMN_TYPE_URI; + case "date": + return this.COLUMN_TYPE_DATE; + case "visitCount": + return this.COLUMN_TYPE_VISITCOUNT; + case "description": + return this.COLUMN_TYPE_DESCRIPTION; + case "dateAdded": + return this.COLUMN_TYPE_DATEADDED; + case "lastModified": + return this.COLUMN_TYPE_LASTMODIFIED; + case "tags": + return this.COLUMN_TYPE_TAGS; + } + return this.COLUMN_TYPE_UNKNOWN; + }, + + _sortTypeToColumnType: function PTV__sortTypeToColumnType(aSortType) { + switch (aSortType) { + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_ASCENDING: + return [this.COLUMN_TYPE_TITLE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TITLE_DESCENDING: + return [this.COLUMN_TYPE_TITLE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_ASCENDING: + return [this.COLUMN_TYPE_DATE, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING: + return [this.COLUMN_TYPE_DATE, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_ASCENDING: + return [this.COLUMN_TYPE_URI, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_URI_DESCENDING: + return [this.COLUMN_TYPE_URI, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_ASCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_VISITCOUNT_DESCENDING: + return [this.COLUMN_TYPE_VISITCOUNT, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING: + if (this._result.sortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + return [this.COLUMN_TYPE_DESCRIPTION, false]; + break; + case Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING: + if (this._result.sortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + return [this.COLUMN_TYPE_DESCRIPTION, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_ASCENDING: + return [this.COLUMN_TYPE_DATEADDED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING: + return [this.COLUMN_TYPE_DATEADDED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_ASCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_LASTMODIFIED_DESCENDING: + return [this.COLUMN_TYPE_LASTMODIFIED, true]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_ASCENDING: + return [this.COLUMN_TYPE_TAGS, false]; + case Ci.nsINavHistoryQueryOptions.SORT_BY_TAGS_DESCENDING: + return [this.COLUMN_TYPE_TAGS, true]; + } + return [this.COLUMN_TYPE_UNKNOWN, false]; + }, + + // nsINavHistoryResultObserver + nodeInserted: function PTV_nodeInserted(aParentNode, aNode, aNewIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) + return; + + let parentRow; + if (aParentNode != this._rootNode) { + parentRow = this._getRowForNode(aParentNode); + + // Update parent when inserting the first item, since twisty has changed. + if (aParentNode.childCount == 1) + this._tree.invalidateRow(parentRow); + } + + // Compute the new row number of the node. + let row = -1; + let cc = aParentNode.childCount; + if (aNewIndex == 0 || this._isPlainContainer(aParentNode) || cc == 0) { + // We don't need to worry about sub hierarchies of the parent node + // if it's a plain container, or if the new node is its first child. + if (aParentNode == this._rootNode) + row = aNewIndex; + else + row = parentRow + aNewIndex + 1; + } else { + // Here, we try to find the next visible element in the child list so we + // can set the new visible index to be right before that. Note that we + // have to search down instead of up, because some siblings could have + // children themselves that would be in the way. + let separatorsAreHidden = PlacesUtils.nodeIsSeparator(aNode) && + this.isSorted(); + for (let i = aNewIndex + 1; i < cc; i++) { + let node = aParentNode.getChild(i); + if (!separatorsAreHidden || PlacesUtils.nodeIsSeparator(node)) { + // The children have not been shifted so the next item will have what + // should be our index. + row = this._getRowForNode(node, false, parentRow, i); + break; + } + } + if (row < 0) { + // At the end of the child list without finding a visible sibling. This + // is a little harder because we don't know how many rows the last item + // in our list takes up (it could be a container with many children). + let prevChild = aParentNode.getChild(aNewIndex - 1); + let prevIndex = this._getRowForNode(prevChild, false, parentRow, + aNewIndex - 1); + row = prevIndex + this._countVisibleRowsForNodeAtRow(prevIndex); + } + } + + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._rows.splice(row, 0, aNode); + this._tree.rowCountChanged(row, 1); + + if (PlacesUtils.nodeIsContainer(aNode) && + PlacesUtils.asContainer(aNode).containerOpen) { + this.invalidateContainer(aNode); + } + }, + + /** + * THIS FUNCTION DOES NOT HANDLE cases where a collapsed node is being + * removed but the node it is collapsed with is not being removed (this then + * just swap out the removee with its collapsing partner). The only time + * when we really remove things is when deleting URIs, which will apply to + * all collapsees. This function is called sometimes when resorting items. + * However, we won't do this when sorted by date because dates will never + * change for visits, and date sorting is the only time things are collapsed. + */ + nodeRemoved: function PTV_nodeRemoved(aParentNode, aNode, aOldIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // XXX bug 517701: We don't know what to do when the root node is removed. + if (aNode == this._rootNode) + throw Cr.NS_ERROR_NOT_IMPLEMENTED; + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) + return; + + let parentRow = aParentNode == this._rootNode ? + undefined : this._getRowForNode(aParentNode, true); + let oldRow = this._getRowForNode(aNode, true, parentRow, aOldIndex); + if (oldRow < 0) + throw Cr.NS_ERROR_UNEXPECTED; + + // If the node was exclusively selected, the node next to it will be + // selected. + let selectNext = false; + let selection = this.selection; + if (selection.getRangeCount() == 1) { + let min = { }, max = { }; + selection.getRangeAt(0, min, max); + if (min.value == max.value && + this.nodeForTreeIndex(min.value) == aNode) + selectNext = true; + } + + // Remove the node and its children, if any. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + for (let splicedNode of this._rows.splice(oldRow, count)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + this._tree.rowCountChanged(oldRow, -count); + + // Redraw the parent if its twisty state has changed. + if (aParentNode != this._rootNode && !aParentNode.hasChildren) { + parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Restore selection if the node was exclusively selected. + if (!selectNext) + return; + + // Restore selection. + let rowToSelect = Math.min(oldRow, this._rows.length - 1); + if (rowToSelect != -1) + this.selection.rangedSelect(rowToSelect, rowToSelect, true); + }, + + nodeMoved: + function PTV_nodeMoved(aNode, aOldParent, aOldIndex, aNewParent, aNewIndex) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // Bail out for hidden separators. + if (PlacesUtils.nodeIsSeparator(aNode) && this.isSorted()) + return; + + // Note that at this point the node has already been moved by the backend, + // so we must give hints to _getRowForNode to get the old row position. + let oldParentRow = aOldParent == this._rootNode ? + undefined : this._getRowForNode(aOldParent, true); + let oldRow = this._getRowForNode(aNode, true, oldParentRow, aOldIndex); + if (oldRow < 0) + throw Cr.NS_ERROR_UNEXPECTED; + + // If this node is a container it could take up more than one row. + let count = this._countVisibleRowsForNodeAtRow(oldRow); + + // Persist selection state. + let nodesToReselect = + this._getSelectedNodesInRange(oldRow, oldRow + count); + if (nodesToReselect.length > 0) + this.selection.selectEventsSuppressed = true; + + // Redraw the parent if its twisty state has changed. + if (aOldParent != this._rootNode && !aOldParent.hasChildren) { + let parentRow = oldRow - 1; + this._tree.invalidateRow(parentRow); + } + + // Remove node and its children, if any, from the old position. + for (let splicedNode of this._rows.splice(oldRow, count)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + this._tree.rowCountChanged(oldRow, -count); + + // Insert the node into the new position. + this.nodeInserted(aNewParent, aNode, aNewIndex); + + // Restore selection. + if (nodesToReselect.length > 0) { + this._restoreSelection(nodesToReselect, aNewParent); + this.selection.selectEventsSuppressed = false; + } + }, + + _invalidateCellValue: function PTV__invalidateCellValue(aNode, + aColumnType) { + console.assert(this._result, "Got a notification but have no result!"); + if (!this._tree || !this._result) + return; + + // Nothing to do for the root node. + if (aNode == this._rootNode) + return; + + let row = this._getRowForNode(aNode); + if (row == -1) + return; + + let column = this._findColumnByType(aColumnType); + if (column && !column.element.hidden) { + if (aColumnType == this.COLUMN_TYPE_TITLE) + this._tree.removeImageCacheEntry(row, column); + this._tree.invalidateCell(row, column); + } + + // Last modified time is altered for almost all node changes. + if (aColumnType != this.COLUMN_TYPE_LASTMODIFIED) { + let lastModifiedColumn = + this._findColumnByType(this.COLUMN_TYPE_LASTMODIFIED); + if (lastModifiedColumn && !lastModifiedColumn.hidden) + this._tree.invalidateCell(row, lastModifiedColumn); + } + }, + + _populateLivemarkContainer: function PTV__populateLivemarkContainer(aNode) { + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + let placesNode = aNode; + // Need to check containerOpen since getLivemark is async. + if (!placesNode.containerOpen) + return; + + let children = aLivemark.getNodesForContainer(placesNode); + for (let i = 0; i < children.length; i++) { + let child = children[i]; + this.nodeInserted(placesNode, child, i); + } + }, Cu.reportError); + }, + + nodeTitleChanged: function PTV_nodeTitleChanged(aNode, aNewTitle) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeURIChanged: function PTV_nodeURIChanged(aNode, aOldURI) { + this._nodeDetails.delete(makeNodeDetailsKey({uri: aOldURI, + itemId: aNode.itemId, + time: aNode.time})); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_URI); + }, + + nodeIconChanged: function PTV_nodeIconChanged(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, + + nodeHistoryDetailsChanged: + function PTV_nodeHistoryDetailsChanged(aNode, aOldVisitDate, + aOldVisitCount) { + this._nodeDetails.delete(makeNodeDetailsKey({uri: aNode.uri, + itemId: aNode.itemId, + time: aOldVisitDate})); + this._nodeDetails.set(makeNodeDetailsKey(aNode), aNode); + if (aNode.parent && this._controller.hasCachedLivemarkInfo(aNode.parent)) { + // Find the node in the parent. + let parentRow = this._flatList ? 0 : this._getRowForNode(aNode.parent); + for (let i = parentRow; i < this._rows.length; i++) { + let child = this.nodeForTreeIndex(i); + if (child.uri == aNode.uri) { + this._cellProperties.delete(child); + this._invalidateCellValue(child, this.COLUMN_TYPE_TITLE); + break; + } + } + return; + } + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATE); + this._invalidateCellValue(aNode, this.COLUMN_TYPE_VISITCOUNT); + }, + + nodeTagsChanged: function PTV_nodeTagsChanged(aNode) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TAGS); + }, + + nodeKeywordChanged(aNode, aNewKeyword) {}, + + nodeAnnotationChanged: function PTV_nodeAnnotationChanged(aNode, aAnno) { + if (aAnno == PlacesUIUtils.DESCRIPTION_ANNO) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DESCRIPTION); + } else if (aAnno == PlacesUtils.LMANNO_FEEDURI) { + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + this._controller.cacheLivemarkInfo(aNode, aLivemark); + let properties = this._cellProperties.get(aNode); + this._cellProperties.set(aNode, properties += " livemark"); + // The livemark attribute is set as a cell property on the title cell. + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + }, Cu.reportError); + } + }, + + nodeDateAddedChanged: function PTV_nodeDateAddedChanged(aNode, aNewValue) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_DATEADDED); + }, + + nodeLastModifiedChanged: + function PTV_nodeLastModifiedChanged(aNode, aNewValue) { + this._invalidateCellValue(aNode, this.COLUMN_TYPE_LASTMODIFIED); + }, + + containerStateChanged: + function PTV_containerStateChanged(aNode, aOldState, aNewState) { + this.invalidateContainer(aNode); + + if (PlacesUtils.nodeIsFolder(aNode) || + (this._flatList && aNode == this._rootNode)) { + let queryOptions = PlacesUtils.asQuery(this._rootNode).queryOptions; + if (queryOptions.excludeItems) { + return; + } + if (aNode.itemId != -1) { // run when there's a valid node id + PlacesUtils.livemarks.getLivemark({ id: aNode.itemId }) + .then(aLivemark => { + let shouldInvalidate = + !this._controller.hasCachedLivemarkInfo(aNode); + this._controller.cacheLivemarkInfo(aNode, aLivemark); + if (aNewState == Ci.nsINavHistoryContainerResultNode.STATE_OPENED) { + aLivemark.registerForUpdates(aNode, this); + // Prioritize the current livemark. + aLivemark.reload(); + PlacesUtils.livemarks.reloadLivemarks(); + if (shouldInvalidate) + this.invalidateContainer(aNode); + } else { + aLivemark.unregisterForUpdates(aNode); + } + }, () => undefined); + } + } + }, + + invalidateContainer: function PTV_invalidateContainer(aContainer) { + console.assert(this._result, "Need to have a result to update"); + if (!this._tree) + return; + + // If we are currently editing, don't invalidate the container until we + // finish. + if (this._tree.element.getAttribute("editing")) { + if (!this._editingObservers) { + this._editingObservers = new Map(); + } + if (!this._editingObservers.has(aContainer)) { + let mutationObserver = new MutationObserver(() => { + Services.tm.dispatchToMainThread( + () => this.invalidateContainer(aContainer)); + let observer = this._editingObservers.get(aContainer); + observer.disconnect(); + this._editingObservers.delete(aContainer); + }); + + mutationObserver.observe(this._tree.element, { + attributes: true, + attributeFilter: ["editing"], + }); + + this._editingObservers.set(aContainer, mutationObserver); + } + return; + } + + let startReplacement, replaceCount; + if (aContainer == this._rootNode) { + startReplacement = 0; + replaceCount = this._rows.length; + + // If the root node is now closed, the tree is empty. + if (!this._rootNode.containerOpen) { + this._nodeDetails.clear(); + this._rows = []; + if (replaceCount) + this._tree.rowCountChanged(startReplacement, -replaceCount); + + return; + } + } else { + // Update the twisty state. + let row = this._getRowForNode(aContainer); + this._tree.invalidateRow(row); + + // We don't replace the container node itself, so we should decrease the + // replaceCount by 1. + startReplacement = row + 1; + replaceCount = this._countVisibleRowsForNodeAtRow(row) - 1; + } + + // Persist selection state. + let nodesToReselect = + this._getSelectedNodesInRange(startReplacement, + startReplacement + replaceCount); + + // Now update the number of elements. + this.selection.selectEventsSuppressed = true; + + // First remove the old elements + for (let splicedNode of this._rows.splice(startReplacement, replaceCount)) { + this._nodeDetails.delete(makeNodeDetailsKey(splicedNode)); + } + + // If the container is now closed, we're done. + if (!aContainer.containerOpen) { + let oldSelectionCount = this.selection.count; + if (replaceCount) + this._tree.rowCountChanged(startReplacement, -replaceCount); + + // Select the row next to the closed container if any of its + // children were selected, and nothing else is selected. + if (nodesToReselect.length > 0 && + nodesToReselect.length == oldSelectionCount) { + this.selection.rangedSelect(startReplacement, startReplacement, true); + this._tree.ensureRowIsVisible(startReplacement); + } + + this.selection.selectEventsSuppressed = false; + return; + } + + // Otherwise, start a batch first. + this._tree.beginUpdateBatch(); + if (replaceCount) + this._tree.rowCountChanged(startReplacement, -replaceCount); + + let toOpenElements = []; + let elementsAddedCount = this._buildVisibleSection(aContainer, + startReplacement, + toOpenElements); + if (elementsAddedCount) + this._tree.rowCountChanged(startReplacement, elementsAddedCount); + + if (!this._flatList) { + // Now, open any containers that were persisted. + for (let i = 0; i < toOpenElements.length; i++) { + let item = toOpenElements[i]; + let parent = item.parent; + + // Avoid recursively opening containers. + while (parent) { + if (parent.uri == item.uri) + break; + parent = parent.parent; + } + + // If we don't have a parent, we made it all the way to the root + // and didn't find a match, so we can open our item. + if (!parent && !item.containerOpen) + item.containerOpen = true; + } + } + + if (this._controller.hasCachedLivemarkInfo(aContainer)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (!queryOptions.excludeItems) { + this._populateLivemarkContainer(aContainer); + } + } + + this._tree.endUpdateBatch(); + + // Restore selection. + this._restoreSelection(nodesToReselect, aContainer); + this.selection.selectEventsSuppressed = false; + }, + + _columns: [], + _findColumnByType: function PTV__findColumnByType(aColumnType) { + if (this._columns[aColumnType]) + return this._columns[aColumnType]; + + let columns = this._tree.columns; + let colCount = columns.count; + for (let i = 0; i < colCount; i++) { + let column = columns.getColumnAt(i); + let columnType = this._getColumnType(column); + this._columns[columnType] = column; + if (columnType == aColumnType) + return column; + } + + // That's completely valid. Most of our trees actually include just the + // title column. + return null; + }, + + sortingChanged: function PTV__sortingChanged(aSortingMode) { + if (!this._tree || !this._result) + return; + + // Depending on the sort mode, certain commands may be disabled. + window.updateCommands("sort"); + + let columns = this._tree.columns; + + // Clear old sorting indicator. + let sortedColumn = columns.getSortedColumn(); + if (sortedColumn) + sortedColumn.element.removeAttribute("sortDirection"); + + // Set new sorting indicator by looking through all columns for ours. + if (aSortingMode == Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) + return; + + let [desiredColumn, desiredIsDescending] = + this._sortTypeToColumnType(aSortingMode); + let column = this._findColumnByType(desiredColumn); + if (column) { + let sortDir = desiredIsDescending ? "descending" : "ascending"; + column.element.setAttribute("sortDirection", sortDir); + } + }, + + _inBatchMode: false, + batching: function PTV__batching(aToggleMode) { + if (this._inBatchMode != aToggleMode) { + this._inBatchMode = this.selection.selectEventsSuppressed = aToggleMode; + if (this._inBatchMode) { + this._tree.beginUpdateBatch(); + } else { + this._tree.endUpdateBatch(); + } + } + }, + + get result() { + return this._result; + }, + set result(val) { + if (this._result) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + + if (val) { + this._result = val; + this._rootNode = this._result.root; + this._cellProperties = new Map(); + this._cuttingNodes = new Set(); + } else if (this._result) { + delete this._result; + delete this._rootNode; + delete this._cellProperties; + delete this._cuttingNodes; + } + + // If the tree is not set yet, setTree will call finishInit. + if (this._tree && val) + this._finishInit(); + + return val; + }, + + /** + * This allows you to get at the real node for a given row index. This is + * only valid when a tree is attached. + * + * @param {Integer} aIndex The index for the node to get. + * @return {Ci.nsINavHistoryResultNode} The node. + * @throws Cr.NS_ERROR_INVALID_ARG if the index is greater than the number of + * rows. + */ + nodeForTreeIndex(aIndex) { + if (aIndex > this._rows.length) + throw Cr.NS_ERROR_INVALID_ARG; + + return this._getNodeForRow(aIndex); + }, + + /** + * Reverse of nodeForTreeIndex, returns the row index for a given result node. + * The node should be part of the tree. + * + * @param {Ci.nsINavHistoryResultNode} aNode The node to look for in the tree. + * @returns {Integer} The found index, or -1 if the item is not visible or not found. + */ + treeIndexForNode(aNode) { + // The API allows passing invisible nodes. + try { + return this._getRowForNode(aNode, true); + } catch (ex) { } + + return -1; + }, + + // nsITreeView + get rowCount() { + return this._rows.length; + }, + get selection() { + return this._selection; + }, + set selection(val) { + this._selection = val; + }, + + getRowProperties() { return ""; }, + + getCellProperties: + function PTV_getCellProperties(aRow, aColumn) { + // for anonid-trees, we need to add the column-type manually + var props = ""; + let columnType = aColumn.element.getAttribute("anonid"); + if (columnType) + props += columnType; + else + columnType = aColumn.id; + + // Set the "ltr" property on url cells + if (columnType == "url") + props += " ltr"; + + if (columnType != "title") + return props; + + let node = this._getNodeForRow(aRow); + + if (this._cuttingNodes.has(node)) { + props += " cutting"; + } + + let properties = this._cellProperties.get(node); + if (properties === undefined) { + properties = ""; + let itemId = node.itemId; + let nodeType = node.type; + if (PlacesUtils.containerTypes.includes(nodeType)) { + if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY) { + properties += " query"; + if (PlacesUtils.nodeIsTagQuery(node)) + properties += " tagContainer"; + else if (PlacesUtils.nodeIsDay(node)) + properties += " dayContainer"; + else if (PlacesUtils.nodeIsHost(node)) + properties += " hostContainer"; + } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER || + nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT) { + if (itemId != -1) { + if (this._controller.hasCachedLivemarkInfo(node)) { + properties += " livemark"; + } else { + PlacesUtils.livemarks.getLivemark({ id: itemId }) + .then(aLivemark => { + this._controller.cacheLivemarkInfo(node, aLivemark); + let livemarkProps = this._cellProperties.get(node); + this._cellProperties.set(node, livemarkProps += " livemark"); + // The livemark attribute is set as a cell property on the title cell. + this._invalidateCellValue(node, this.COLUMN_TYPE_TITLE); + }, () => undefined); + } + } + } + + if (itemId == -1) { + switch (node.bookmarkGuid) { + case PlacesUtils.bookmarks.virtualToolbarGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.toolbarGuid}`; + break; + case PlacesUtils.bookmarks.virtualMenuGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.menuGuid}`; + break; + case PlacesUtils.bookmarks.virtualUnfiledGuid: + properties += ` queryFolder_${PlacesUtils.bookmarks.unfiledGuid}`; + break; + } + } else { + let queryName = PlacesUIUtils.getLeftPaneQueryNameFromId(itemId); + if (queryName) + properties += " OrganizerQuery_" + queryName; + } + } else if (nodeType == Ci.nsINavHistoryResultNode.RESULT_TYPE_SEPARATOR) + properties += " separator"; + else if (PlacesUtils.nodeIsURI(node)) { + properties += " " + PlacesUIUtils.guessUrlSchemeForUI(node.uri); + + if (this._controller.hasCachedLivemarkInfo(node.parent)) { + properties += " livemarkItem"; + if (node.accessCount) { + properties += " visited"; + } + } + } + + this._cellProperties.set(node, properties); + } + + return props + " " + properties; + }, + + getColumnProperties(aColumn) { return ""; }, + + isContainer: function PTV_isContainer(aRow) { + // Only leaf nodes aren't listed in the rows array. + let node = this._rows[aRow]; + if (node === undefined || !PlacesUtils.nodeIsContainer(node)) + return false; + + // Flat-lists may ignore expandQueries and other query options when + // they are asked to open a container. + if (this._flatList) + return true; + + // Treat non-expandable childless queries as non-containers, unless they + // are tags. + if (PlacesUtils.nodeIsQuery(node) && !PlacesUtils.nodeIsTagQuery(node)) { + PlacesUtils.asQuery(node); + return node.queryOptions.expandQueries || node.hasChildren; + } + return true; + }, + + isContainerOpen: function PTV_isContainerOpen(aRow) { + if (this._flatList) + return false; + + // All containers are listed in the rows array. + return this._rows[aRow].containerOpen; + }, + + isContainerEmpty: function PTV_isContainerEmpty(aRow) { + if (this._flatList) + return true; + + let node = this._rows[aRow]; + if (this._controller.hasCachedLivemarkInfo(node)) { + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + return queryOptions.excludeItems; + } + + // All containers are listed in the rows array. + return !node.hasChildren; + }, + + isSeparator: function PTV_isSeparator(aRow) { + // All separators are listed in the rows array. + let node = this._rows[aRow]; + return node && PlacesUtils.nodeIsSeparator(node); + }, + + isSorted: function PTV_isSorted() { + return this._result.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE; + }, + + canDrop: function PTV_canDrop(aRow, aOrientation, aDataTransfer) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + // Drop position into a sorted treeview would be wrong. + if (this.isSorted()) + return false; + + let ip = this._getInsertionPoint(aRow, aOrientation); + return ip && PlacesControllerDragHelper.canDrop(ip, aDataTransfer); + }, + + _getInsertionPoint: function PTV__getInsertionPoint(index, orientation) { + let container = this._result.root; + let dropNearNode = null; + // When there's no selection, assume the container is the container + // the view is populated from (i.e. the result's itemId). + if (index != -1) { + let lastSelected = this.nodeForTreeIndex(index); + if (this.isContainer(index) && orientation == Ci.nsITreeView.DROP_ON) { + // If the last selected item is an open container, append _into_ + // it, rather than insert adjacent to it. + container = lastSelected; + index = -1; + } else if (lastSelected.containerOpen && + orientation == Ci.nsITreeView.DROP_AFTER && + lastSelected.hasChildren) { + // If the last selected node is an open container and the user is + // trying to drag into it as a first node, really insert into it. + container = lastSelected; + orientation = Ci.nsITreeView.DROP_ON; + index = 0; + } else { + // Use the last-selected node's container. + container = lastSelected.parent; + + // During its Drag & Drop operation, the tree code closes-and-opens + // containers very often (part of the XUL "spring-loaded folders" + // implementation). And in certain cases, we may reach a closed + // container here. However, we can simply bail out when this happens, + // because we would then be back here in less than a millisecond, when + // the container had been reopened. + if (!container || !container.containerOpen) + return null; + + // Avoid the potentially expensive call to getChildIndex + // if we know this container doesn't allow insertion. + if (this._controller.disallowInsertion(container)) + return null; + + let queryOptions = PlacesUtils.asQuery(this._result.root).queryOptions; + if (queryOptions.sortingMode != + Ci.nsINavHistoryQueryOptions.SORT_BY_NONE) { + // If we are within a sorted view, insert at the end. + index = -1; + } else if (queryOptions.excludeItems || + queryOptions.excludeQueries || + queryOptions.excludeReadOnlyFolders) { + // Some item may be invisible, insert near last selected one. + // We don't replace index here to avoid requests to the db, + // instead it will be calculated later by the controller. + index = -1; + dropNearNode = lastSelected; + } else { + let lsi = container.getChildIndex(lastSelected); + index = orientation == Ci.nsITreeView.DROP_BEFORE ? lsi : lsi + 1; + } + } + } + + if (this._controller.disallowInsertion(container)) + return null; + + // TODO (Bug 1160193): properly support dropping on a tag root. + let tagName = null; + if (PlacesUtils.nodeIsTagQuery(container)) { + tagName = container.title; + if (!tagName) + return null; + } + + return new PlacesInsertionPoint({ + parentId: PlacesUtils.getConcreteItemId(container), + parentGuid: PlacesUtils.getConcreteItemGuid(container), + index, orientation, tagName, dropNearNode + }); + }, + + drop: function PTV_drop(aRow, aOrientation, aDataTransfer) { + // We are responsible for translating the |index| and |orientation| + // parameters into a container id and index within the container, + // since this information is specific to the tree view. + let ip = this._getInsertionPoint(aRow, aOrientation); + if (ip) { + PlacesControllerDragHelper.onDrop(ip, aDataTransfer, this._tree.element) + .catch(Cu.reportError) + .then(() => { + // We should only clear the drop target once + // the onDrop is complete, as it is an async function. + PlacesControllerDragHelper.currentDropTarget = null; + }); + } + }, + + getParentIndex: function PTV_getParentIndex(aRow) { + let [, parentRow] = this._getParentByChildRow(aRow); + return parentRow; + }, + + hasNextSibling: function PTV_hasNextSibling(aRow, aAfterIndex) { + if (aRow == this._rows.length - 1) { + // The last row has no sibling. + return false; + } + + let node = this._rows[aRow]; + if (node === undefined || this._isPlainContainer(node.parent)) { + // The node is a child of a plain container. + // If the next row is either unset or has the same parent, + // it's a sibling. + let nextNode = this._rows[aRow + 1]; + return (nextNode == undefined || nextNode.parent == node.parent); + } + + let thisLevel = node.indentLevel; + for (let i = aAfterIndex + 1; i < this._rows.length; ++i) { + let rowNode = this._getNodeForRow(i); + let nextLevel = rowNode.indentLevel; + if (nextLevel == thisLevel) + return true; + if (nextLevel < thisLevel) + break; + } + + return false; + }, + + getLevel(aRow) { + return this._getNodeForRow(aRow).indentLevel; + }, + + getImageSrc: function PTV_getImageSrc(aRow, aColumn) { + // Only the title column has an image. + if (this._getColumnType(aColumn) != this.COLUMN_TYPE_TITLE) + return ""; + + let node = this._getNodeForRow(aRow); + return node.icon; + }, + + getCellValue(aRow, aColumn) { }, + + getCellText: function PTV_getCellText(aRow, aColumn) { + let node = this._getNodeForRow(aRow); + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + // normally, this is just the title, but we don't want empty items in + // the tree view so return a special string if the title is empty. + // Do it here so that callers can still get at the 0 length title + // if they go through the "result" API. + if (PlacesUtils.nodeIsSeparator(node)) + return ""; + return PlacesUIUtils.getBestTitle(node, true); + case this.COLUMN_TYPE_TAGS: + return node.tags; + case this.COLUMN_TYPE_URI: + if (PlacesUtils.nodeIsURI(node)) + return node.uri; + return ""; + case this.COLUMN_TYPE_DATE: + let nodeTime = node.time; + if (nodeTime == 0 || !PlacesUtils.nodeIsURI(node)) { + // hosts and days shouldn't have a value for the date column. + // Actually, you could argue this point, but looking at the + // results, seeing the most recently visited date is not what + // I expect, and gives me no information I know how to use. + // Only show this for URI-based items. + return ""; + } + + return this._convertPRTimeToString(nodeTime); + case this.COLUMN_TYPE_VISITCOUNT: + return node.accessCount; + case this.COLUMN_TYPE_DESCRIPTION: + if (node.itemId != -1) { + try { + return PlacesUtils.annotations. + getItemAnnotation(node.itemId, PlacesUIUtils.DESCRIPTION_ANNO); + } catch (ex) { /* has no description */ } + } + return ""; + case this.COLUMN_TYPE_DATEADDED: + if (node.dateAdded) + return this._convertPRTimeToString(node.dateAdded); + return ""; + case this.COLUMN_TYPE_LASTMODIFIED: + if (node.lastModified) + return this._convertPRTimeToString(node.lastModified); + return ""; + } + return ""; + }, + + setTree: function PTV_setTree(aTree) { + // If we are replacing the tree during a batch, there is a concrete risk + // that the treeView goes out of sync, thus it's safer to end the batch now. + // This is a no-op if we are not batching. + this.batching(false); + + let hasOldTree = this._tree != null; + this._tree = aTree; + + if (this._result) { + if (hasOldTree) { + // detach from result when we are detaching from the tree. + // This breaks the reference cycle between us and the result. + if (!aTree) { + this._result.removeObserver(this); + this._rootNode.containerOpen = false; + } + } + if (aTree) + this._finishInit(); + } + }, + + toggleOpenState: function PTV_toggleOpenState(aRow) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + let node = this._rows[aRow]; + if (this._flatList && this._openContainerCallback) { + this._openContainerCallback(node); + return; + } + + // Persist containers open status, but never persist livemarks. + if (!this._controller.hasCachedLivemarkInfo(node)) { + let uri = node.uri; + + if (uri) { + let docURI = document.documentURI; + + if (node.containerOpen) { + this._xulStore.removeValue(docURI, uri, "open"); + } else { + this._xulStore.setValue(docURI, uri, "open", "true"); + } + } + } + + node.containerOpen = !node.containerOpen; + }, + + cycleHeader: function PTV_cycleHeader(aColumn) { + if (!this._result) + throw Cr.NS_ERROR_UNEXPECTED; + + // Sometimes you want a tri-state sorting, and sometimes you don't. This + // rule allows tri-state sorting when the root node is a folder. This will + // catch the most common cases. When you are looking at folders, you want + // the third state to reset the sorting to the natural bookmark order. When + // you are looking at history, that third state has no meaning so we try + // to disallow it. + // + // The problem occurs when you have a query that results in bookmark + // folders. One example of this is the subscriptions view. In these cases, + // this rule doesn't allow you to sort those sub-folders by their natural + // order. + let allowTriState = PlacesUtils.nodeIsFolder(this._result.root); + + let oldSort = this._result.sortingMode; + let oldSortingAnnotation = this._result.sortingAnnotation; + let newSort; + let newSortingAnnotation = ""; + const NHQO = Ci.nsINavHistoryQueryOptions; + switch (this._getColumnType(aColumn)) { + case this.COLUMN_TYPE_TITLE: + if (oldSort == NHQO.SORT_BY_TITLE_ASCENDING) + newSort = NHQO.SORT_BY_TITLE_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_TITLE_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_TITLE_ASCENDING; + + break; + case this.COLUMN_TYPE_URI: + if (oldSort == NHQO.SORT_BY_URI_ASCENDING) + newSort = NHQO.SORT_BY_URI_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_URI_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_URI_ASCENDING; + + break; + case this.COLUMN_TYPE_DATE: + if (oldSort == NHQO.SORT_BY_DATE_ASCENDING) + newSort = NHQO.SORT_BY_DATE_DESCENDING; + else if (allowTriState && + oldSort == NHQO.SORT_BY_DATE_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_DATE_ASCENDING; + + break; + case this.COLUMN_TYPE_VISITCOUNT: + // visit count default is unusual because we sort by descending + // by default because you are most likely to be looking for + // highly visited sites when you click it + if (oldSort == NHQO.SORT_BY_VISITCOUNT_DESCENDING) + newSort = NHQO.SORT_BY_VISITCOUNT_ASCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_VISITCOUNT_ASCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_VISITCOUNT_DESCENDING; + + break; + case this.COLUMN_TYPE_DESCRIPTION: + if (oldSort == NHQO.SORT_BY_ANNOTATION_ASCENDING && + oldSortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) { + newSort = NHQO.SORT_BY_ANNOTATION_DESCENDING; + newSortingAnnotation = PlacesUIUtils.DESCRIPTION_ANNO; + } else if (allowTriState && + oldSort == NHQO.SORT_BY_ANNOTATION_DESCENDING && + oldSortingAnnotation == PlacesUIUtils.DESCRIPTION_ANNO) + newSort = NHQO.SORT_BY_NONE; + else { + newSort = NHQO.SORT_BY_ANNOTATION_ASCENDING; + newSortingAnnotation = PlacesUIUtils.DESCRIPTION_ANNO; + } + + break; + case this.COLUMN_TYPE_DATEADDED: + if (oldSort == NHQO.SORT_BY_DATEADDED_ASCENDING) + newSort = NHQO.SORT_BY_DATEADDED_DESCENDING; + else if (allowTriState && + oldSort == NHQO.SORT_BY_DATEADDED_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_DATEADDED_ASCENDING; + + break; + case this.COLUMN_TYPE_LASTMODIFIED: + if (oldSort == NHQO.SORT_BY_LASTMODIFIED_ASCENDING) + newSort = NHQO.SORT_BY_LASTMODIFIED_DESCENDING; + else if (allowTriState && + oldSort == NHQO.SORT_BY_LASTMODIFIED_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_LASTMODIFIED_ASCENDING; + + break; + case this.COLUMN_TYPE_TAGS: + if (oldSort == NHQO.SORT_BY_TAGS_ASCENDING) + newSort = NHQO.SORT_BY_TAGS_DESCENDING; + else if (allowTriState && oldSort == NHQO.SORT_BY_TAGS_DESCENDING) + newSort = NHQO.SORT_BY_NONE; + else + newSort = NHQO.SORT_BY_TAGS_ASCENDING; + + break; + default: + throw Cr.NS_ERROR_INVALID_ARG; + } + this._result.sortingAnnotation = newSortingAnnotation; + this._result.sortingMode = newSort; + }, + + isEditable: function PTV_isEditable(aRow, aColumn) { + // At this point we only support editing the title field. + if (aColumn.index != 0) + return false; + + let node = this._rows[aRow]; + if (!node) { + Cu.reportError("isEditable called for an unbuilt row."); + return false; + } + let itemGuid = node.bookmarkGuid; + + // Only bookmark-nodes are editable. Fortunately, this checks also takes + // care of livemark children. + if (itemGuid == "") + return false; + + // The following items are also not editable, even though they are bookmark + // items. + // * places-roots + // * the left pane special folders and queries (those are place: uri + // bookmarks) + // * separators + // + // Note that concrete itemIds aren't used intentionally. For example, we + // have no reason to disallow renaming a shortcut to the Bookmarks Toolbar, + // except for the one under All Bookmarks. + if (PlacesUtils.nodeIsSeparator(node) || PlacesUtils.isRootItem(itemGuid) || + PlacesUtils.isQueryGeneratedFolder(itemGuid)) + return false; + + let parentId = PlacesUtils.getConcreteItemId(node.parent); + if (parentId == PlacesUIUtils.leftPaneFolderId) { + // Note that the for the time being this is the check that actually + // blocks renaming places "roots", and not the isRootItem check above. + // That's because places root are only exposed through folder shortcuts + // descendants of the left pane folder. + return false; + } + + return true; + }, + + setCellText: function PTV_setCellText(aRow, aColumn, aText) { + // We may only get here if the cell is editable. + let node = this._rows[aRow]; + if (node.title != aText) { + PlacesTransactions.EditTitle({ guid: node.bookmarkGuid, title: aText }) + .transact().catch(Cu.reportError); + } + }, + + toggleCutNode: function PTV_toggleCutNode(aNode, aValue) { + let currentVal = this._cuttingNodes.has(aNode); + if (currentVal != aValue) { + if (aValue) + this._cuttingNodes.add(aNode); + else + this._cuttingNodes.delete(aNode); + + this._invalidateCellValue(aNode, this.COLUMN_TYPE_TITLE); + } + }, + + selectionChanged() { }, + cycleCell(aRow, aColumn) { }, + isSelectable(aRow, aColumn) { return false; }, +}; diff --git a/comm/suite/components/places/jar.mn b/comm/suite/components/places/jar.mn new file mode 100644 index 0000000000..e0bb80042b --- /dev/null +++ b/comm/suite/components/places/jar.mn @@ -0,0 +1,30 @@ +# 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/. + +comm.jar: +# Provide another URI for the bookmarkProperties dialog so we can persist the +# attributes separately + content/communicator/places/bookmarkProperties2.xul (content/bookmarkProperties.xul) +* content/communicator/places/places.xul (content/places.xul) + content/communicator/places/places.js (content/places.js) + content/communicator/places/places.css (content/places.css) + content/communicator/places/organizer.css (content/organizer.css) + content/communicator/places/bookmarkProperties.xul (content/bookmarkProperties.xul) + content/communicator/places/bookmarkProperties.js (content/bookmarkProperties.js) + content/communicator/places/placesOverlay.xul (content/placesOverlay.xul) + content/communicator/places/menu.xml (content/menu.xml) + content/communicator/places/tree.xml (content/tree.xml) + content/communicator/places/controller.js (content/controller.js) + content/communicator/places/treeView.js (content/treeView.js) + content/communicator/places/browserPlacesViews.js (content/browserPlacesViews.js) +# keep the Places version of the history sidebar at history/history-panel.xul +# to prevent having to worry about between versions of the browser +* content/communicator/history/history-panel.xul (content/history-panel.xul) + content/communicator/places/history-panel.js (content/history-panel.js) +# ditto for the bookmarks sidebar + content/communicator/bookmarks/bookmarksPanel.xul (content/bookmarksPanel.xul) + content/communicator/bookmarks/bookmarksPanel.js (content/bookmarksPanel.js) + content/communicator/bookmarks/sidebarUtils.js (content/sidebarUtils.js) + content/communicator/places/editBookmarkOverlay.xul (content/editBookmarkOverlay.xul) + content/communicator/places/editBookmarkOverlay.js (content/editBookmarkOverlay.js) diff --git a/comm/suite/components/places/moz.build b/comm/suite/components/places/moz.build new file mode 100644 index 0000000000..6c33011ed4 --- /dev/null +++ b/comm/suite/components/places/moz.build @@ -0,0 +1,28 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/autocomplete/xpcshell.ini", + "tests/unit/xpcshell.ini", +] + +MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"] +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_COMPONENTS += [ + "nsPlacesAutoComplete.js", + "nsPlacesAutoComplete.manifest", +] + +EXTRA_JS_MODULES += [ + "PlacesUIUtils.jsm", +] + + +with Files("**"): + BUG_COMPONENT = ("SeaMonkey", "Bookmarks & History") diff --git a/comm/suite/components/places/nsPlacesAutoComplete.js b/comm/suite/components/places/nsPlacesAutoComplete.js new file mode 100644 index 0000000000..89c6d66753 --- /dev/null +++ b/comm/suite/components/places/nsPlacesAutoComplete.js @@ -0,0 +1,1323 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 expandtab + * 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 {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +var {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); + +//////////////////////////////////////////////////////////////////////////////// +//// Constants + +// This SQL query fragment provides the following: +// - whether the entry is bookmarked (kQueryIndexBookmarked) +// - the bookmark title, if it is a bookmark (kQueryIndexBookmarkTitle) +// - the tags associated with a bookmarked entry (kQueryIndexTags) +const kBookTagSQLFragment = + `EXISTS(SELECT 1 FROM moz_bookmarks WHERE fk = h.id) AS bookmarked, + ( + SELECT title FROM moz_bookmarks WHERE fk = h.id AND title NOTNULL + ORDER BY lastModified DESC LIMIT 1 + ) AS btitle, + ( + SELECT GROUP_CONCAT(t.title, ',') + FROM moz_bookmarks b + JOIN moz_bookmarks t ON t.id = +b.parent AND t.parent = :parent + WHERE b.fk = h.id + ) AS tags`; + +// observer topics +const kTopicShutdown = "places-shutdown"; +const kPrefChanged = "nsPref:changed"; + +// Match type constants. These indicate what type of search function we should +// be using. +const MATCH_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_ANYWHERE; +const MATCH_BOUNDARY_ANYWHERE = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY_ANYWHERE; +const MATCH_BOUNDARY = Ci.mozIPlacesAutoComplete.MATCH_BOUNDARY; +const MATCH_BEGINNING = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING; +const MATCH_BEGINNING_CASE_SENSITIVE = Ci.mozIPlacesAutoComplete.MATCH_BEGINNING_CASE_SENSITIVE; + +// AutoComplete index constants. All AutoComplete queries will provide these +// columns in this order. +const kQueryIndexURL = 0; +const kQueryIndexTitle = 1; +const kQueryIndexBookmarked = 2; +const kQueryIndexBookmarkTitle = 3; +const kQueryIndexTags = 4; +const kQueryIndexVisitCount = 5; +const kQueryIndexTyped = 6; +const kQueryIndexPlaceId = 7; +const kQueryIndexQueryType = 8; +const kQueryIndexOpenPageCount = 9; + +// AutoComplete query type constants. Describes the various types of queries +// that we can process. +const kQueryTypeKeyword = 0; +const kQueryTypeFiltered = 1; + +// This separator is used as an RTL-friendly way to split the title and tags. +// It can also be used by an nsIAutoCompleteResult consumer to re-split the +// "comment" back into the title and the tag. +const kTitleTagsSeparator = " \u2013 "; + +const kBrowserUrlbarBranch = "browser.urlbar."; +// Toggle autocomplete. +const kBrowserUrlbarAutocompleteEnabledPref = "autocomplete.enabled"; + +//////////////////////////////////////////////////////////////////////////////// +//// Globals + +XPCOMUtils.defineLazyServiceGetter(this, "gTextURIService", + "@mozilla.org/intl/texttosuburi;1", + "nsITextToSubURI"); + +//////////////////////////////////////////////////////////////////////////////// +//// Helpers + +/** + * Initializes our temporary table on a given database. + * + * @param aDatabase + * The mozIStorageConnection to set up the temp table on. + */ +function initTempTable(aDatabase) +{ + // Note: this should be kept up-to-date with the definition in + // nsPlacesTables.h. + let stmt = aDatabase.createAsyncStatement( + `CREATE TEMP TABLE moz_openpages_temp ( + url TEXT PRIMARY KEY + , open_count INTEGER + )` + ); + stmt.executeAsync(); + stmt.finalize(); + + // Note: this should be kept up-to-date with the definition in + // nsPlacesTriggers.h. + stmt = aDatabase.createAsyncStatement( + `CREATE TEMPORARY TRIGGER moz_openpages_temp_afterupdate_trigger + AFTER UPDATE OF open_count ON moz_openpages_temp FOR EACH ROW + WHEN NEW.open_count = 0 + BEGIN + DELETE FROM moz_openpages_temp + WHERE url = NEW.url; + END` + ); + stmt.executeAsync(); + stmt.finalize(); +} + +/** + * Used to unescape encoded URI strings, and drop information that we do not + * care about for searching. + * + * @param aURIString + * The text to unescape and modify. + * @return the modified uri. + */ +function fixupSearchText(aURIString) +{ + let uri = stripPrefix(aURIString); + return gTextURIService.unEscapeURIForUI("UTF-8", uri); +} + +/** + * Strip prefixes from the URI that we don't care about for searching. + * + * @param aURIString + * The text to modify. + * @return the modified uri. + */ +function stripPrefix(aURIString) +{ + let uri = aURIString; + + if (uri.indexOf("http://") == 0) { + uri = uri.slice(7); + } + else if (uri.indexOf("https://") == 0) { + uri = uri.slice(8); + } + else if (uri.indexOf("ftp://") == 0) { + uri = uri.slice(6); + } + + if (uri.indexOf("www.") == 0) { + uri = uri.slice(4); + } + return uri; +} + +/** + * safePrefGetter get the pref with type safety. + * This will return the default value provided if no pref is set. + * + * @param aPrefBranch + * The nsIPrefBranch containing the required preference + * @param aName + * A preference name + * @param aDefault + * The preference's default value + * @return the preference value or provided default + */ + +function safePrefGetter(aPrefBranch, aName, aDefault) { + let types = { + boolean: "Bool", + number: "Int", + string: "Char" + }; + let type = types[typeof(aDefault)]; + if (!type) { + throw "Unknown type!"; + } + + // If the pref isn't set, we want to use the default. + if (aPrefBranch.getPrefType(aName) == Ci.nsIPrefBranch.PREF_INVALID) { + return aDefault; + } + try { + return aPrefBranch["get" + type + "Pref"](aName); + } + catch (e) { + return aDefault; + } +} + +/** + * Whether UnifiedComplete is alive. + */ +function isUnifiedCompleteInstantiated() { + try { + return Components.manager.QueryInterface(Ci.nsIServiceManager) + .isServiceInstantiated(Cc["@mozilla.org/autocomplete/search;1?name=unifiedcomplete"], + Ci.mozIPlacesAutoComplete); + } catch (ex) { + return false; + } +} + +//////////////////////////////////////////////////////////////////////////////// +//// AutoCompleteStatementCallbackWrapper class + +/** + * Wraps a callback and ensures that handleCompletion is not dispatched if the + * query is no longer tracked. + * + * @param aAutocomplete + * A reference to a nsPlacesAutoComplete. + * @param aCallback + * A reference to a mozIStorageStatementCallback + * @param aDBConnection + * The database connection to execute the queries on. + */ +function AutoCompleteStatementCallbackWrapper(aAutocomplete, aCallback, + aDBConnection) +{ + this._autocomplete = aAutocomplete; + this._callback = aCallback; + this._db = aDBConnection; +} + +AutoCompleteStatementCallbackWrapper.prototype = { + ////////////////////////////////////////////////////////////////////////////// + //// mozIStorageStatementCallback + + handleResult: function ACSCW_handleResult(aResultSet) + { + this._callback.handleResult.apply(this._callback, arguments); + }, + + handleError: function ACSCW_handleError(aError) + { + this._callback.handleError.apply(this._callback, arguments); + }, + + handleCompletion: function ACSCW_handleCompletion(aReason) + { + // Only dispatch handleCompletion if we are not done searching and are a + // pending search. + if (!this._autocomplete.isSearchComplete() && + this._autocomplete.isPendingSearch(this._handle)) { + this._callback.handleCompletion.apply(this._callback, arguments); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// AutoCompleteStatementCallbackWrapper + + /** + * Executes the specified query asynchronously. This object will notify + * this._callback if we should notify (logic explained in handleCompletion). + * + * @param aQueries + * The queries to execute asynchronously. + * @return a mozIStoragePendingStatement that can be used to cancel the + * queries. + */ + executeAsync: function ACSCW_executeAsync(aQueries) + { + return this._handle = this._db.executeAsync(aQueries, aQueries.length, + this); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsISupports + + QueryInterface: XPCOMUtils.generateQI([ + Ci.mozIStorageStatementCallback, + ]) +}; + +//////////////////////////////////////////////////////////////////////////////// +//// nsPlacesAutoComplete class +//// @mozilla.org/autocomplete/search;1?name=history + +function nsPlacesAutoComplete() +{ + ////////////////////////////////////////////////////////////////////////////// + //// Shared Constants for Smart Getters + + // TODO bug 412736 in case of a frecency tie, break it with h.typed and + // h.visit_count which is better than nothing. This is slow, so not doing it + // yet... + function baseQuery(conditions = "") { + let query = `SELECT h.url, h.title, ${kBookTagSQLFragment}, + h.visit_count, h.typed, h.id, :query_type, + t.open_count + FROM moz_places h + LEFT JOIN moz_openpages_temp t ON t.url = h.url + WHERE h.frecency <> 0 + AND AUTOCOMPLETE_MATCH(:searchString, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, + bookmarked, t.open_count, + :matchBehavior, :searchBehavior) + ${conditions} + ORDER BY h.frecency DESC, h.id DESC + LIMIT :maxResults`; + return query; + } + + ////////////////////////////////////////////////////////////////////////////// + //// Smart Getters + + XPCOMUtils.defineLazyGetter(this, "_db", function() { + // Get a cloned, read-only version of the database. We'll only ever write + // to our own in-memory temp table, and having a cloned copy means we do not + // run the risk of our queries taking longer due to the main database + // connection performing a long-running task. + let db = PlacesUtils.history.DBConnection.clone(true); + + // Autocomplete often fallbacks to a table scan due to lack of text indices. + // In such cases a larger cache helps reducing IO. The default Storage + // value is MAX_CACHE_SIZE_BYTES in storage/mozStorageConnection.cpp. + let stmt = db.createAsyncStatement("PRAGMA cache_size = -6144"); // 6MiB + stmt.executeAsync(); + stmt.finalize(); + + // Create our in-memory tables for tab tracking. + initTempTable(db); + + // Populate the table with current open pages cache contents. + if (this._openPagesCache.length > 0) { + // Avoid getter re-entrance from the _registerOpenPageQuery lazy getter. + let stmt = this._registerOpenPageQuery = + db.createAsyncStatement(this._registerOpenPageQuerySQL); + let params = stmt.newBindingParamsArray(); + for (let i = 0; i < this._openPagesCache.length; i++) { + let bp = params.newBindingParams(); + bp.bindByName("page_url", this._openPagesCache[i]); + params.addParams(bp); + } + stmt.bindParameters(params); + stmt.executeAsync(); + stmt.finalize(); + delete this._openPagesCache; + } + + return db; + }); + + this._customQuery = (conditions = "") => { + return this._db.createAsyncStatement(baseQuery(conditions)); + }; + + XPCOMUtils.defineLazyGetter(this, "_defaultQuery", function() { + return this._db.createAsyncStatement(baseQuery()); + }); + + XPCOMUtils.defineLazyGetter(this, "_historyQuery", function() { + // Enforce ignoring the visit_count index, since the frecency one is much + // faster in this case. ANALYZE helps the query planner to figure out the + // faster path, but it may not have run yet. + return this._db.createAsyncStatement(baseQuery("AND +h.visit_count > 0")); + }); + + XPCOMUtils.defineLazyGetter(this, "_bookmarkQuery", function() { + return this._db.createAsyncStatement(baseQuery("AND bookmarked")); + }); + + XPCOMUtils.defineLazyGetter(this, "_tagsQuery", function() { + return this._db.createAsyncStatement(baseQuery("AND tags IS NOT NULL")); + }); + + XPCOMUtils.defineLazyGetter(this, "_openPagesQuery", function() { + return this._db.createAsyncStatement( + `SELECT t.url, t.url, NULL, NULL, NULL, NULL, NULL, NULL, + :query_type, t.open_count, NULL + FROM moz_openpages_temp t + LEFT JOIN moz_places h ON h.url_hash = hash(t.url) AND h.url = t.url + WHERE h.id IS NULL + AND AUTOCOMPLETE_MATCH(:searchString, t.url, t.url, NULL, + NULL, NULL, NULL, t.open_count, + :matchBehavior, :searchBehavior) + ORDER BY t.ROWID DESC + LIMIT :maxResults` + ); + }); + + XPCOMUtils.defineLazyGetter(this, "_typedQuery", function() { + return this._db.createAsyncStatement(baseQuery("AND h.typed = 1")); + }); + + XPCOMUtils.defineLazyGetter(this, "_adaptiveQuery", function() { + return this._db.createAsyncStatement( + `/* do not warn (bug 487789) */ + SELECT h.url, h.title, ${kBookTagSQLFragment}, + h.visit_count, h.typed, h.id, :query_type, t.open_count + FROM ( + SELECT ROUND( + MAX(use_count) * (1 + (input = :search_string)), 1 + ) AS rank, place_id + FROM moz_inputhistory + WHERE input BETWEEN :search_string AND :search_string || X'FFFF' + GROUP BY place_id + ) AS i + JOIN moz_places h ON h.id = i.place_id + LEFT JOIN moz_openpages_temp t ON t.url = h.url + WHERE AUTOCOMPLETE_MATCH(NULL, h.url, + IFNULL(btitle, h.title), tags, + h.visit_count, h.typed, bookmarked, + t.open_count, + :matchBehavior, :searchBehavior) + ORDER BY rank DESC, h.frecency DESC` + ); + }); + + XPCOMUtils.defineLazyGetter(this, "_keywordQuery", function() { + return this._db.createAsyncStatement( + `/* do not warn (bug 487787) */ + SELECT REPLACE(h.url, '%s', :query_string) AS search_url, h.title, + 1, NULL, NULL, h.visit_count, h.typed, h.id, + :query_type, t.open_count + FROM moz_keywords k + JOIN moz_places h ON k.place_id = h.id + LEFT JOIN moz_openpages_temp t ON t.url = search_url + WHERE k.keyword = LOWER(:keyword)` + ); + }); + + this._registerOpenPageQuerySQL = + `INSERT OR REPLACE INTO moz_openpages_temp (url, open_count) + VALUES (:page_url, + IFNULL( + ( + SELECT open_count + 1 + FROM moz_openpages_temp + WHERE url = :page_url + ), + 1 + ) + )`; + XPCOMUtils.defineLazyGetter(this, "_registerOpenPageQuery", function() { + return this._db.createAsyncStatement(this._registerOpenPageQuerySQL); + }); + + XPCOMUtils.defineLazyGetter(this, "_unregisterOpenPageQuery", function() { + return this._db.createAsyncStatement( + `UPDATE moz_openpages_temp + SET open_count = open_count - 1 + WHERE url = :page_url` + ); + }); + + ////////////////////////////////////////////////////////////////////////////// + //// Initialization + + // load preferences + this._prefs = Services.prefs.getBranch(kBrowserUrlbarBranch); + this._syncEnabledPref(); + this._loadPrefs(true); + + // register observers + Services.obs.addObserver(this, kTopicShutdown); +} + +nsPlacesAutoComplete.prototype = { + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSearch + + startSearch: function PAC_startSearch(aSearchString, aSearchParam, + aPreviousResult, aListener) + { + // Stop the search in case the controller has not taken care of it. + this.stopSearch(); + + // Note: We don't use aPreviousResult to make sure ordering of results are + // consistent. See bug 412730 for more details. + + // We want to store the original string with no leading or trailing + // whitespace for case sensitive searches. + this._originalSearchString = aSearchString.trim(); + + this._currentSearchString = + fixupSearchText(this._originalSearchString.toLowerCase()); + + let params = new Set(aSearchParam.split(" ")); + this._enableActions = params.has("enable-actions"); + this._disablePrivateActions = params.has("disable-private-actions"); + + this._listener = aListener; + let result = Cc["@mozilla.org/autocomplete/simple-result;1"]. + createInstance(Ci.nsIAutoCompleteSimpleResult); + result.setSearchString(aSearchString); + result.setListener(this); + this._result = result; + + // If we are not enabled, we need to return now. + if (!this._enabled) { + this._finishSearch(true); + return; + } + + // Reset our search behavior to the default. + if (this._currentSearchString) { + this._behavior = this._defaultBehavior; + } + else { + this._behavior = this._emptySearchDefaultBehavior; + } + // For any given search, we run up to four queries: + // 1) keywords (this._keywordQuery) + // 2) adaptive learning (this._adaptiveQuery) + // 3) open pages not supported by history (this._openPagesQuery) + // 4) query from this._getSearch + // (1) only gets ran if we get any filtered tokens from this._getSearch, + // since if there are no tokens, there is nothing to match, so there is no + // reason to run the query). + let {query, tokens} = + this._getSearch(this._getUnfilteredSearchTokens(this._currentSearchString)); + let queries = tokens.length ? + [this._getBoundKeywordQuery(tokens), this._getBoundAdaptiveQuery()] : + [this._getBoundAdaptiveQuery()]; + + if (this._hasBehavior("openpage")) { + queries.push(this._getBoundOpenPagesQuery(tokens)); + } + queries.push(query); + + // Start executing our queries. + this._executeQueries(queries); + + // Set up our persistent state for the duration of the search. + this._searchTokens = tokens; + this._usedPlaces = {}; + }, + + stopSearch: function PAC_stopSearch() + { + // We need to cancel our searches so we do not get any [more] results. + // However, it's possible we haven't actually started any searches, so this + // method may throw because this._pendingQuery may be undefined. + if (this._pendingQuery) { + this._stopActiveQuery(); + } + + this._finishSearch(false); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIAutoCompleteSimpleResultListener + + onValueRemoved: function PAC_onValueRemoved(aResult, aURISpec, aRemoveFromDB) + { + if (aRemoveFromDB) { + PlacesUtils.history.removePage(NetUtil.newURI(aURISpec)); + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// mozIPlacesAutoComplete + + // If the connection has not yet been started, use this local cache. This + // prevents autocomplete from initing the database till the first search. + _openPagesCache: [], + registerOpenPage: function PAC_registerOpenPage(aURI) + { + if (!this._databaseInitialized) { + this._openPagesCache.push(aURI.spec); + return; + } + + let stmt = this._registerOpenPageQuery; + stmt.params.page_url = aURI.spec; + stmt.executeAsync(); + }, + + unregisterOpenPage: function PAC_unregisterOpenPage(aURI) + { + if (!this._databaseInitialized) { + let index = this._openPagesCache.indexOf(aURI.spec); + if (index != -1) { + this._openPagesCache.splice(index, 1); + } + return; + } + + let stmt = this._unregisterOpenPageQuery; + stmt.params.page_url = aURI.spec; + stmt.executeAsync(); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// mozIStorageStatementCallback + + handleResult: function PAC_handleResult(aResultSet) + { + let row, haveMatches = false; + while ((row = aResultSet.getNextRow())) { + let match = this._processRow(row); + haveMatches = haveMatches || match; + + if (this._result.matchCount == this._maxRichResults) { + // We have enough results, so stop running our search. + this._stopActiveQuery(); + + // And finish our search. + this._finishSearch(true); + return; + } + + } + + // Notify about results if we've gotten them. + if (haveMatches) { + this._notifyResults(true); + } + }, + + handleError: function PAC_handleError(aError) + { + Cu.reportError("Places AutoComplete: An async statement encountered an " + + "error: " + aError.result + ", '" + aError.message + "'"); + }, + + handleCompletion: function PAC_handleCompletion(aReason) + { + // If we have already finished our search, we should bail out early. + if (this.isSearchComplete()) { + return; + } + + // If we do not have enough results, and our match type is + // MATCH_BOUNDARY_ANYWHERE, search again with MATCH_ANYWHERE to get more + // results. + if (this._matchBehavior == MATCH_BOUNDARY_ANYWHERE && + this._result.matchCount < this._maxRichResults && !this._secondPass) { + this._secondPass = true; + let queries = [ + this._getBoundAdaptiveQuery(MATCH_ANYWHERE), + this._getBoundSearchQuery(MATCH_ANYWHERE, this._searchTokens), + ]; + this._executeQueries(queries); + return; + } + + this._finishSearch(true); + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsIObserver + + observe: function PAC_observe(aSubject, aTopic, aData) + { + if (aTopic == kTopicShutdown) { + Services.obs.removeObserver(this, kTopicShutdown); + + // Remove our preference observer. + this._prefs.removeObserver("", this); + delete this._prefs; + + // Finalize the statements that we have used. + let stmts = [ + "_defaultQuery", + "_historyQuery", + "_bookmarkQuery", + "_tagsQuery", + "_openPagesQuery", + "_typedQuery", + "_adaptiveQuery", + "_keywordQuery", + "_registerOpenPageQuery", + "_unregisterOpenPageQuery", + ]; + for (let i = 0; i < stmts.length; i++) { + // We do not want to create any query we haven't already created, so + // see if it is a getter first. + if (Object.getOwnPropertyDescriptor(this, stmts[i]).value !== undefined) { + this[stmts[i]].finalize(); + } + } + + if (this._databaseInitialized) { + this._db.asyncClose(); + } + } + else if (aTopic == kPrefChanged) { + // Avoid re-entrancy when flipping linked preferences. + if (this._ignoreNotifications) + return; + this._ignoreNotifications = true; + this._loadPrefs(false, aTopic, aData); + this._ignoreNotifications = false; + } + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsPlacesAutoComplete + + get _databaseInitialized() { + return Object.getOwnPropertyDescriptor(this, "_db").value !== undefined; + }, + + /** + * Generates the tokens used in searching from a given string. + * + * @param aSearchString + * The string to generate tokens from. + * @return an array of tokens. + */ + _getUnfilteredSearchTokens: function PAC_unfilteredSearchTokens(aSearchString) + { + // Calling split on an empty string will return an array containing one + // empty string. We don't want that, as it'll break our logic, so return an + // empty array then. + return aSearchString.length ? aSearchString.split(" ") : []; + }, + + /** + * Properly cleans up when searching is completed. + * + * @param aNotify + * Indicates if we should notify the AutoComplete listener about our + * results or not. + */ + _finishSearch: function PAC_finishSearch(aNotify) + { + // Notify about results if we are supposed to. + if (aNotify) { + this._notifyResults(false); + } + + // Clear our state + delete this._originalSearchString; + delete this._currentSearchString; + delete this._strippedPrefix; + delete this._searchTokens; + delete this._listener; + delete this._result; + delete this._usedPlaces; + delete this._pendingQuery; + this._secondPass = false; + this._enableActions = false; + }, + + /** + * Executes the given queries asynchronously. + * + * @param aQueries + * The queries to execute. + */ + _executeQueries: function PAC_executeQueries(aQueries) + { + // Because we might get a handleCompletion for canceled queries, we want to + // filter out queries we no longer care about (described in the + // handleCompletion implementation of AutoCompleteStatementCallbackWrapper). + + // Create our wrapper object and execute the queries. + let wrapper = new AutoCompleteStatementCallbackWrapper(this, this, this._db); + this._pendingQuery = wrapper.executeAsync(aQueries); + }, + + /** + * Stops executing our active query. + */ + _stopActiveQuery: function PAC_stopActiveQuery() + { + this._pendingQuery.cancel(); + delete this._pendingQuery; + }, + + /** + * Notifies the listener about results. + * + * @param aSearchOngoing + * Indicates if the search is ongoing or not. + */ + _notifyResults: function PAC_notifyResults(aSearchOngoing) + { + let result = this._result; + let resultCode = result.matchCount ? "RESULT_SUCCESS" : "RESULT_NOMATCH"; + if (aSearchOngoing) { + resultCode += "_ONGOING"; + } + result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]); + this._listener.onSearchResult(this, result); + }, + + /** + * Synchronize suggest.* prefs with autocomplete.enabled. + */ + _syncEnabledPref: function PAC_syncEnabledPref() + { + let suggestPrefs = ["suggest.history", "suggest.bookmark", "suggest.openpage"]; + let types = ["History", "Bookmark", "Openpage"]; + + this._enabled = safePrefGetter(this._prefs, kBrowserUrlbarAutocompleteEnabledPref, + true); + this._suggestHistory = safePrefGetter(this._prefs, "suggest.history", true); + this._suggestBookmark = safePrefGetter(this._prefs, "suggest.bookmark", true); + this._suggestOpenpage = safePrefGetter(this._prefs, "suggest.openpage", true); + + if (this._enabled) { + // If the autocomplete preference is active, activate all suggest + // preferences only if all of them are false. + if (types.every(type => this["_suggest" + type] == false)) { + for (let type of suggestPrefs) { + this._prefs.setBoolPref(type, true); + } + } + } else { + // If the preference was deactivated, deactivate all suggest preferences. + for (let type of suggestPrefs) { + this._prefs.setBoolPref(type, false); + } + } + }, + + /** + * Loads the preferences that we care about. + * + * @param [optional] aRegisterObserver + * Indicates if the preference observer should be added or not. The + * default value is false. + * @param [optional] aTopic + * Observer's topic, if any. + * @param [optional] aSubject + * Observer's subject, if any. + */ + _loadPrefs: function PAC_loadPrefs(aRegisterObserver, aTopic, aData) + { + // Avoid race conditions with UnifiedComplete component. + if (aData && !isUnifiedCompleteInstantiated()) { + // Synchronize suggest.* prefs with autocomplete.enabled. + if (aData == kBrowserUrlbarAutocompleteEnabledPref) { + this._syncEnabledPref(); + } else if (aData.startsWith("suggest.")) { + let suggestPrefs = ["suggest.history", "suggest.bookmark", "suggest.openpage"]; + this._prefs.setBoolPref(kBrowserUrlbarAutocompleteEnabledPref, + suggestPrefs.some(pref => safePrefGetter(this._prefs, pref, true))); + } + } + + this._enabled = safePrefGetter(this._prefs, + kBrowserUrlbarAutocompleteEnabledPref, + true); + this._matchBehavior = safePrefGetter(this._prefs, + "matchBehavior", + MATCH_BOUNDARY_ANYWHERE); + this._filterJavaScript = safePrefGetter(this._prefs, "filter.javascript", true); + this._maxRichResults = safePrefGetter(this._prefs, "maxRichResults", 25); + this._restrictHistoryToken = safePrefGetter(this._prefs, + "restrict.history", "^"); + this._restrictBookmarkToken = safePrefGetter(this._prefs, + "restrict.bookmark", "*"); + this._restrictTypedToken = safePrefGetter(this._prefs, "restrict.typed", "~"); + this._restrictTagToken = safePrefGetter(this._prefs, "restrict.tag", "+"); + this._restrictOpenPageToken = safePrefGetter(this._prefs, + "restrict.openpage", "%"); + this._matchTitleToken = safePrefGetter(this._prefs, "match.title", "#"); + this._matchURLToken = safePrefGetter(this._prefs, "match.url", "@"); + + this._suggestHistory = safePrefGetter(this._prefs, "suggest.history", true); + this._suggestBookmark = safePrefGetter(this._prefs, "suggest.bookmark", true); + this._suggestOpenpage = safePrefGetter(this._prefs, "suggest.openpage", true); + this._suggestTyped = safePrefGetter(this._prefs, "suggest.history.onlyTyped", false); + + // If history is not set, onlyTyped value should be ignored. + if (!this._suggestHistory) { + this._suggestTyped = false; + } + let types = ["History", "Bookmark", "Openpage", "Typed"]; + this._defaultBehavior = types.reduce((memo, type) => { + let prefValue = this["_suggest" + type]; + return memo | (prefValue && + Ci.mozIPlacesAutoComplete["BEHAVIOR_" + type.toUpperCase()]); + }, 0); + + // Further restrictions to apply for "empty searches" (i.e. searches for ""). + // The empty behavior is typed history, if history is enabled. Otherwise, + // it is bookmarks, if they are enabled. If both history and bookmarks are disabled, + // it defaults to open pages. + this._emptySearchDefaultBehavior = Ci.mozIPlacesAutoComplete.BEHAVIOR_RESTRICT; + if (this._suggestHistory) { + this._emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_HISTORY | + Ci.mozIPlacesAutoComplete.BEHAVIOR_TYPED; + } else if (this._suggestBookmark) { + this._emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_BOOKMARK; + } else { + this._emptySearchDefaultBehavior |= Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE; + } + + // Validate matchBehavior; default to MATCH_BOUNDARY_ANYWHERE. + if (this._matchBehavior != MATCH_ANYWHERE && + this._matchBehavior != MATCH_BOUNDARY && + this._matchBehavior != MATCH_BEGINNING) { + this._matchBehavior = MATCH_BOUNDARY_ANYWHERE; + } + // register observer + if (aRegisterObserver) { + this._prefs.addObserver("", this); + } + }, + + /** + * Given an array of tokens, this function determines which query should be + * ran. It also removes any special search tokens. + * + * @param aTokens + * An array of search tokens. + * @return an object with two properties: + * query: the correctly optimized, bound query to search the database + * with. + * tokens: the filtered list of tokens to search with. + */ + _getSearch: function PAC_getSearch(aTokens) + { + let foundToken = false; + let restrict = (behavior) => { + if (!foundToken) { + this._behavior = 0; + this._setBehavior("restrict"); + foundToken = true; + } + this._setBehavior(behavior); + }; + + // Set the proper behavior so our call to _getBoundSearchQuery gives us the + // correct query. + for (let i = aTokens.length - 1; i >= 0; i--) { + switch (aTokens[i]) { + case this._restrictHistoryToken: + restrict("history"); + break; + case this._restrictBookmarkToken: + restrict("bookmark"); + break; + case this._restrictTagToken: + restrict("tag"); + break; + case this._restrictOpenPageToken: + if (!this._enableActions) { + continue; + } + restrict("openpage"); + break; + case this._matchTitleToken: + restrict("title"); + break; + case this._matchURLToken: + restrict("url"); + break; + case this._restrictTypedToken: + restrict("typed"); + break; + default: + // We do not want to remove the token if we did not match. + continue; + } + + aTokens.splice(i, 1); + } + + // Set the right JavaScript behavior based on our preference. Note that the + // preference is whether or not we should filter JavaScript, and the + // behavior is if we should search it or not. + if (!this._filterJavaScript) { + this._setBehavior("javascript"); + } + + return { + query: this._getBoundSearchQuery(this._matchBehavior, aTokens), + tokens: aTokens + }; + }, + + /** + * @return a string consisting of the search query to be used based on the + * previously set urlbar suggestion preferences. + */ + _getSuggestionPrefQuery: function PAC_getSuggestionPrefQuery() + { + if (!this._hasBehavior("restrict") && this._hasBehavior("history") && + this._hasBehavior("bookmark")) { + return this._hasBehavior("typed") ? this._customQuery("AND h.typed = 1") + : this._defaultQuery; + } + let conditions = []; + if (this._hasBehavior("history")) { + // Enforce ignoring the visit_count index, since the frecency one is much + // faster in this case. ANALYZE helps the query planner to figure out the + // faster path, but it may not have up-to-date information yet. + conditions.push("+h.visit_count > 0"); + } + if (this._hasBehavior("typed")) { + conditions.push("h.typed = 1"); + } + if (this._hasBehavior("bookmark")) { + conditions.push("bookmarked"); + } + if (this._hasBehavior("tag")) { + conditions.push("tags NOTNULL"); + } + + return conditions.length ? this._customQuery("AND " + conditions.join(" AND ")) + : this._defaultQuery; + }, + + /** + * Obtains the search query to be used based on the previously set search + * behaviors (accessed by this._hasBehavior). The query is bound and ready to + * execute. + * + * @param aMatchBehavior + * How this query should match its tokens to the search string. + * @param aTokens + * An array of search tokens. + * @return the correctly optimized query to search the database with and the + * new list of tokens to search with. The query has all the needed + * parameters bound, so consumers can execute it without doing any + * additional work. + */ + _getBoundSearchQuery: function PAC_getBoundSearchQuery(aMatchBehavior, + aTokens) + { + let query = this._getSuggestionPrefQuery(); + + // Bind the needed parameters to the query so consumers can use it. + let params = query.params; + params.parent = PlacesUtils.tagsFolderId; + params.query_type = kQueryTypeFiltered; + params.matchBehavior = aMatchBehavior; + params.searchBehavior = this._behavior; + + // We only want to search the tokens that we are left with - not the + // original search string. + params.searchString = aTokens.join(" "); + + // Limit the query to the the maximum number of desired results. + // This way we can avoid doing more work than needed. + params.maxResults = this._maxRichResults; + + return query; + }, + + _getBoundOpenPagesQuery: function PAC_getBoundOpenPagesQuery(aTokens) + { + let query = this._openPagesQuery; + + // Bind the needed parameters to the query so consumers can use it. + let params = query.params; + params.query_type = kQueryTypeFiltered; + params.matchBehavior = this._matchBehavior; + params.searchBehavior = this._behavior; + + // We only want to search the tokens that we are left with - not the + // original search string. + params.searchString = aTokens.join(" "); + params.maxResults = this._maxRichResults; + + return query; + }, + + /** + * Obtains the keyword query with the properly bound parameters. + * + * @param aTokens + * The array of search tokens to check against. + * @return the bound keyword query. + */ + _getBoundKeywordQuery: function PAC_getBoundKeywordQuery(aTokens) + { + // The keyword is the first word in the search string, with the parameters + // following it. + let searchString = this._originalSearchString; + let queryString = ""; + let queryIndex = searchString.indexOf(" "); + if (queryIndex != -1) { + queryString = searchString.substring(queryIndex + 1); + } + // We need to escape the parameters as if they were the query in a URL + queryString = encodeURIComponent(queryString).replace(/%20/g, "+"); + + // The first word could be a keyword, so that's what we'll search. + let keyword = aTokens[0]; + + let query = this._keywordQuery; + let params = query.params; + params.keyword = keyword; + params.query_string = queryString; + params.query_type = kQueryTypeKeyword; + + return query; + }, + + /** + * Obtains the adaptive query with the properly bound parameters. + * + * @return the bound adaptive query. + */ + _getBoundAdaptiveQuery: function PAC_getBoundAdaptiveQuery(aMatchBehavior) + { + // If we were not given a match behavior, use the stored match behavior. + if (arguments.length == 0) { + aMatchBehavior = this._matchBehavior; + } + + let query = this._adaptiveQuery; + let params = query.params; + params.parent = PlacesUtils.tagsFolderId; + params.search_string = this._currentSearchString; + params.query_type = kQueryTypeFiltered; + params.matchBehavior = aMatchBehavior; + params.searchBehavior = this._behavior; + + return query; + }, + + /** + * Processes a mozIStorageRow to generate the proper data for the AutoComplete + * result. This will add an entry to the current result if it matches the + * criteria. + * + * @param aRow + * The row to process. + * @return true if the row is accepted, and false if not. + */ + _processRow: function PAC_processRow(aRow) + { + // Before we do any work, make sure this entry isn't already in our results. + let entryId = aRow.getResultByIndex(kQueryIndexPlaceId); + let escapedEntryURL = aRow.getResultByIndex(kQueryIndexURL); + let openPageCount = aRow.getResultByIndex(kQueryIndexOpenPageCount) || 0; + + // If actions are enabled and the page is open, add only the switch-to-tab + // result. Otherwise, add the normal result. + let [url, action] = this._enableActions && openPageCount > 0 && this._hasBehavior("openpage") ? + ["moz-action:switchtab," + escapedEntryURL, "action "] : + [escapedEntryURL, ""]; + + if (this._inResults(entryId, url)) { + return false; + } + + let entryTitle = aRow.getResultByIndex(kQueryIndexTitle) || ""; + let entryBookmarked = aRow.getResultByIndex(kQueryIndexBookmarked); + let entryBookmarkTitle = entryBookmarked ? + aRow.getResultByIndex(kQueryIndexBookmarkTitle) : null; + let entryTags = aRow.getResultByIndex(kQueryIndexTags) || ""; + + // Always prefer the bookmark title unless it is empty + let title = entryBookmarkTitle || entryTitle; + + let style; + if (aRow.getResultByIndex(kQueryIndexQueryType) == kQueryTypeKeyword) { + style = "keyword"; + title = NetUtil.newURI(escapedEntryURL).host; + } + + // We will always prefer to show tags if we have them. + let showTags = !!entryTags; + + // However, we'll act as if a page is not bookmarked if the user wants + // only history and not bookmarks and there are no tags. + if (this._hasBehavior("history") && !this._hasBehavior("bookmark") && + !showTags) { + showTags = false; + style = "favicon"; + } + + // If we have tags and should show them, we need to add them to the title. + if (showTags) { + title += kTitleTagsSeparator + entryTags; + } + // We have to determine the right style to display. Tags show the tag icon, + // bookmarks get the bookmark icon, and keywords get the keyword icon. If + // the result does not fall into any of those, it just gets the favicon. + if (!style) { + // It is possible that we already have a style set (from a keyword + // search or because of the user's preferences), so only set it if we + // haven't already done so. + if (showTags) { + style = "tag"; + } + else if (entryBookmarked) { + style = "bookmark"; + } + else { + style = "favicon"; + } + } + + this._addToResults(entryId, url, title, action + style); + return true; + }, + + /** + * Checks to see if the given place has already been added to the results. + * + * @param aPlaceId + * The place id to check for, may be null. + * @param aUrl + * The url to check for. + * @return true if the place has been added, false otherwise. + * + * @note Must check both the id and the url for a negative match, since + * autocomplete may run in the middle of a new page addition. In such + * a case the switch-to-tab query would hash the page by url, then a + * next query, running after the page addition, would hash it by id. + * It's not possible to just rely on url though, since keywords + * dynamically modify the url to include their search string. + */ + _inResults: function PAC_inResults(aPlaceId, aUrl) + { + if (aPlaceId && aPlaceId in this._usedPlaces) { + return true; + } + return aUrl in this._usedPlaces; + }, + + /** + * Adds a result to the AutoComplete results. Also tracks that we've added + * this place_id into the result set. + * + * @param aPlaceId + * The place_id of the item to be added to the result set. This is + * used by _inResults. + * @param aURISpec + * The URI spec for the entry. + * @param aTitle + * The title to give the entry. + * @param aStyle + * Indicates how the entry should be styled when displayed. + */ + _addToResults: function PAC_addToResults(aPlaceId, aURISpec, aTitle, + aStyle) + { + // Add this to our internal tracker to ensure duplicates do not end up in + // the result. _usedPlaces is an Object that is being used as a set. + // Not all entries have a place id, thus we fallback to the url for them. + // We cannot use only the url since keywords entries are modified to + // include the search string, and would be returned multiple times. Ids + // are faster too. + this._usedPlaces[aPlaceId || aURISpec] = true; + + // Obtain the favicon for this URI. + let favicon = "page-icon:" + aURISpec; + this._result.appendMatch(aURISpec, aTitle, favicon, aStyle); + }, + + /** + * Determines if the specified AutoComplete behavior is set. + * + * @param aType + * The behavior type to test for. + * @return true if the behavior is set, false otherwise. + */ + _hasBehavior: function PAC_hasBehavior(aType) + { + let behavior = Ci.mozIPlacesAutoComplete["BEHAVIOR_" + aType.toUpperCase()]; + + if (this._disablePrivateActions && + behavior == Ci.mozIPlacesAutoComplete.BEHAVIOR_OPENPAGE) { + return false; + } + + return this._behavior & behavior; + }, + + /** + * Enables the desired AutoComplete behavior. + * + * @param aType + * The behavior type to set. + */ + _setBehavior: function PAC_setBehavior(aType) + { + this._behavior |= + Ci.mozIPlacesAutoComplete["BEHAVIOR_" + aType.toUpperCase()]; + }, + + /** + * Determines if we are done searching or not. + * + * @return true if we have completed searching, false otherwise. + */ + isSearchComplete: function PAC_isSearchComplete() + { + // If _pendingQuery is null, we should no longer do any work since we have + // already called _finishSearch. This means we completed our search. + return this._pendingQuery == null; + }, + + /** + * Determines if the given handle of a pending statement is a pending search + * or not. + * + * @param aHandle + * A mozIStoragePendingStatement to check and see if we are waiting for + * results from it still. + * @return true if it is a pending query, false otherwise. + */ + isPendingSearch: function PAC_isPendingSearch(aHandle) + { + return this._pendingQuery == aHandle; + }, + + ////////////////////////////////////////////////////////////////////////////// + //// nsISupports + + classID: Components.ID("d0272978-beab-4adc-a3d4-04b76acfa4e7"), + + _xpcom_factory: XPCOMUtils.generateSingletonFactory(nsPlacesAutoComplete), + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIAutoCompleteSearch, + Ci.nsIAutoCompleteSimpleResultListener, + Ci.mozIPlacesAutoComplete, + Ci.mozIStorageStatementCallback, + Ci.nsIObserver, + Ci.nsISupportsWeakReference, + ]) +}; + +var components = [nsPlacesAutoComplete]; +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components); diff --git a/comm/suite/components/places/nsPlacesAutoComplete.manifest b/comm/suite/components/places/nsPlacesAutoComplete.manifest new file mode 100644 index 0000000000..77dc732af2 --- /dev/null +++ b/comm/suite/components/places/nsPlacesAutoComplete.manifest @@ -0,0 +1,3 @@ +component {d0272978-beab-4adc-a3d4-04b76acfa4e7} nsPlacesAutoComplete.js +contract @mozilla.org/autocomplete/search;1?name=history {d0272978-beab-4adc-a3d4-04b76acfa4e7} + diff --git a/comm/suite/components/places/tests/autocomplete/head_autocomplete.js b/comm/suite/components/places/tests/autocomplete/head_autocomplete.js new file mode 100644 index 0000000000..bccc7f56d2 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/head_autocomplete.js @@ -0,0 +1,307 @@ +/* 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 {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +// Import common head. +{ + let commonFile = do_get_file("../head_common.js", false); + let uri = Services.io.newFileURI(commonFile); + Services.scriptloader.loadSubScript(uri.spec, this); +} + +// Put any other stuff relative to this test folder below. + + +/** + * Header file for autocomplete testcases that create a set of pages with uris, + * titles, tags and tests that a given search term matches certain pages. + */ + +var current_test = 0; + +function AutoCompleteInput(aSearches) { + this.searches = aSearches; +} +AutoCompleteInput.prototype = { + timeout: 10, + textValue: "", + searches: null, + searchParam: "", + popupOpen: false, + minResultsForPopup: 0, + invalidate: function() {}, + disableAutoComplete: false, + completeDefaultIndex: false, + get popup() { return this; }, + onSearchBegin: function() {}, + onSearchComplete: function() {}, + setSelectedIndex: function() {}, + get searchCount() { return this.searches.length; }, + getSearchAt: function(aIndex) { return this.searches[aIndex]; }, + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIAutoCompleteInput, + Ci.nsIAutoCompletePopup, + ]) +}; + +function toURI(aSpec) { + return uri(aSpec); +} + +var appendTags = true; +// Helper to turn off tag matching in results +function ignoreTags() +{ + print("Ignoring tags from results"); + appendTags = false; +} + +function ensure_results(aSearch, aExpected) +{ + let controller = Cc["@mozilla.org/autocomplete/controller;1"]. + getService(Ci.nsIAutoCompleteController); + + // Make an AutoCompleteInput that uses our searches + // and confirms results on search complete + let input = new AutoCompleteInput(["history"]); + + controller.input = input; + + if (typeof kSearchParam == "string") + input.searchParam = kSearchParam; + + let numSearchesStarted = 0; + input.onSearchBegin = function() { + numSearchesStarted++; + Assert.equal(numSearchesStarted, 1); + }; + + input.onSearchComplete = function() { + Assert.equal(numSearchesStarted, 1); + aExpected = aExpected.slice(); + + // Check to see the expected uris and titles match up (in any order) + for (let i = 0; i < controller.matchCount; i++) { + let value = controller.getValueAt(i); + let comment = controller.getCommentAt(i); + + print("Looking for '" + value + "', '" + comment + "' in expected results..."); + let j; + for (j = 0; j < aExpected.length; j++) { + // Skip processed expected results + if (aExpected[j] == undefined) + continue; + + let [uri, title, tags] = gPages[aExpected[j]]; + + // Load the real uri and titles and tags if necessary + uri = toURI(kURIs[uri]).spec; + title = kTitles[title]; + if (tags && appendTags) + title += " \u2013 " + tags.map(aTag => kTitles[aTag]); + print("Checking against expected '" + uri + "', '" + title + "'..."); + + // Got a match on both uri and title? + if (uri == value && title == comment) { + print("Got it at index " + j + "!!"); + // Make it undefined so we don't process it again + aExpected[j] = undefined; + break; + } + } + + // We didn't hit the break, so we must have not found it + if (j == aExpected.length) + do_throw("Didn't find the current result ('" + value + "', '" + comment + "') in expected: " + aExpected); + } + + // Make sure we have the right number of results + print("Expecting " + aExpected.length + " results; got " + + controller.matchCount + " results"); + Assert.equal(controller.matchCount, aExpected.length); + + // If we expect results, make sure we got matches + Assert.equal(controller.searchStatus, aExpected.length ? + Ci.nsIAutoCompleteController.STATUS_COMPLETE_MATCH : + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH); + + // Fetch the next test if we have more + if (++current_test < gTests.length) + run_test(); + + do_test_finished(); + }; + + print("Searching for.. '" + aSearch + "'"); + controller.startSearch(aSearch); +} + +// Get history services +var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); +var bhist = histsvc.QueryInterface(Ci.nsIBrowserHistory); +var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); +var tagsvc = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); +var prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefBranch); + +// Some date not too long ago +var gDate = new Date(Date.now() - 1000 * 60 * 60) * 1000; +// Store the page info for each uri +var gPages = []; + +// Initialization tasks to be run before the next test +var gNextTestSetupTasks = []; + +/** + * Adds a page, and creates various properties for it depending on the + * parameters passed in. This function will also add one visit, unless + * aNoVisit is true. + * + * @param aURI + * An index into kURIs that holds the string for the URI we are to add a + * page for. + * @param aTitle + * An index into kTitles that holds the string for the title we are to + * associate with the specified URI. + * @param aBook [optional] + * An index into kTitles that holds the string for the title we are to + * associate with the bookmark. If this is undefined, no bookmark is + * created. + * @param aTags [optional] + * An array of indexes into kTitles that hold the strings for the tags we + * are to associate with the URI. If this is undefined (or aBook is), no + * tags are added. + * @param aKey [optional] + * A string to associate as the keyword for this bookmark. aBook must be + * a valid index into kTitles for this to be checked and used. + * @param aTransitionType [optional] + * The transition type to use when adding the visit. The default is + * nsINavHistoryService::TRANSITION_LINK. + * @param aNoVisit [optional] + * If true, no visit is added for the URI. If false or undefined, a + * visit is added. + */ +function addPageBook(aURI, aTitle, aBook, aTags, aKey, aTransitionType, aNoVisit) +{ + gNextTestSetupTasks.push([task_addPageBook, arguments]); +} + +async function task_addPageBook(aURI, aTitle, aBook, aTags, aKey, aTransitionType, aNoVisit) +{ + // Add a page entry for the current uri + gPages[aURI] = [aURI, aBook != undefined ? aBook : aTitle, aTags]; + + let uri = toURI(kURIs[aURI]); + let title = kTitles[aTitle]; + + let out = [aURI, aTitle, aBook, aTags, aKey]; + out.push("\nuri=" + kURIs[aURI]); + out.push("\ntitle=" + title); + + // Add the page and a visit if we need to + if (!aNoVisit) { + await PlacesTestUtils.addVisits({ + uri: uri, + transition: aTransitionType || TRANSITION_LINK, + visitDate: gDate, + title: title + }); + out.push("\nwith visit"); + } + + // Add a bookmark if we need to + if (aBook != undefined) { + let book = kTitles[aBook]; + let bmid = bmsvc.insertBookmark(bmsvc.unfiledBookmarksFolder, uri, + bmsvc.DEFAULT_INDEX, book); + out.push("\nbook=" + book); + + // Add a keyword to the bookmark if we need to + if (aKey != undefined) + await PlacesUtils.keywords.insert({url: uri.spec, keyword: aKey}); + + // Add tags if we need to + if (aTags != undefined && aTags.length > 0) { + // Convert each tag index into the title + let tags = aTags.map(aTag => kTitles[aTag]); + tagsvc.tagURI(uri, tags); + out.push("\ntags=" + tags); + } + } + + print("\nAdding page/book/tag: " + out.join(", ")); +} + +function run_test() { + print("\n"); + // always search in history + bookmarks, no matter what the default is + prefs.setBoolPref("browser.urlbar.suggest.history", true); + prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + prefs.setBoolPref("browser.urlbar.suggest.openpage", true); + prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", false); + + // Search is asynchronous, so don't let the test finish immediately + do_test_pending(); + + // Load the test and print a description then run the test + let [description, search, expected, func] = gTests[current_test]; + print(description); + + // By default assume we want to match tags + appendTags = true; + + // Do an extra function if necessary + if (func) + func(); + + (async function() { + // Iterate over all tasks and execute them + for (let [fn, args] of gNextTestSetupTasks) { + await fn.apply(this, args); + } + + // Clean up to allow tests to register more functions. + gNextTestSetupTasks = []; + + // At this point frecency could still be updating due to latest pages + // updates. This is not a problem in real life, but autocomplete tests + // should return reliable resultsets, thus we have to wait. + await PlacesTestUtils.promiseAsyncUpdates(); + + })().then(() => ensure_results(search, expected), + do_report_unexpected_exception); +} + +// Utility function to remove history pages +function removePages(aURIs) +{ + gNextTestSetupTasks.push([do_removePages, arguments]); +} + +function do_removePages(aURIs) +{ + for (let uri of aURIs) + histsvc.removePage(toURI(kURIs[uri])); +} + +// Utility function to mark pages as typed +function markTyped(aURIs, aTitle) +{ + gNextTestSetupTasks.push([task_markTyped, arguments]); +} + +async function task_markTyped(aURIs, aTitle) +{ + for (let uri of aURIs) { + await PlacesTestUtils.addVisits({ + uri: toURI(kURIs[uri]), + transition: TRANSITION_TYPED, + title: kTitles[aTitle] + }); + } +} diff --git a/comm/suite/components/places/tests/autocomplete/test_416211.js b/comm/suite/components/places/tests/autocomplete/test_416211.js new file mode 100644 index 0000000000..8f662b5b13 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_416211.js @@ -0,0 +1,30 @@ +/* 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/. */ + +/* + * Test bug 416211 to make sure results that match the tag show the bookmark + * title instead of the page title. + */ + +var theTag = "superTag"; + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://theuri/", +]; +var kTitles = [ + "Page title", + "Bookmark title", + theTag, +]; + +// Add page with a title, bookmark, and [tags] +addPageBook(0, 0, 1, [2]); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Make sure the tag match gives the bookmark title", + theTag, [0]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_416214.js b/comm/suite/components/places/tests/autocomplete/test_416214.js new file mode 100644 index 0000000000..f891180069 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_416214.js @@ -0,0 +1,38 @@ +/* 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/. */ + +/* + * Test autocomplete for non-English URLs that match the tag bug 416214. Also + * test bug 417441 by making sure escaped ascii characters like "+" remain + * escaped. + * + * - add a visit for a page with a non-English URL + * - add a tag for the page + * - search for the tag + * - test number of matches (should be exactly one) + * - make sure the url is decoded + */ + +var theTag = "superTag"; + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://escaped/ユニコード", + "http://asciiescaped/blocking-firefox3%2B", +]; +var kTitles = [ + "title", + theTag, +]; + +// Add pages that match the tag +addPageBook(0, 0, 0, [1]); +addPageBook(1, 0, 0, [1]); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Make sure tag matches return the right url as well as '+' remain escaped", + theTag, [0, 1]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_417798.js b/comm/suite/components/places/tests/autocomplete/test_417798.js new file mode 100644 index 0000000000..6306c4125c --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_417798.js @@ -0,0 +1,36 @@ +/* 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/. */ + +/** + * Test for bug 417798 to make sure javascript: URIs don't show up unless the + * user searches for javascript: explicitly. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://abc/def", + "javascript:5", +]; +var kTitles = [ + "Title with javascript:", +]; + +addPageBook(0, 0); // regular url +// javascript: uri as bookmark (no visit) +addPageBook(1, 0, 0, undefined, undefined, undefined, true); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Match non-javascript: with plain search", + "a", [0]], + ["1: Match non-javascript: with almost javascript:", + "javascript", [0]], + ["2: Match javascript:", + "javascript:", [0, 1]], + ["3: Match nothing with non-first javascript:", + "5 javascript:", []], + ["4: Match javascript: with multi-word search", + "javascript: 5", [1]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_418257.js b/comm/suite/components/places/tests/autocomplete/test_418257.js new file mode 100644 index 0000000000..edff19d8b0 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_418257.js @@ -0,0 +1,43 @@ +/* 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/. */ + +/** + * Test bug 418257 by making sure tags are returned with the title as part of + * the "comment" if there are tags even if we didn't match in the tags. They + * are separated from the title by a endash. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://page1", + "http://page2", + "http://page3", + "http://page4", +]; +var kTitles = [ + "tag1", + "tag2", + "tag3", +]; + +// Add pages with varying number of tags +addPageBook(0, 0, 0, [0]); +addPageBook(1, 0, 0, [0, 1]); +addPageBook(2, 0, 0, [0, 2]); +addPageBook(3, 0, 0, [0, 1, 2]); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Make sure tags come back in the title when matching tags", + "page1 tag", [0]], + ["1: Check tags in title for page2", + "page2 tag", [1]], + ["2: Make sure tags appear even when not matching the tag", + "page3", [2]], + ["3: Multiple tags come in commas for page4", + "page4", [3]], + ["4: Extra test just to make sure we match the title", + "tag2", [1, 3]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_422277.js b/comm/suite/components/places/tests/autocomplete/test_422277.js new file mode 100644 index 0000000000..d6eb193dc8 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_422277.js @@ -0,0 +1,25 @@ +/* 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/. */ + +/** + * Test bug 422277 to make sure bad escaped uris don't get escaped. This makes + * sure we don't hit an assertion for "not a UTF8 string". + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://site/%EAid", +]; +var kTitles = [ + "title", +]; + +addPageBook(0, 0); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Bad escaped uri stays escaped", + "site", [0]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_autocomplete_on_value_removed_479089.js b/comm/suite/components/places/tests/autocomplete/test_autocomplete_on_value_removed_479089.js new file mode 100644 index 0000000000..5969fc9f5b --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_autocomplete_on_value_removed_479089.js @@ -0,0 +1,54 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/* + * Need to test that removing a page from autocomplete actually removes a page + * Description From Shawn Wilsher :sdwilsh 2009-02-18 11:29:06 PST + * We don't test the code path of onValueRemoved + * for the autocomplete implementation + * Bug 479089 + */ + +var histsvc = Cc["@mozilla.org/browser/nav-history-service;1"]. +getService(Ci.nsINavHistoryService); + +function run_test() +{ + run_next_test(); +} + +add_task(async function test_autocomplete_on_value_removed() +{ + // QI to nsIAutoCompleteSimpleResultListener + var listener = Cc["@mozilla.org/autocomplete/search;1?name=history"]. + getService(Ci.nsIAutoCompleteSimpleResultListener); + + // add history visit + var testUri = uri("http://foo.mozilla.com/"); + await PlacesTestUtils.addVisits({ + uri: testUri, + referrer: uri("http://mozilla.com/") + }); + // create a query object + var query = histsvc.getNewQuery(); + // create the options object we will never use + var options = histsvc.getNewQueryOptions(); + // look for this uri only + query.uri = testUri; + // execute + var queryRes = histsvc.executeQuery(query, options); + // open the result container + queryRes.root.containerOpen = true; + // debug queries + // dump_table("moz_places"); + Assert.equal(queryRes.root.childCount, 1); + // call the untested code path + listener.onValueRemoved(null, testUri.spec, true); + // make sure it is GONE from the DB + Assert.equal(queryRes.root.childCount, 0); + // close the container + queryRes.root.containerOpen = false; +}); diff --git a/comm/suite/components/places/tests/autocomplete/test_download_embed_bookmarks.js b/comm/suite/components/places/tests/autocomplete/test_download_embed_bookmarks.js new file mode 100644 index 0000000000..65bec60cc7 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_download_embed_bookmarks.js @@ -0,0 +1,53 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +/** + * Tests bug 449406 to ensure that TRANSITION_DOWNLOAD, TRANSITION_EMBED and + * TRANSITION_FRAMED_LINK bookmarked uri's show up in the location bar. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://download/bookmarked", + "http://embed/bookmarked", + "http://framed/bookmarked", + "http://download", + "http://embed", + "http://framed", +]; +var kTitles = [ + "download-bookmark", + "embed-bookmark", + "framed-bookmark", + "download2", + "embed2", + "framed2", +]; + +// Add download and embed uris +addPageBook(0, 0, 0, undefined, undefined, TRANSITION_DOWNLOAD); +addPageBook(1, 1, 1, undefined, undefined, TRANSITION_EMBED); +addPageBook(2, 2, 2, undefined, undefined, TRANSITION_FRAMED_LINK); +addPageBook(3, 3, undefined, undefined, undefined, TRANSITION_DOWNLOAD); +addPageBook(4, 4, undefined, undefined, undefined, TRANSITION_EMBED); +addPageBook(5, 5, undefined, undefined, undefined, TRANSITION_FRAMED_LINK); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Searching for bookmarked download uri matches", + kTitles[0], [0]], + ["1: Searching for bookmarked embed uri matches", + kTitles[1], [1]], + ["2: Searching for bookmarked framed uri matches", + kTitles[2], [2]], + ["3: Searching for download uri does not match", + kTitles[3], []], + ["4: Searching for embed uri does not match", + kTitles[4], []], + ["5: Searching for framed uri does not match", + kTitles[5], []], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_empty_search.js b/comm/suite/components/places/tests/autocomplete/test_empty_search.js new file mode 100644 index 0000000000..df8eac383a --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_empty_search.js @@ -0,0 +1,69 @@ +/* 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/. */ + +/** + * Test for bug 426864 that makes sure the empty search (drop down list) only + * shows typed pages from history. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://foo/0", + "http://foo/1", + "http://foo/2", + "http://foo/3", + "http://foo/4", + "http://foo/5", +]; +var kTitles = [ + "title", +]; + +// Visited (in history) +addPageBook(0, 0); // history +addPageBook(1, 0, 0); // bookmark +addPageBook(2, 0); // history typed +addPageBook(3, 0, 0); // bookmark typed + +// Unvisited bookmark +addPageBook(4, 0, 0); // bookmark +addPageBook(5, 0, 0); // bookmark typed + +// Set some pages as typed +markTyped([2, 3, 5], 0); +// Remove pages from history to treat them as unvisited +removePages([4, 5]); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Match everything", + "foo", [0, 1, 2, 3, 4, 5]], + ["1: Match only typed history", + "foo ^ ~", [2, 3]], + ["2: Drop-down empty search matches only typed history", + "", [2, 3]], + ["3: Drop-down empty search matches only bookmarks", + "", [2, 3], matchBookmarks], + ["4: Drop-down empty search matches only typed", + "", [2, 3], matchTyped], +]; + +function matchBookmarks() { + prefs.setBoolPref("browser.urlbar.suggest.history", false); + prefs.setBoolPref("browser.urlbar.suggest.bookmark", true); + clearPrefs(); +} + +function matchTyped() { + prefs.setBoolPref("browser.urlbar.suggest.history", true); + prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", true); + clearPrefs(); +} + +function clearPrefs() { + prefs.clearUserPref("browser.urlbar.suggest.history"); + prefs.clearUserPref("browser.urlbar.suggest.bookmark"); + prefs.clearUserPref("browser.urlbar.suggest.history.onlyTyped"); +} diff --git a/comm/suite/components/places/tests/autocomplete/test_enabled.js b/comm/suite/components/places/tests/autocomplete/test_enabled.js new file mode 100644 index 0000000000..a12242763f --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_enabled.js @@ -0,0 +1,69 @@ +/* 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/. */ + +/** + * Test for bug 471903 to make sure searching in autocomplete can be turned on + * and off. Also test bug 463535 for pref changing search. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://url/0", +]; +var kTitles = [ + "title", +]; + +addPageBook(0, 0); // visited page + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["1: plain search", + "url", [0]], + ["2: search disabled", + "url", [], () => setSearch(0)], + ["3: resume normal search", + "url", [0], () => setSearch(1)], +]; + +function setSearch(aSearch) { + prefs.setBoolPref("browser.urlbar.autocomplete.enabled", !!aSearch); +} + +add_task(async function test_sync_enabled() { + // Initialize autocomplete component. + Cc["@mozilla.org/autocomplete/search;1?name=history"] + .getService(Ci.mozIPlacesAutoComplete); + + let types = [ "history", "bookmark", "openpage" ]; + + // Test the service keeps browser.urlbar.autocomplete.enabled synchronized + // with browser.urlbar.suggest prefs. + for (let type of types) { + Services.prefs.setBoolPref("browser.urlbar.suggest." + type, true); + } + Assert.equal(Services.prefs.getBoolPref("browser.urlbar.autocomplete.enabled"), true); + + // Disable autocomplete and check all the suggest prefs are set to false. + Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", false); + for (let type of types) { + Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), false); + } + + // Setting even a single suggest pref to true should enable autocomplete. + Services.prefs.setBoolPref("browser.urlbar.suggest.history", true); + for (let type of types.filter(t => t != "history")) { + Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), false); + } + Assert.equal(Services.prefs.getBoolPref("browser.urlbar.autocomplete.enabled"), true); + + // Disable autocoplete again, then re-enable it and check suggest prefs + // have been reset. + Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", false); + Services.prefs.setBoolPref("browser.urlbar.autocomplete.enabled", true); + for (let type of types.filter(t => t != "history")) { + Assert.equal(Services.prefs.getBoolPref("browser.urlbar.suggest." + type), true); + } +}); diff --git a/comm/suite/components/places/tests/autocomplete/test_escape_self.js b/comm/suite/components/places/tests/autocomplete/test_escape_self.js new file mode 100644 index 0000000000..0b0918b8ff --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_escape_self.js @@ -0,0 +1,30 @@ +/* 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/. */ + +/** + * Test bug 422698 to make sure searches with urls from the location bar + * correctly match itself when it contains escaped characters. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://unescapeduri/", + "http://escapeduri/%40/", +]; +var kTitles = [ + "title", +]; + +// Add unescaped and escaped uris +addPageBook(0, 0); +addPageBook(1, 0); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Unescaped location matches itself", + kURIs[0], [0]], + ["1: Escaped location matches itself", + kURIs[1], [1]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_ignore_protocol.js b/comm/suite/components/places/tests/autocomplete/test_ignore_protocol.js new file mode 100644 index 0000000000..2ad63b735f --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_ignore_protocol.js @@ -0,0 +1,27 @@ +/* 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/. */ + +/** + * Test bug 424509 to make sure searching for "h" doesn't match "http" of urls. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://site/", + "http://happytimes/", +]; +var kTitles = [ + "title", +]; + +// Add site without "h" and with "h" +addPageBook(0, 0); +addPageBook(1, 0); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Searching for h matches site and not http://", + "h", [1]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_keyword_search.js b/comm/suite/components/places/tests/autocomplete/test_keyword_search.js new file mode 100644 index 0000000000..796f386bb8 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_keyword_search.js @@ -0,0 +1,73 @@ +/* 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/. */ + +/** + * Test for bug 392143 that puts keyword results into the autocomplete. Makes + * sure that multiple parameter queries get spaces converted to +, + converted + * to %2B, non-ascii become escaped, and pages in history that match the + * keyword uses the page's title. + * + * Also test for bug 249468 by making sure multiple keyword bookmarks with the + * same keyword appear in the list. + */ + +// Details for the keyword bookmark +var keyBase = "http://abc/?search="; +var keyKey = "key"; + +// A second keyword bookmark with the same keyword +var otherBase = "http://xyz/?foo="; + +var unescaped = "ユニコード"; +var pageInHistory = "ThisPageIsInHistory"; + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + keyBase + "%s", + keyBase + "term", + keyBase + "multi+word", + keyBase + "blocking%2B", + keyBase + unescaped, + keyBase + pageInHistory, + keyBase, + otherBase + "%s", + keyBase + "twoKey", + otherBase + "twoKey" +]; +var kTitles = [ + "Generic page title", + "Keyword title", + "abc", + "xyz" +]; + +// Add the keyword bookmark +addPageBook(0, 0, 1, [], keyKey); +// Add in the "fake pages" for keyword searches +gPages[1] = [1, 2]; +gPages[2] = [2, 2]; +gPages[3] = [3, 2]; +gPages[4] = [4, 2]; +// Add a page into history +addPageBook(5, 2); +gPages[6] = [6, 2]; + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Plain keyword query", + keyKey + " term", [1]], + ["1: Multi-word keyword query", + keyKey + " multi word", [2]], + ["2: Keyword query with +", + keyKey + " blocking+", [3]], + ["3: Unescaped term in query", + keyKey + " " + unescaped, [4]], + ["4: Keyword that happens to match a page", + keyKey + " " + pageInHistory, [5]], + ["5: Keyword without query (without space)", + keyKey, [6]], + ["6: Keyword without query (with space)", + keyKey + " ", [6]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_match_beginning.js b/comm/suite/components/places/tests/autocomplete/test_match_beginning.js new file mode 100644 index 0000000000..b9ba3ab39b --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_match_beginning.js @@ -0,0 +1,45 @@ +/* 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/. */ + +/** + * Test bug 451760 which allows matching only at the beginning of urls or + * titles to simulate Firefox 2 functionality. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://x.com/y", + "https://y.com/x", +]; +var kTitles = [ + "a b", + "b a", +]; + +addPageBook(0, 0); +addPageBook(1, 1); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + // Tests after this one will match at the beginning + ["0: Match at the beginning of titles", + "a", [0], + () => setBehavior(3)], + ["1: Match at the beginning of titles", + "b", [1]], + ["2: Match at the beginning of urls", + "x", [0]], + ["3: Match at the beginning of urls", + "y", [1]], + + // Tests after this one will match against word boundaries and anywhere + ["4: Sanity check that matching anywhere finds more", + "a", [0, 1], + () => setBehavior(1)], +]; + +function setBehavior(aType) { + prefs.setIntPref("browser.urlbar.matchBehavior", aType); +} diff --git a/comm/suite/components/places/tests/autocomplete/test_multi_word_search.js b/comm/suite/components/places/tests/autocomplete/test_multi_word_search.js new file mode 100644 index 0000000000..c0b896c868 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_multi_word_search.js @@ -0,0 +1,49 @@ +/* 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/. */ + +/** + * Test for bug 401869 to allow multiple words separated by spaces to match in + * the page title, page url, or bookmark title to be considered a match. All + * terms must match but not all terms need to be in the title, etc. + * + * Test bug 424216 by making sure bookmark titles are always shown if one is + * available. Also bug 425056 makes sure matches aren't found partially in the + * page title and partially in the bookmark. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://a.b.c/d-e_f/h/t/p", + "http://d.e.f/g-h_i/h/t/p", + "http://g.h.i/j-k_l/h/t/p", + "http://j.k.l/m-n_o/h/t/p", +]; +var kTitles = [ + "f(o)o b<a>r", + "b(a)r b<a>z", +]; + +// Regular pages +addPageBook(0, 0); +addPageBook(1, 1); +// Bookmarked pages +addPageBook(2, 0, 0); +addPageBook(3, 0, 1); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: Match 2 terms all in url", + "c d", [0]], + ["1: Match 1 term in url and 1 term in title", + "b e", [0, 1]], + ["2: Match 3 terms all in title; display bookmark title if matched", + "b a z", [1, 3]], + ["3: Match 2 terms in url and 1 in title; make sure bookmark title is used for search", + "k f t", [2]], + ["4: Match 3 terms in url and 1 in title", + "d i g z", [1]], + ["5: Match nothing", + "m o z i", []], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_special_search.js b/comm/suite/components/places/tests/autocomplete/test_special_search.js new file mode 100644 index 0000000000..78bf5a7d64 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_special_search.js @@ -0,0 +1,183 @@ +/* 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/. */ + +/** + * Test for bug 395161 that allows special searches that restrict results to + * history/bookmark/tagged items and title/url matches. + * + * Test 485122 by making sure results don't have tags when restricting result + * to just history either by default behavior or dynamic query restrict. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://url/", + "http://url/2", + "http://foo.bar/", + "http://foo.bar/2", + "http://url/star", + "http://url/star/2", + "http://foo.bar/star", + "http://foo.bar/star/2", + "http://url/tag", + "http://url/tag/2", + "http://foo.bar/tag", + "http://foo.bar/tag/2", +]; +var kTitles = [ + "title", + "foo.bar", +]; + +// Plain page visits +addPageBook(0, 0); // plain page +addPageBook(1, 1); // title +addPageBook(2, 0); // url +addPageBook(3, 1); // title and url + +// Bookmarked pages (no tag) +addPageBook(4, 0, 0); // bookmarked page +addPageBook(5, 1, 1); // title +addPageBook(6, 0, 0); // url +addPageBook(7, 1, 1); // title and url + +// Tagged pages +addPageBook(8, 0, 0, [1]); // tagged page +addPageBook(9, 1, 1, [1]); // title +addPageBook(10, 0, 0, [1]); // url +addPageBook(11, 1, 1, [1]); // title and url + +// Remove pages from history to treat them as unvisited, so pages that do have +// visits are 0,1,2,3,5,10 +removePages([4, 6, 7, 8, 9, 11]); +// Set some pages as typed +markTyped([0, 10], 0); +markTyped([3], 1); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + // Test restricting searches + ["0: History restrict", + "^", [0, 1, 2, 3, 5, 10]], + ["1: Star restrict", + "*", [4, 5, 6, 7, 8, 9, 10, 11]], + ["2: Tag restrict", + "+", [8, 9, 10, 11]], + + // Test specials as any word position + ["3: Special as first word", + "^ foo bar", [1, 2, 3, 5, 10]], + ["4: Special as middle word", + "foo ^ bar", [1, 2, 3, 5, 10]], + ["5: Special as last word", + "foo bar ^", [1, 2, 3, 5, 10]], + + // Test restricting and matching searches with a term + ["6.1: foo ^ -> history", + "foo ^", [1, 2, 3, 5, 10]], + ["6.2: foo | -> history (change pref)", + "foo |", [1, 2, 3, 5, 10], () => changeRestrict("history", "|")], + ["7.1: foo * -> is star", + "foo *", [5, 6, 7, 8, 9, 10, 11], () => resetRestrict("history")], + ["7.2: foo | -> is star (change pref)", + "foo |", [5, 6, 7, 8, 9, 10, 11], () => changeRestrict("bookmark", "|")], + ["8.1: foo # -> in title", + "foo #", [1, 3, 5, 7, 8, 9, 10, 11], () => resetRestrict("bookmark")], + ["8.2: foo | -> in title (change pref)", + "foo |", [1, 3, 5, 7, 8, 9, 10, 11], () => changeRestrict("title", "|")], + ["9.1: foo @ -> in url", + "foo @", [2, 3, 6, 7, 10, 11], () => resetRestrict("title")], + ["9.2: foo | -> in url (change pref)", + "foo |", [2, 3, 6, 7, 10, 11], () => changeRestrict("url", "|")], + ["10: foo + -> is tag", + "foo +", [8, 9, 10, 11], () => resetRestrict("url")], + ["10.2: foo | -> is tag (change pref)", + "foo |", [8, 9, 10, 11], () => changeRestrict("tag", "|")], + ["10.3: foo ~ -> is typed", + "foo ~", [3, 10], () => resetRestrict("tag")], + ["10.4: foo | -> is typed (change pref)", + "foo |", [3, 10], () => changeRestrict("typed", "|")], + + // Test various pairs of special searches + ["11: foo ^ * -> history, is star", + "foo ^ *", [5, 10], () => resetRestrict("typed")], + ["12: foo ^ # -> history, in title", + "foo ^ #", [1, 3, 5, 10]], + ["13: foo ^ @ -> history, in url", + "foo ^ @", [2, 3, 10]], + ["14: foo ^ + -> history, is tag", + "foo ^ +", [10]], + ["14.1: foo ^ ~ -> history, is typed", + "foo ^ ~", [3, 10]], + ["15: foo * # -> is star, in title", + "foo * #", [5, 7, 8, 9, 10, 11]], + ["16: foo * @ -> is star, in url", + "foo * @", [6, 7, 10, 11]], + ["17: foo * + -> same as +", + "foo * +", [8, 9, 10, 11]], + ["17.1: foo * ~ -> is star, is typed", + "foo * ~", [10]], + ["18: foo # @ -> in title, in url", + "foo # @", [3, 7, 10, 11]], + ["19: foo # + -> in title, is tag", + "foo # +", [8, 9, 10, 11]], + ["19.1: foo # ~ -> in title, is typed", + "foo # ~", [3, 10]], + ["20: foo @ + -> in url, is tag", + "foo @ +", [10, 11]], + ["20.1: foo @ ~ -> in url, is typed", + "foo @ ~", [3, 10]], + ["20.2: foo + ~ -> is tag, is typed", + "foo + ~", [10]], + + // Test default usage by setting certain bits of default.behavior to 1 + ["21: foo -> default history", + "foo", [1, 2, 3, 5, 10], function () { setPref({ history: true }); }], + ["22: foo -> default history or is star", + "foo", [1, 2, 3, 5, 6, 7, 8, 9, 10, 11], () => setPref({ history: true, bookmark: true })], + ["22.1: foo -> default history or is star, is typed", + "foo", [3, 10], () => setPref({ history: true, bookmark: true, "history.onlyTyped": true })], + +]; + +function setPref(aTypes) { + clearSuggestPrefs(); + for (let type in aTypes) { + prefs.setBoolPref("browser.urlbar.suggest." + type, aTypes[type]); + } +} + +function clearSuggestPrefs() { + prefs.setBoolPref("browser.urlbar.suggest.history", false); + prefs.setBoolPref("browser.urlbar.suggest.bookmark", false); + prefs.setBoolPref("browser.urlbar.suggest.history.onlyTyped", false); + prefs.setBoolPref("browser.urlbar.suggest.openpage", false); +} + +function changeRestrict(aType, aChar) +{ + let branch = "browser.urlbar."; + // "title" and "url" are different from everything else, so special case them. + if (aType == "title" || aType == "url") + branch += "match."; + else + branch += "restrict."; + + print("changing restrict for " + aType + " to '" + aChar + "'"); + prefs.setCharPref(branch + aType, aChar); +} + +function resetRestrict(aType) +{ + let branch = "browser.urlbar."; + // "title" and "url" are different from everything else, so special case them. + if (aType == "title" || aType == "url") + branch += "match."; + else + branch += "restrict."; + + if (prefs.prefHasUserValue(branch + aType)) + prefs.clearUserPref(branch + aType); +} diff --git a/comm/suite/components/places/tests/autocomplete/test_swap_protocol.js b/comm/suite/components/places/tests/autocomplete/test_swap_protocol.js new file mode 100644 index 0000000000..860b722498 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_swap_protocol.js @@ -0,0 +1,63 @@ +/* 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/. */ + +/** + * Test bug 424717 to make sure searching with an existing location like + * http://site/ also matches https://site/ or ftp://site/. Same thing for + * ftp://site/ and https://site/. + * + * Test bug 461483 to make sure a search for "w" doesn't match the "www." from + * site subdomains. + */ + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://www.site/", + "http://site/", + "ftp://ftp.site/", + "ftp://site/", + "https://www.site/", + "https://site/", + "http://woohoo/", + "http://wwwwwwacko/", +]; +var kTitles = [ + "title", +]; + +// Add various protocols of site +addPageBook(0, 0); +addPageBook(1, 0); +addPageBook(2, 0); +addPageBook(3, 0); +addPageBook(4, 0); +addPageBook(5, 0); +addPageBook(6, 0); +addPageBook(7, 0); + +var allSite = [0, 1, 2, 3, 4, 5]; + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + ["0: http://www.site matches all site", "http://www.site", allSite], + ["1: http://site matches all site", "http://site", allSite], + ["2: ftp://ftp.site matches itself", "ftp://ftp.site", [2]], + ["3: ftp://site matches all site", "ftp://site", allSite], + ["4: https://www.site matches all site", "https://www.site", allSite], + ["5: https://site matches all site", "https://site", allSite], + ["6: www.site matches all site", "www.site", allSite], + + ["7: w matches none of www.", "w", [6, 7]], + ["8: http://w matches none of www.", "w", [6, 7]], + ["9: http://www.w matches none of www.", "w", [6, 7]], + + ["10: ww matches none of www.", "ww", [7]], + ["11: http://ww matches none of www.", "http://ww", [7]], + ["12: http://www.ww matches none of www.", "http://www.ww", [7]], + + ["13: www matches none of www.", "www", [7]], + ["14: http://www matches none of www.", "http://www", [7]], + ["15: http://www.www matches none of www.", "http://www.www", [7]], +]; diff --git a/comm/suite/components/places/tests/autocomplete/test_tabmatches.js b/comm/suite/components/places/tests/autocomplete/test_tabmatches.js new file mode 100644 index 0000000000..22f83a86e6 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_tabmatches.js @@ -0,0 +1,97 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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 gTabRestrictChar = "%"; +prefs.setCharPref("browser.urlbar.restrict.openpage", gTabRestrictChar); +registerCleanupFunction(() => { + prefs.clearUserPref("browser.urlbar.restrict.openpage"); +}); + +var kSearchParam = "enable-actions"; + +var kURIs = [ + "http://abc.com/", + "moz-action:switchtab,http://abc.com/", + "http://xyz.net/", + "moz-action:switchtab,http://xyz.net/", + "about:mozilla", + "moz-action:switchtab,about:mozilla", + "data:text/html,test", + "moz-action:switchtab,data:text/html,test" +]; + +var kTitles = [ + "ABC rocks", + "xyz.net - we're better than ABC", + "about:mozilla", + "data:text/html,test" +]; + +addPageBook(0, 0); +gPages[1] = [1, 0]; +addPageBook(2, 1); +gPages[3] = [3, 1]; + +addOpenPages(0, 1); + +// PAges that cannot be registered in history. +addOpenPages(4, 1); +gPages[5] = [5, 2]; +addOpenPages(6, 1); +gPages[7] = [7, 3]; + +var gTests = [ + ["0: single result, that is also a tab match", + "abc.com", [1]], + ["1: two results, one tab match", + "abc", [1, 2]], + ["2: two results, both tab matches", + "abc", [1, 3], + function() { + addOpenPages(2, 1); + }], + ["3: two results, both tab matches, one has multiple tabs", + "abc", [1, 3], + function() { + addOpenPages(2, 5); + }], + ["4: two results, no tab matches", + "abc", [0, 2], + function() { + removeOpenPages(0, 1); + removeOpenPages(2, 6); + }], + ["5: tab match search with restriction character", + gTabRestrictChar + " abc", [1], + function() { + addOpenPages(0, 1); + }], + ["6: tab match with not-addable pages", + "mozilla", [5]], + ["7: tab match with not-addable pages and restriction character", + gTabRestrictChar + " mozilla", [5]], + ["8: tab match with not-addable pages and only restriction character", + gTabRestrictChar, [1, 5, 7]], +]; + + +function addOpenPages(aUri, aCount) { + let num = aCount || 1; + let acprovider = Cc["@mozilla.org/autocomplete/search;1?name=history"]. + getService(Ci.mozIPlacesAutoComplete); + for (let i = 0; i < num; i++) { + acprovider.registerOpenPage(toURI(kURIs[aUri])); + } +} + +function removeOpenPages(aUri, aCount) { + let num = aCount || 1; + let acprovider = Cc["@mozilla.org/autocomplete/search;1?name=history"]. + getService(Ci.mozIPlacesAutoComplete); + for (let i = 0; i < num; i++) { + acprovider.unregisterOpenPage(toURI(kURIs[aUri])); + } +} diff --git a/comm/suite/components/places/tests/autocomplete/test_word_boundary_search.js b/comm/suite/components/places/tests/autocomplete/test_word_boundary_search.js new file mode 100644 index 0000000000..b4ae368491 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/test_word_boundary_search.js @@ -0,0 +1,105 @@ +/* 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/. */ + +/** + * Test bug 393678 to make sure matches against the url, title, tags are only + * made on word boundaries instead of in the middle of words. + * + * Make sure we don't try matching one after a CamelCase because the upper-case + * isn't really a word boundary. (bug 429498) + * + * Bug 429531 provides switching between "must match on word boundary" and "can + * match," so leverage "must match" pref for checking word boundary logic and + * make sure "can match" matches anywhere. + */ + +var katakana = ["\u30a8", "\u30c9"]; // E, Do +var ideograph = ["\u4efb", "\u5929", "\u5802"]; // Nin Ten Do + +// Define some shared uris and titles (each page needs its own uri) +var kURIs = [ + "http://matchme/", + "http://dontmatchme/", + "http://title/1", + "http://title/2", + "http://tag/1", + "http://tag/2", + "http://crazytitle/", + "http://katakana/", + "http://ideograph/", + "http://camel/pleaseMatchMe/", +]; +var kTitles = [ + "title1", + "matchme2", + "dontmatchme3", + "!@#$%^&*()_+{}|:<>?word", + katakana.join(""), + ideograph.join(""), +]; + +// Boundaries on the url +addPageBook(0, 0); +addPageBook(1, 0); +// Boundaries on the title +addPageBook(2, 1); +addPageBook(3, 2); +// Boundaries on the tag +addPageBook(4, 0, 0, [1]); +addPageBook(5, 0, 0, [2]); +// Lots of word boundaries before a word +addPageBook(6, 3); +// Katakana +addPageBook(7, 4); +// Ideograph +addPageBook(8, 5); +// CamelCase +addPageBook(9, 0); + +// Provide for each test: description; search terms; array of gPages indices of +// pages that should match; optional function to be run before the test +var gTests = [ + // Tests after this one will match only on word boundaries + ["0: Match 'match' at the beginning or after / or on a CamelCase", + "match", [0, 2, 4, 9], + () => setBehavior(2)], + ["1: Match 'dont' at the beginning or after /", + "dont", [1, 3, 5]], + ["2: Match '2' after the slash and after a word (in tags too)", + "2", [2, 3, 4, 5]], + ["3: Match 't' at the beginning or after /", + "t", [0, 1, 2, 3, 4, 5, 9]], + ["4: Match 'word' after many consecutive word boundaries", + "word", [6]], + ["5: Match a word boundary '/' for everything", + "/", [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]], + ["6: Match word boundaries '()_+' that are among word boundaries", + "()_+", [6]], + + ["7: Katakana characters form a string, so match the beginning", + katakana[0], [7]], + /*["8: Middle of a katakana word shouldn't be matched", + katakana[1], []],*/ + + ["9: Ideographs are treated as words so 'nin' is one word", + ideograph[0], [8]], + ["10: Ideographs are treated as words so 'ten' is another word", + ideograph[1], [8]], + ["11: Ideographs are treated as words so 'do' is yet another", + ideograph[2], [8]], + + ["12: Extra negative assert that we don't match in the middle", + "ch", []], + ["13: Don't match one character after a camel-case word boundary (bug 429498)", + "atch", []], + + // Tests after this one will match against word boundaries and anywhere + ["14: Match on word boundaries as well as anywhere (bug 429531)", + "tch", [0, 1, 2, 3, 4, 5, 9], + () => setBehavior(1)], +]; + +function setBehavior(aType) { + prefs.setIntPref("browser.urlbar.matchBehavior", aType); +} diff --git a/comm/suite/components/places/tests/autocomplete/xpcshell.ini b/comm/suite/components/places/tests/autocomplete/xpcshell.ini new file mode 100644 index 0000000000..7c018dbcc7 --- /dev/null +++ b/comm/suite/components/places/tests/autocomplete/xpcshell.ini @@ -0,0 +1,29 @@ +[DEFAULT] +head = head_autocomplete.js +tail = +skip-if = toolkit == 'android' || toolkit == 'gonk' + +[test_416211.js] +[test_416214.js] +[test_417798.js] +[test_418257.js] +[test_422277.js] +[test_autocomplete_on_value_removed_479089.js] +# Bug 676989: test fails consistently on Android +fail-if = os == "android" +[test_download_embed_bookmarks.js] +# Bug 676989: test fails consistently on Android +fail-if = os == "android" +[test_empty_search.js] +# Bug 676989: test fails consistently on Android +fail-if = os == "android" +[test_enabled.js] +[test_escape_self.js] +[test_ignore_protocol.js] +[test_keyword_search.js] +[test_match_beginning.js] +[test_multi_word_search.js] +[test_special_search.js] +[test_swap_protocol.js] +[test_tabmatches.js] +[test_word_boundary_search.js] diff --git a/comm/suite/components/places/tests/browser/browser.ini b/comm/suite/components/places/tests/browser/browser.ini new file mode 100644 index 0000000000..f21d4b73a2 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser.ini @@ -0,0 +1,12 @@ +[DEFAULT] +support-files = head.js + +[browser_0_library_left_pane_migration.js] +[browser_425884.js] +[browser_drag_bookmarks_on_toolbar.js] +[browser_library_infoBox.js] +[browser_library_left_pane_commands.js] +[browser_library_left_pane_fixnames.js] +[browser_library_open_leak.js] +[browser_library_views_liveupdate.js] +[browser_sort_in_library.js] diff --git a/comm/suite/components/places/tests/browser/browser_0_library_left_pane_migration.js b/comm/suite/components/places/tests/browser/browser_0_library_left_pane_migration.js new file mode 100644 index 0000000000..1db96f5000 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_0_library_left_pane_migration.js @@ -0,0 +1,93 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test we correctly migrate Library left pane to the latest version. + * Note: this test MUST be the first between browser chrome tests, or results + * of next tests could be unexpected due to PlacesUIUtils getters. + */ + +const TEST_URI = "http://www.mozilla.org/"; + +function onLibraryReady(organizer) { + // Check left pane. + ok(PlacesUIUtils.leftPaneFolderId > 0, + "Left pane folder correctly created"); + var leftPaneItems = + PlacesUtils.annotations + .getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(leftPaneItems.length, 1, + "We correctly have only 1 left pane folder"); + var leftPaneRoot = leftPaneItems[0]; + is(leftPaneRoot, PlacesUIUtils.leftPaneFolderId, + "leftPaneFolderId getter has correct value"); + // Check version has been upgraded. + var version = + PlacesUtils.annotations.getItemAnnotation(leftPaneRoot, + PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(version, PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION, + "Left pane version has been correctly upgraded"); + + // Check left pane is populated. + organizer.PlacesOrganizer.selectLeftPaneQuery('AllBookmarks'); + is(organizer.PlacesOrganizer._places.selectedNode.itemId, + PlacesUIUtils.leftPaneQueries["AllBookmarks"], + "Library left pane is populated and working"); + + // Close Library window. + organizer.close(); + // No need to cleanup anything, we have a correct left pane now. + finish(); +} + +function test() { + waitForExplicitFinish(); + // Sanity checks. + ok(PlacesUtils, "PlacesUtils is running in chrome context"); + ok(PlacesUIUtils, "PlacesUIUtils is running in chrome context"); + ok(PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION > 0, + "Left pane version in chrome context, current version is: " + PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION ); + + // Check if we have any left pane folder already set, remove it eventually. + var leftPaneItems = PlacesUtils.annotations + .getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + if (leftPaneItems.length > 0) { + // The left pane has already been created, touching it now would cause + // next tests to rely on wrong values (and possibly crash) + is(leftPaneItems.length, 1, "We correctly have only 1 left pane folder"); + // Check version. + var version = PlacesUtils.annotations.getItemAnnotation(leftPaneItems[0], + PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(version, PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION, "Left pane version is actual"); + ok(true, "left pane has already been created, skipping test"); + finish(); + return; + } + + // Create a fake left pane folder with an old version (current version - 1). + var fakeLeftPaneRoot = + PlacesUtils.bookmarks.createFolder(PlacesUtils.placesRootId, "", + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.annotations.setItemAnnotation(fakeLeftPaneRoot, + PlacesUIUtils.ORGANIZER_FOLDER_ANNO, + PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION - 1, + 0, + PlacesUtils.annotations.EXPIRE_NEVER); + + // Check fake left pane root has been correctly created. + var leftPaneItems = + PlacesUtils.annotations.getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(leftPaneItems.length, 1, "We correctly have only 1 left pane folder"); + is(leftPaneItems[0], fakeLeftPaneRoot, "left pane root itemId is correct"); + + // Check version. + var version = PlacesUtils.annotations.getItemAnnotation(fakeLeftPaneRoot, + PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(version, PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION - 1, "Left pane version correctly set"); + + // Open Library, this will upgrade our left pane version. + openLibrary(onLibraryReady); +} diff --git a/comm/suite/components/places/tests/browser/browser_425884.js b/comm/suite/components/places/tests/browser/browser_425884.js new file mode 100644 index 0000000000..4140ac0234 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_425884.js @@ -0,0 +1,103 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +function test() { + // sanity check + ok(PlacesUtils, "checking PlacesUtils, running in chrome context?"); + ok(PlacesUIUtils, "checking PlacesUIUtils, running in chrome context?"); + + /* + Deep copy of bookmark data, using the front-end codepath: + + - create test folder A + - add a subfolder to folder A, and add items to it + - validate folder A (sanity check) + - copy folder A, creating new folder B, using the front-end path + - validate folder B + - undo copy transaction + - validate folder B (empty) + - redo copy transaction + - validate folder B's contents + + */ + + var toolbarId = PlacesUtils.toolbarFolderId; + var toolbarNode = PlacesUtils.getFolderContents(toolbarId).root; + + var oldCount = toolbarNode.childCount; + var testRootId = PlacesUtils.bookmarks.createFolder(toolbarId, "test root", -1); + is(toolbarNode.childCount, oldCount+1, "confirm test root node is a container, and is empty"); + var testRootNode = toolbarNode.getChild(toolbarNode.childCount-1); + testRootNode.QueryInterface(Ci.nsINavHistoryContainerResultNode); + testRootNode.containerOpen = true; + is(testRootNode.childCount, 0, "confirm test root node is a container, and is empty"); + + // create folder A, fill it, validate its contents + var folderAId = PlacesUtils.bookmarks.createFolder(testRootId, "A", -1); + populate(folderAId); + var folderANode = PlacesUtils.getFolderContents(folderAId).root; + validate(folderANode); + is(testRootNode.childCount, 1, "create test folder"); + + // copy it, using the front-end helper functions + var serializedNode = PlacesUtils.wrapNode(folderANode, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER); + var rawNode = PlacesUtils.unwrapNodes(serializedNode, PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER).shift(); + // confirm serialization + ok(rawNode.type, "confirm json node"); + folderANode.containerOpen = false; + + var transaction = PlacesUIUtils.makeTransaction(rawNode, + PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, + testRootId, + -1, + true); + ok(transaction, "create transaction"); + PlacesUtils.transactionManager.doTransaction(transaction); + // confirm copy + is(testRootNode.childCount, 2, "create test folder via copy"); + + // validate the copy + var folderBNode = testRootNode.getChild(1); + validate(folderBNode); + + // undo the transaction, confirm the removal + PlacesUtils.transactionManager.undoTransaction(); + is(testRootNode.childCount, 1, "confirm undo removed the copied folder"); + + // redo the transaction + PlacesUtils.transactionManager.redoTransaction(); + is(testRootNode.childCount, 2, "confirm redo re-copied the folder"); + folderBNode = testRootNode.getChild(1); + validate(folderBNode); + + // Close containers, cleaning up their observers. + testRootNode.containerOpen = false; + toolbarNode.containerOpen = false; + + // clean up + PlacesUtils.transactionManager.undoTransaction(); + PlacesUtils.bookmarks.removeItem(folderAId); +} + +function populate(aFolderId) { + var folderId = PlacesUtils.bookmarks.createFolder(aFolderId, "test folder", -1); + PlacesUtils.bookmarks.insertBookmark(folderId, PlacesUtils._uri("http://foo"), -1, "test bookmark"); + PlacesUtils.bookmarks.insertSeparator(folderId, -1); +} + +function validate(aNode) { + PlacesUtils.asContainer(aNode); + aNode.containerOpen = true; + is(aNode.childCount, 1, "confirm child count match"); + var folderNode = aNode.getChild(0); + is(folderNode.title, "test folder", "confirm folder title"); + PlacesUtils.asContainer(folderNode); + folderNode.containerOpen = true; + is(folderNode.childCount, 2, "confirm child count match"); + var bookmarkNode = folderNode.getChild(0); + var separatorNode = folderNode.getChild(1); + folderNode.containerOpen = false; + aNode.containerOpen = false; +} diff --git a/comm/suite/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js b/comm/suite/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js new file mode 100644 index 0000000000..62af31ccf7 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js @@ -0,0 +1,233 @@ +/* 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/. */ + +const TEST_URL = "http://www.mozilla.org"; +const TEST_TITLE = "example_title"; + +var gBookmarksToolbar = window.document.getElementById("PlacesToolbar"); +var dragDirections = { LEFT: 0, UP: 1, RIGHT: 2, DOWN: 3 }; + +/** + * Tests dragging on toolbar. + * + * We must test these 2 cases: + * - Dragging toward left, top, right should start a drag. + * - Dragging toward down should should open the container if the item is a + * container, drag the item otherwise. + * + * @param aElement + * DOM node element we will drag + * @param aExpectedDragData + * Array of flavors and values in the form: + * [ ["text/plain: sometext", "text/html: <b>sometext</b>"], [...] ] + * Pass an empty array to check that drag even has been canceled. + * @param aDirection + * Direction for the dragging gesture, see dragDirections helper object. + */ +function synthesizeDragWithDirection(aElement, aExpectedDragData, aDirection) { + var trapped = false; + + // Dragstart listener function. + var trapDrag = function(event) { + trapped = true; + var dataTransfer = event.dataTransfer; + is(dataTransfer.mozItemCount, aExpectedDragData.length, + "Number of dragged items should be the same."); + + for (var t = 0; t < dataTransfer.mozItemCount; t++) { + var types = dataTransfer.mozTypesAt(t); + var expecteditem = aExpectedDragData[t]; + is(types.length, expecteditem.length, + "Number of flavors for item " + t + " should be the same."); + + for (var f = 0; f < types.length; f++) { + is(types[f], expecteditem[f].substring(0, types[f].length), + "Flavor " + types[f] + " for item " + t + " should be the same."); + is(dataTransfer.mozGetDataAt(types[f], t), + expecteditem[f].substring(types[f].length + 2), + "Contents for item " + t + " with flavor " + types[f] + " should be the same."); + } + } + + if (!aExpectedDragData.length) + ok(event.defaultPrevented, "Drag has been canceled."); + + event.preventDefault(); + event.stopPropagation(); + } + + var prevent = function(aEvent) {aEvent.preventDefault();} + + var xIncrement = 0; + var yIncrement = 0; + + switch (aDirection) { + case dragDirections.LEFT: + xIncrement = -1; + break; + case dragDirections.RIGHT: + xIncrement = +1; + break; + case dragDirections.UP: + yIncrement = -1; + break; + case dragDirections.DOWN: + yIncrement = +1; + break; + } + + var rect = aElement.getBoundingClientRect(); + var startingPoint = { x: (rect.right - rect.left)/2, + y: (rect.bottom - rect.top)/2 }; + + EventUtils.synthesizeMouse(aElement, + startingPoint.x, + startingPoint.y, + { type: "mousedown" }); + EventUtils.synthesizeMouse(aElement, + startingPoint.x + xIncrement * 1, + startingPoint.y + yIncrement * 1, + { type: "mousemove" }); + gBookmarksToolbar.addEventListener("dragstart", trapDrag); + EventUtils.synthesizeMouse(aElement, + startingPoint.x + xIncrement * 9, + startingPoint.y + yIncrement * 9, + { type: "mousemove" }); + ok(trapped, "A dragstart event has been trapped."); + gBookmarksToolbar.removeEventListener("dragstart", trapDrag); + + // This is likely to cause a click event, and, in case we are dragging a + // bookmark, an unwanted page visit. Prevent the click event. + aElement.addEventListener("click", prevent); + EventUtils.synthesizeMouse(aElement, + startingPoint.x + xIncrement * 9, + startingPoint.y + yIncrement * 9, + { type: "mouseup" }); + aElement.removeEventListener("click", prevent); + + // Cleanup eventually opened menus. + if (aElement.localName == "menu" && aElement.open) + aElement.open = false; +} + +function getToolbarNodeForItemId(aItemId) { + var children = document.getElementById("PlacesToolbarItems").childNodes; + var node = null; + for (var i = 0; i < children.length; i++) { + if (aItemId == children[i]._placesNode.itemId) { + node = children[i]; + break; + } + } + return node; +} + +function getExpectedDataForPlacesNode(aNode) { + var wrappedNode = []; + var flavors = ["text/x-moz-place", + "text/x-moz-url", + "text/plain", + "text/html"]; + + flavors.forEach(function(aFlavor) { + var wrappedFlavor = aFlavor + ": " + + PlacesUtils.wrapNode(aNode, aFlavor); + wrappedNode.push(wrappedFlavor); + }); + + return [wrappedNode]; +} + +var gTests = [ + +//------------------------------------------------------------------------------ + + { + desc: "Drag a folder on toolbar", + run: function() { + // Create a test folder to be dragged. + var folderId = PlacesUtils.bookmarks + .createFolder(PlacesUtils.toolbarFolderId, + TEST_TITLE, + PlacesUtils.bookmarks.DEFAULT_INDEX); + var element = getToolbarNodeForItemId(folderId); + isnot(element, null, "Found node on toolbar"); + + isnot(element._placesNode, null, "Toolbar node has an associated Places node."); + var expectedData = getExpectedDataForPlacesNode(element._placesNode); + + ok(true, "Dragging left"); + synthesizeDragWithDirection(element, expectedData, dragDirections.LEFT); + ok(true, "Dragging right"); + synthesizeDragWithDirection(element, expectedData, dragDirections.RIGHT); + ok(true, "Dragging up"); + synthesizeDragWithDirection(element, expectedData, dragDirections.UP); + ok(true, "Dragging down"); + synthesizeDragWithDirection(element, new Array(), dragDirections.DOWN); + + // Cleanup. + PlacesUtils.bookmarks.removeItem(folderId); + } + }, + +//------------------------------------------------------------------------------ + + { + desc: "Drag a bookmark on toolbar", + run: function() { + // Create a test bookmark to be dragged. + var itemId = PlacesUtils.bookmarks + .insertBookmark(PlacesUtils.toolbarFolderId, + PlacesUtils._uri(TEST_URL), + PlacesUtils.bookmarks.DEFAULT_INDEX, + TEST_TITLE); + var element = getToolbarNodeForItemId(itemId); + isnot(element, null, "Found node on toolbar"); + + isnot(element._placesNode, null, "Toolbar node has an associated Places node."); + var expectedData = getExpectedDataForPlacesNode(element._placesNode); + + ok(true, "Dragging left"); + synthesizeDragWithDirection(element, expectedData, dragDirections.LEFT); + ok(true, "Dragging right"); + synthesizeDragWithDirection(element, expectedData, dragDirections.RIGHT); + ok(true, "Dragging up"); + synthesizeDragWithDirection(element, expectedData, dragDirections.UP); + ok(true, "Dragging down"); + synthesizeDragWithDirection(element, expectedData, dragDirections.DOWN); + + // Cleanup. + PlacesUtils.bookmarks.removeItem(itemId); + } + }, +]; + +function nextTest() { + if (gTests.length) { + var test = gTests.shift(); + info("Start of test: " + test.desc); + test.run(); + + setTimeout(nextTest, 0); + } + else { + // Collapse the personal toolbar if needed. + if (wasCollapsed) + toolbar.collapsed = true; + finish(); + } +} + +var toolbar = document.getElementById("PersonalToolbar"); +var wasCollapsed = toolbar.collapsed; + +function test() { + // Uncollapse the personal toolbar if needed. + if (wasCollapsed) + toolbar.collapsed = false; + + waitForExplicitFinish(); + nextTest(); +} + diff --git a/comm/suite/components/places/tests/browser/browser_library_infoBox.js b/comm/suite/components/places/tests/browser/browser_library_infoBox.js new file mode 100644 index 0000000000..03ee11574a --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_library_infoBox.js @@ -0,0 +1,171 @@ +/* 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/. */ + +/** + * Test appropriate visibility of infoBoxExpanderWrapper and + * additionalInfoFields in infoBox section of library + */ + +const TEST_URI = "http://www.mozilla.org/"; + +var gTests = []; +var gLibrary; + +//------------------------------------------------------------------------------ + +gTests.push({ + desc: "Bug 430148 - Remove or hide the more/less button in details pane...", + run: function() { + var PO = gLibrary.PlacesOrganizer; + var infoBoxExpanderWrapper = getAndCheckElmtById("infoBoxExpanderWrapper"); + + function addVisitsCallback() { + // open all bookmarks node + PO.selectLeftPaneQuery("AllBookmarks"); + isnot(PO._places.selectedNode, null, + "Correctly selected all bookmarks node."); + checkInfoBoxSelected(PO); + ok(infoBoxExpanderWrapper.hidden, + "Expander button is hidden for all bookmarks node."); + checkAddInfoFieldsCollapsed(PO); + + // open bookmarks menu node + PO.selectLeftPaneQuery("BookmarksMenu"); + isnot(PO._places.selectedNode, null, + "Correctly selected bookmarks menu node."); + checkInfoBoxSelected(PO); + ok(infoBoxExpanderWrapper.hidden, + "Expander button is hidden for bookmarks menu node."); + checkAddInfoFieldsCollapsed(PO); + + // open recently bookmarked node + var menuNode = PO._places.selectedNode. + QueryInterface(Ci.nsINavHistoryContainerResultNode); + menuNode.containerOpen = true; + var childNode = menuNode.getChild(0); + isnot(childNode, null, "Bookmarks menu child node exists."); + var recentlyBookmarkedTitle = PlacesUIUtils. + getString("recentlyBookmarkedTitle"); + isnot(recentlyBookmarkedTitle, null, + "Correctly got the recently bookmarked title locale string."); + is(childNode.title, recentlyBookmarkedTitle, + "Correctly selected recently bookmarked node."); + PO._places.selectNode(childNode); + checkInfoBoxSelected(PO); + // Note: SeaMonkey differs from Firefox UI in this case. + ok(infoBoxExpanderWrapper.hidden, + "Expander button is hidden for recently bookmarked node."); + checkAddInfoFieldsNotCollapsed(PO); + + // open first bookmark + PO._content.focus(); + var view = PO._content.treeBoxObject.view; + ok(view.rowCount > 0, "Bookmark item exists."); + view.selection.select(0); + checkInfoBoxSelected(PO); + ok(!infoBoxExpanderWrapper.hidden, + "Expander button is not hidden for bookmark item."); + checkAddInfoFieldsNotCollapsed(PO); + checkAddInfoFields(PO, "bookmark item"); + + // make sure additional fields are still hidden in second bookmark item + ok(view.rowCount > 1, "Second bookmark item exists."); + view.selection.select(1); + checkInfoBoxSelected(PO); + ok(!infoBoxExpanderWrapper.hidden, + "Expander button is not hidden for second bookmark item."); + checkAddInfoFieldsNotCollapsed(PO); + checkAddInfoFields(PO, "second bookmark item"); + + menuNode.containerOpen = false; + + waitForClearHistory(nextTest); + }; + + // Add a visit to browser history + addVisits( + { uri: PlacesUtils._uri(TEST_URI), + visitDate: Date.now()*1000, + transition: PlacesUtils.history.TRANSITION_TYPED }, + addVisitsCallback); + } +}); + +function checkInfoBoxSelected(PO) { + is(getAndCheckElmtById("detailsDeck").selectedIndex, 1, + "Selected element in detailsDeck is infoBox."); +} + +function checkAddInfoFieldsCollapsed(PO) { + PO._additionalInfoFields.forEach(function (id) { + ok(getAndCheckElmtById(id).collapsed, + "Additional info field correctly collapsed: #" + id); + }); +} + +function checkAddInfoFieldsNotCollapsed(PO) { + ok(PO._additionalInfoFields.some(function (id) { + return !getAndCheckElmtById(id).collapsed; + }), "Some additional info field correctly not collapsed"); +} + +function checkAddInfoFields(PO, nodeName) { + ok(true, "Checking additional info fields visibiity for node: " + nodeName); + var expanderButton = getAndCheckElmtById("infoBoxExpander"); + + // make sure additional fields are hidden by default + PO._additionalInfoFields.forEach(function (id) { + ok(getAndCheckElmtById(id).hidden, + "Additional info field correctly hidden by default: #" + id); + }); + + // toggle fields and make sure they are hidden/unhidden as expected + expanderButton.click(); + PO._additionalInfoFields.forEach(function (id) { + ok(!getAndCheckElmtById(id).hidden, + "Additional info field correctly unhidden after toggle: #" + id); + }); + expanderButton.click(); + PO._additionalInfoFields.forEach(function (id) { + ok(getAndCheckElmtById(id).hidden, + "Additional info field correctly hidden after toggle: #" + id); + }); +} + +function getAndCheckElmtById(id) { + var elmt = gLibrary.document.getElementById(id); + isnot(elmt, null, "Correctly got element: #" + id); + return elmt; +} + +//------------------------------------------------------------------------------ + +function nextTest() { + if (gTests.length) { + var test = gTests.shift(); + ok(true, "TEST: " + test.desc); + dump("TEST: " + test.desc + "\n"); + test.run(); + } + else { + // Close Library window. + gLibrary.close(); + // No need to cleanup anything, we have a correct left pane now. + finish(); + } +} + +function test() { + waitForExplicitFinish(); + // Sanity checks. + ok(PlacesUtils, "PlacesUtils is running in chrome context"); + ok(PlacesUIUtils, "PlacesUIUtils is running in chrome context"); + + // Open Library. + openLibrary(function (library) { + gLibrary = library; + gLibrary.PlacesOrganizer._places.focus(); + nextTest(gLibrary); + }); +} diff --git a/comm/suite/components/places/tests/browser/browser_library_left_pane_commands.js b/comm/suite/components/places/tests/browser/browser_library_left_pane_commands.js new file mode 100644 index 0000000000..bd4f089018 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_library_left_pane_commands.js @@ -0,0 +1,100 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test enabled commands in the left pane folder of the Library. + */ + +const TEST_URI = "http://www.mozilla.org/"; + +var gTests = []; +var gLibrary; + +//------------------------------------------------------------------------------ + +gTests.push({ + desc: "Bug 490156 - Can't delete smart bookmark containers", + run: function() { + // Select and open the left pane "Bookmarks Toolbar" folder. + var PO = gLibrary.PlacesOrganizer; + PO.selectLeftPaneQuery('BookmarksToolbar'); + isnot(PO._places.selectedNode, null, "We have a valid selection"); + is(PlacesUtils.getConcreteItemId(PO._places.selectedNode), + PlacesUtils.toolbarFolderId, + "We have correctly selected bookmarks toolbar node."); + + // Check that both cut and delete commands are disabled. + ok(!PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is disabled"); + ok(!PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is disabled"); + + var toolbarNode = PO._places.selectedNode + .QueryInterface(Ci.nsINavHistoryContainerResultNode); + toolbarNode.containerOpen = true; + + // Add an History query to the toolbar. + PlacesUtils.bookmarks.insertBookmark(PlacesUtils.toolbarFolderId, + PlacesUtils._uri("place:sort=4"), + 0, // Insert at start. + "special_query"); + // Get first child and check it is the "Most Visited" smart bookmark. + ok(toolbarNode.childCount > 0, "Toolbar node has children"); + var queryNode = toolbarNode.getChild(0); + is(queryNode.title, "special_query", "Query node is correctly selected"); + + // Select query node. + PO._places.selectNode(queryNode); + is(PO._places.selectedNode, queryNode, "We correctly selected query node"); + + // Check that both cut and delete commands are enabled. + ok(PO._places.controller.isCommandEnabled("cmd_cut"), + "Cut command is enabled"); + ok(PO._places.controller.isCommandEnabled("cmd_delete"), + "Delete command is enabled"); + + // Execute the delete command and check bookmark has been removed. + PO._places.controller.doCommand("cmd_delete"); + try { + PlacesUtils.bookmarks.getFolderIdForItem(queryNode.itemId); + ok(false, "Unable to remove query node bookmark"); + } catch(ex) { + ok(true, "Query node bookmark has been correctly removed"); + } + + toolbarNode.containerOpen = false; + nextTest(); + } +}); + +//------------------------------------------------------------------------------ + +function nextTest() { + if (gTests.length) { + var test = gTests.shift(); + info("Start of test: " + test.desc); + test.run(); + } + else { + // Close Library window. + gLibrary.close(); + // No need to cleanup anything, we have a correct left pane now. + finish(); + } +} + +function test() { + waitForExplicitFinish(); + // Sanity checks. + ok(PlacesUtils, "PlacesUtils is running in chrome context"); + ok(PlacesUIUtils, "PlacesUIUtils is running in chrome context"); + + // Open Library. + openLibrary(function (library) { + gLibrary = library; + nextTest(); + }); +} diff --git a/comm/suite/components/places/tests/browser/browser_library_left_pane_fixnames.js b/comm/suite/components/places/tests/browser/browser_library_left_pane_fixnames.js new file mode 100644 index 0000000000..d46baf520a --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_library_left_pane_fixnames.js @@ -0,0 +1,92 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Test we correctly fix broken Library left pane queries names. + */ + +// Array of left pane queries objects, each one has the following properties: +// name: query's identifier got from annotations, +// itemId: query's itemId, +// correctTitle: original and correct query's title. +var leftPaneQueries = []; + +function onLibraryReady(organizer) { + // Check titles have been fixed. + for (var i = 0; i < leftPaneQueries.length; i++) { + var query = leftPaneQueries[i]; + if ("concreteId" in query) { + is(PlacesUtils.bookmarks.getItemTitle(query.concreteId), + query.concreteTitle, "Concrete title is correct for query " + query.name); + } + } + + // Close Library window. + organizer.close(); + // No need to cleanup anything, we have a correct left pane now. + finish(); +} + +function test() { + waitForExplicitFinish(); + // Sanity checks. + ok(PlacesUtils, "PlacesUtils is running in chrome context"); + ok(PlacesUIUtils, "PlacesUIUtils is running in chrome context"); + ok(PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION > 0, + "Left pane version in chrome context, current version is: " + PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION ); + + // Ensure left pane is initialized. + ok(PlacesUIUtils.leftPaneFolderId > 0, "left pane folder is initialized"); + + // Get the left pane folder. + var leftPaneItems = PlacesUtils.annotations + .getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + + is(leftPaneItems.length, 1, "We correctly have only 1 left pane folder"); + // Check version. + var version = PlacesUtils.annotations + .getItemAnnotation(leftPaneItems[0], + PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(version, PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION, "Left pane version is actual"); + + // Get all left pane queries. + var items = PlacesUtils.annotations + .getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_QUERY_ANNO); + // Get current queries names. + for (var i = 0; i < items.length; i++) { + var itemId = items[i]; + var queryName = PlacesUtils.annotations + .getItemAnnotation(items[i], + PlacesUIUtils.ORGANIZER_QUERY_ANNO); + var query = { name: queryName, + itemId: itemId, + correctTitle: PlacesUtils.bookmarks.getItemTitle(itemId) } + switch (queryName) { + case "BookmarksToolbar": + query.concreteId = PlacesUtils.toolbarFolderId; + query.concreteTitle = PlacesUtils.bookmarks.getItemTitle(query.concreteId); + break; + case "BookmarksMenu": + query.concreteId = PlacesUtils.bookmarksMenuFolderId; + query.concreteTitle = PlacesUtils.bookmarks.getItemTitle(query.concreteId); + break; + case "UnfiledBookmarks": + query.concreteId = PlacesUtils.unfiledBookmarksFolderId; + query.concreteTitle = PlacesUtils.bookmarks.getItemTitle(query.concreteId); + break; + } + leftPaneQueries.push(query); + // Rename to a bad title. + PlacesUtils.bookmarks.setItemTitle(query.itemId, "badName"); + if ("concreteId" in query) + PlacesUtils.bookmarks.setItemTitle(query.concreteId, "badName"); + } + + PlacesUIUtils.__defineGetter__("leftPaneFolderId", cachedLeftPaneFolderIdGetter); + + // Open Library, this will kick-off left pane code. + openLibrary(onLibraryReady); +} diff --git a/comm/suite/components/places/tests/browser/browser_library_open_leak.js b/comm/suite/components/places/tests/browser/browser_library_open_leak.js new file mode 100644 index 0000000000..03aa3408f7 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_library_open_leak.js @@ -0,0 +1,23 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Bug 474831 + * https://bugzilla.mozilla.org/show_bug.cgi?id=474831 + * + * Tests for leaks caused by simply opening and closing the Places Library + * window. Opens the Places Library window, waits for it to load, closes it, + * and finishes. + */ + +function test() { + waitForExplicitFinish(); + openLibrary(function (win) { + ok(true, "Library has been correctly opened"); + win.close(); + finish(); + }); +} diff --git a/comm/suite/components/places/tests/browser/browser_library_views_liveupdate.js b/comm/suite/components/places/tests/browser/browser_library_views_liveupdate.js new file mode 100644 index 0000000000..33fe4f0b34 --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_library_views_liveupdate.js @@ -0,0 +1,303 @@ +/* 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/. */ + +/** + * Tests Library Left pane view for liveupdate. + */ + +var gLibrary = null; + +function test() { + waitForExplicitFinish(); + // This test takes quite some time, and timeouts frequently, so we require + // more time to run. + // See Bug 525610. + requestLongerTimeout(2); + + // Sanity checks. + ok(PlacesUtils, "PlacesUtils in context"); + ok(PlacesUIUtils, "PlacesUIUtils in context"); + + // Open Library, we will check the left pane. + openLibrary(function (library) { + gLibrary = library; + startTest(); + }); +} + +/** + * Adds bookmarks observer, and executes a bunch of bookmarks operations. + */ +function startTest() { + var bs = PlacesUtils.bookmarks; + // Add observers. + bs.addObserver(bookmarksObserver); + PlacesUtils.annotations.addObserver(bookmarksObserver); + var addedBookmarks = []; + + // MENU + ok(true, "*** Acting on menu bookmarks"); + var id = bs.insertBookmark(bs.bookmarksMenuFolder, + PlacesUtils._uri("http://bm1.mozilla.org/"), + bs.DEFAULT_INDEX, + "bm1"); + addedBookmarks.push(id); + id = bs.insertBookmark(bs.bookmarksMenuFolder, + PlacesUtils._uri("place:"), + bs.DEFAULT_INDEX, + "bm2"); + bs.setItemTitle(id, "bm2_edited"); + addedBookmarks.push(id); + id = bs.insertSeparator(bs.bookmarksMenuFolder, bs.DEFAULT_INDEX); + addedBookmarks.push(id); + id = bs.createFolder(bs.bookmarksMenuFolder, + "bmf", + bs.DEFAULT_INDEX); + bs.setItemTitle(id, "bmf_edited"); + addedBookmarks.push(id); + id = bs.insertBookmark(id, + PlacesUtils._uri("http://bmf1.mozilla.org/"), + bs.DEFAULT_INDEX, + "bmf1"); + addedBookmarks.push(id); + bs.moveItem(id, bs.bookmarksMenuFolder, 0); + + // TOOLBAR + ok(true, "*** Acting on toolbar bookmarks"); + bs.insertBookmark(bs.toolbarFolder, + PlacesUtils._uri("http://tb1.mozilla.org/"), + bs.DEFAULT_INDEX, + "tb1"); + bs.setItemTitle(id, "tb1_edited"); + addedBookmarks.push(id); + id = bs.insertBookmark(bs.toolbarFolder, + PlacesUtils._uri("place:"), + bs.DEFAULT_INDEX, + "tb2"); + bs.setItemTitle(id, "tb2_edited"); + addedBookmarks.push(id); + id = bs.insertSeparator(bs.toolbarFolder, bs.DEFAULT_INDEX); + addedBookmarks.push(id); + id = bs.createFolder(bs.toolbarFolder, + "tbf", + bs.DEFAULT_INDEX); + bs.setItemTitle(id, "tbf_edited"); + addedBookmarks.push(id); + id = bs.insertBookmark(id, + PlacesUtils._uri("http://tbf1.mozilla.org/"), + bs.DEFAULT_INDEX, + "bmf1"); + addedBookmarks.push(id); + bs.moveItem(id, bs.toolbarFolder, 0); + + // UNSORTED + ok(true, "*** Acting on unsorted bookmarks"); + id = bs.insertBookmark(bs.unfiledBookmarksFolder, + PlacesUtils._uri("http://ub1.mozilla.org/"), + bs.DEFAULT_INDEX, + "ub1"); + bs.setItemTitle(id, "ub1_edited"); + addedBookmarks.push(id); + id = bs.insertBookmark(bs.unfiledBookmarksFolder, + PlacesUtils._uri("place:"), + bs.DEFAULT_INDEX, + "ub2"); + bs.setItemTitle(id, "ub2_edited"); + addedBookmarks.push(id); + id = bs.insertSeparator(bs.unfiledBookmarksFolder, bs.DEFAULT_INDEX); + addedBookmarks.push(id); + id = bs.createFolder(bs.unfiledBookmarksFolder, + "ubf", + bs.DEFAULT_INDEX); + bs.setItemTitle(id, "ubf_edited"); + addedBookmarks.push(id); + id = bs.insertBookmark(id, + PlacesUtils._uri("http://ubf1.mozilla.org/"), + bs.DEFAULT_INDEX, + "ubf1"); + addedBookmarks.push(id); + bs.moveItem(id, bs.unfiledBookmarksFolder, 0); + + // Remove all added bookmarks. + addedBookmarks.forEach(function (aItem) { + // If we remove an item after its containing folder has been removed, + // this will throw, but we can ignore that. + try { + bs.removeItem(aItem); + } catch (ex) {} + }); + + // Remove observers. + bs.removeObserver(bookmarksObserver); + PlacesUtils.annotations.removeObserver(bookmarksObserver); + finishTest(); +} + +/** + * Restores browser state and calls finish. + */ +function finishTest() { + // Close Library window. + gLibrary.close(); + finish(); +} + +/** + * The observer is where magic happens, for every change we do it will look for + * nodes positions in the affected views. + */ +var bookmarksObserver = { + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsINavBookmarkObserver + , Ci.nsIAnnotationObserver + ]), + + // nsIAnnotationObserver + onItemAnnotationSet: function() {}, + onItemAnnotationRemoved: function() {}, + onPageAnnotationSet: function() {}, + onPageAnnotationRemoved: function() {}, + + // nsINavBookmarkObserver + onItemAdded: function PSB_onItemAdded(aItemId, aFolderId, aIndex, aItemType, + aURI) { + var node = null; + var index = null; + [node, index] = getNodeForTreeItem(aItemId, gLibrary.PlacesOrganizer._places); + // Left pane should not be updated for normal bookmarks or separators. + var type = PlacesUtils.bookmarks.getItemType(aItemId); + switch (type) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + var uriString = PlacesUtils.bookmarks.getBookmarkURI(aItemId).spec; + var isQuery = uriString.substr(0, 6) == "place:"; + if (isQuery) { + isnot(node, null, "Found new Places node in left pane"); + ok(index >= 0, "Node is at index " + index); + break; + } + // Fallback to separator case if this is not a query. + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + is(node, null, "New Places node not added in left pane"); + break; + default: + isnot(node, null, "Found new Places node in left pane"); + ok(index >= 0, "Node is at index " + index); + } + }, + + onItemRemoved: function PSB_onItemRemoved(aItemId, aFolder, aIndex) { + var node = null; + var index = null; + [node, index] = getNodeForTreeItem(aItemId, gLibrary.PlacesOrganizer._places); + is(node, null, "Places node not found in left pane"); + }, + + onItemMoved: function(aItemId, + aOldFolderId, aOldIndex, + aNewFolderId, aNewIndex) { + var node = null; + var index = null; + [node, index] = getNodeForTreeItem(aItemId, gLibrary.PlacesOrganizer._places); + // Left pane should not be updated for normal bookmarks or separators. + var type = PlacesUtils.bookmarks.getItemType(aItemId); + switch (type) { + case PlacesUtils.bookmarks.TYPE_BOOKMARK: + var uriString = PlacesUtils.bookmarks.getBookmarkURI(aItemId).spec; + var isQuery = uriString.substr(0, 6) == "place:"; + if (isQuery) { + isnot(node, null, "Found new Places node in left pane"); + ok(index >= 0, "Node is at index " + index); + break; + } + // Fallback to separator case if this is not a query. + case PlacesUtils.bookmarks.TYPE_SEPARATOR: + is(node, null, "New Places node not added in left pane"); + break; + default: + isnot(node, null, "Found new Places node in left pane"); + ok(index >= 0, "Node is at index " + index); + } + }, + + onBeginUpdateBatch: function PSB_onBeginUpdateBatch() {}, + onEndUpdateBatch: function PSB_onEndUpdateBatch() {}, + onItemVisited: function() {}, + onItemChanged: function PSB_onItemChanged(aItemId, aProperty, + aIsAnnotationProperty, aNewValue) { + if (aProperty == "title") { + let validator = function(aTreeRowIndex) { + let tree = gLibrary.PlacesOrganizer._places; + let cellText = tree.view.getCellText(aTreeRowIndex, + tree.columns.getColumnAt(0)); + return cellText == aNewValue; + } + let [node, index, valid] = getNodeForTreeItem(aItemId, gLibrary.PlacesOrganizer._places, validator); + if (node) // Only visible nodes. + ok(valid, "Title cell value has been correctly updated"); + } + } +}; + + +/** + * Get places node and index for an itemId in a tree view. + * + * @param aItemId + * item id of the item to search. + * @param aTree + * Tree to search in. + * @param aValidator [optional] + * function to check row validity if found. Defaults to {return true;}. + * @returns [node, index, valid] or [null, null, false] if not found. + */ +function getNodeForTreeItem(aItemId, aTree, aValidator) { + + function findNode(aContainerIndex) { + if (aTree.view.isContainerEmpty(aContainerIndex)) + return [null, null, false]; + + // The rowCount limit is just for sanity, but we will end looping when + // we have checked the last child of this container or we have found node. + for (var i = aContainerIndex + 1; i < aTree.view.rowCount; i++) { + var node = aTree.view.nodeForTreeIndex(i); + + if (node.itemId == aItemId) { + // Minus one because we want relative index inside the container. + let valid = aValidator ? aValidator(i) : true; + return [node, i - aTree.view.getParentIndex(i) - 1, valid]; + } + + if (PlacesUtils.nodeIsFolder(node)) { + // Open container. + aTree.view.toggleOpenState(i); + // Search inside it. + var foundNode = findNode(i); + // Close container. + aTree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) + return foundNode; + } + + // We have finished walking this container. + if (!aTree.view.hasNextSibling(aContainerIndex + 1, i)) + break; + } + return [null, null, false] + } + + // Root node is hidden, so we need to manually walk the first level. + for (var i = 0; i < aTree.view.rowCount; i++) { + // Open container. + aTree.view.toggleOpenState(i); + // Search inside it. + var foundNode = findNode(i); + // Close container. + aTree.view.toggleOpenState(i); + // Return node if found. + if (foundNode[0] != null) + return foundNode; + } + return [null, null, false]; +} diff --git a/comm/suite/components/places/tests/browser/browser_sort_in_library.js b/comm/suite/components/places/tests/browser/browser_sort_in_library.js new file mode 100644 index 0000000000..6967be16aa --- /dev/null +++ b/comm/suite/components/places/tests/browser/browser_sort_in_library.js @@ -0,0 +1,254 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests the following bugs: + * + * Bug 443745 - View>Sort>of "alpha" sort items is default to Z>A instead of A>Z + * https://bugzilla.mozilla.org/show_bug.cgi?id=443745 + * + * Bug 444179 - Library>Views>Sort>Sort by Tags does nothing + * https://bugzilla.mozilla.org/show_bug.cgi?id=444179 + * + * Basically, fully tests sorting the placeContent tree in the Places Library + * window. Sorting is verified by comparing the nsINavHistoryResult returned by + * placeContent.result to the expected sort values. + */ + +// Two properties of nsINavHistoryResult control the sort of the tree: +// sortingMode and sortingAnnotation. sortingMode's value is one of the +// nsINavHistoryQueryOptions.SORT_BY_* constants. sortingAnnotation is the +// annotation used to sort for SORT_BY_ANNOTATION_* mode. +// +// This lookup table maps the possible values of anonid's of the treecols to +// objects that represent the treecols' correct state after the user sorts the +// previously unsorted tree by selecting a column from the Views > Sort menu. +// sortingMode is constructed from the key and dir properties (i.e., +// SORT_BY_<key>_<dir>) and sortingAnnotation is checked against anno. anno +// may be undefined if key is not "ANNOTATION". +const SORT_LOOKUP_TABLE = { + title: { key: "TITLE", dir: "ASCENDING" }, + tags: { key: "TAGS", dir: "ASCENDING" }, + url: { key: "URI", dir: "ASCENDING" }, + date: { key: "DATE", dir: "DESCENDING" }, + visitCount: { key: "VISITCOUNT", dir: "DESCENDING" }, + keyword: { key: "KEYWORD", dir: "ASCENDING" }, + dateAdded: { key: "DATEADDED", dir: "DESCENDING" }, + lastModified: { key: "LASTMODIFIED", dir: "DESCENDING" }, + description: { key: "ANNOTATION", + dir: "ASCENDING", + anno: "bookmarkProperties/description" } +}; + +// This is the column that's sorted if one is not specified and the tree is +// currently unsorted. Set it to a key substring in the name of one of the +// nsINavHistoryQueryOptions.SORT_BY_* constants, e.g., "TITLE", "URI". +// Method ViewMenu.setSortColumn in browser/components/places/content/places.js +// determines this value. +const DEFAULT_SORT_KEY = "TITLE"; + +// Part of the test is checking that sorts stick, so each time we sort we need +// to remember it. +var prevSortDir = null; +var prevSortKey = null; + +/////////////////////////////////////////////////////////////////////////////// + +/** + * Ensures that the sort of aTree is aSortingMode and aSortingAnno. + * + * @param aTree + * the tree to check + * @param aSortingMode + * one of the Ci.nsINavHistoryQueryOptions.SORT_BY_* constants + * @param aSortingAnno + * checked only if sorting mode is one of the + * Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_* constants + */ +function checkSort(aTree, aSortingMode, aSortingAnno) { + // The placeContent tree's sort is determined by the nsINavHistoryResult it + // stores. Get it and check that the sort is what the caller expects. + let res = aTree.result; + isnot(res, null, + "sanity check: placeContent.result should not return null"); + + // Check sortingMode. + is(res.sortingMode, aSortingMode, + "column should now have sortingMode " + aSortingMode); + + // Check sortingAnnotation, but only if sortingMode is ANNOTATION. + if ([Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_ASCENDING, + Ci.nsINavHistoryQueryOptions.SORT_BY_ANNOTATION_DESCENDING]. + indexOf(aSortingMode) >= 0) { + is(res.sortingAnnotation, aSortingAnno, + "column should now have sorting annotation " + aSortingAnno); + } +} + +/** + * Sets the sort of aTree. + * + * @param aOrganizerWin + * the Places window + * @param aTree + * the tree to sort + * @param aUnsortFirst + * true if the sort should be set to SORT_BY_NONE before sorting by aCol + * and aDir + * @param aShouldFail + * true if setSortColumn should fail on aCol or aDir + * @param aCol + * the column of aTree by which to sort + * @param aDir + * either "ascending" or "descending" + */ +function setSort(aOrganizerWin, aTree, aUnsortFirst, aShouldFail, aCol, aDir) { + if (aUnsortFirst) { + aOrganizerWin.ViewMenu.setSortColumn(); + checkSort(aTree, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, ""); + + // Remember the sort key and direction. + prevSortKey = null; + prevSortDir = null; + } + + let failed = false; + try { + aOrganizerWin.ViewMenu.setSortColumn(aCol, aDir); + + // Remember the sort key and direction. + if (!aCol && !aDir) { + prevSortKey = null; + prevSortDir = null; + } + else { + if (aCol) + prevSortKey = SORT_LOOKUP_TABLE[aCol.getAttribute("anonid")].key; + else if (prevSortKey === null) + prevSortKey = DEFAULT_SORT_KEY; + + if (aDir) + prevSortDir = aDir.toUpperCase(); + else if (prevSortDir === null) + prevSortDir = SORT_LOOKUP_TABLE[aCol.getAttribute("anonid")].dir; + } + } catch (exc) { + failed = true; + } + + is(failed, !!aShouldFail, + "setSortColumn on column " + + (aCol ? aCol.getAttribute("anonid") : "(no column)") + + " with direction " + (aDir || "(no direction)") + + " and table previously " + (aUnsortFirst ? "unsorted" : "sorted") + + " should " + (aShouldFail ? "" : "not ") + "fail"); +} + +/** + * Tries sorting by an invalid column and sort direction. + * + * @param aOrganizerWin + * the Places window + * @param aPlaceContentTree + * the placeContent tree in aOrganizerWin + */ +function testInvalid(aOrganizerWin, aPlaceContentTree) { + // Invalid column should fail by throwing an exception. + let bogusCol = document.createElement("treecol"); + bogusCol.setAttribute("anonid", "bogusColumn"); + setSort(aOrganizerWin, aPlaceContentTree, true, true, bogusCol, "ascending"); + + // Invalid direction reverts to SORT_BY_NONE. + setSort(aOrganizerWin, aPlaceContentTree, false, false, null, "bogus dir"); + checkSort(aPlaceContentTree, Ci.nsINavHistoryQueryOptions.SORT_BY_NONE, ""); +} + +/** + * Tests sorting aPlaceContentTree by column only and then by both column + * and direction. + * + * @param aOrganizerWin + * the Places window + * @param aPlaceContentTree + * the placeContent tree in aOrganizerWin + * @param aUnsortFirst + * true if, before each sort we try, we should sort to SORT_BY_NONE + */ +function testSortByColAndDir(aOrganizerWin, aPlaceContentTree, aUnsortFirst) { + let cols = aPlaceContentTree.getElementsByTagName("treecol"); + ok(cols.length > 0, "sanity check: placeContent should contain columns"); + + for (let i = 0; i < cols.length; i++) { + let col = cols.item(i); + ok(col.hasAttribute("anonid"), + "sanity check: column " + col.id + " should have anonid"); + + let colId = col.getAttribute("anonid"); + ok(colId in SORT_LOOKUP_TABLE, + "sanity check: unexpected placeContent column anonid"); + + let sortConst = + "SORT_BY_" + SORT_LOOKUP_TABLE[colId].key + "_" + + (aUnsortFirst ? SORT_LOOKUP_TABLE[colId].dir : prevSortDir); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortConst]; + let expectedAnno = SORT_LOOKUP_TABLE[colId].anno || ""; + + // Test sorting by only a column. + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, col); + checkSort(aPlaceContentTree, expectedSortMode, expectedAnno); + + // Test sorting by both a column and a direction. + ["ascending", "descending"].forEach(function (dir) { + let sortConst = + "SORT_BY_" + SORT_LOOKUP_TABLE[colId].key + "_" + dir.toUpperCase(); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortConst]; + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, col, dir); + checkSort(aPlaceContentTree, expectedSortMode, expectedAnno); + }); + } +} + +/** + * Tests sorting aPlaceContentTree by direction only. + * + * @param aOrganizerWin + * the Places window + * @param aPlaceContentTree + * the placeContent tree in aOrganizerWin + * @param aUnsortFirst + * true if, before each sort we try, we should sort to SORT_BY_NONE + */ +function testSortByDir(aOrganizerWin, aPlaceContentTree, aUnsortFirst) { + ["ascending", "descending"].forEach(function (dir) { + let key = (aUnsortFirst ? DEFAULT_SORT_KEY : prevSortKey); + let sortConst = "SORT_BY_" + key + "_" + dir.toUpperCase(); + let expectedSortMode = Ci.nsINavHistoryQueryOptions[sortConst]; + setSort(aOrganizerWin, aPlaceContentTree, aUnsortFirst, false, null, dir); + checkSort(aPlaceContentTree, expectedSortMode, ""); + }); +} + +/////////////////////////////////////////////////////////////////////////////// + +function test() { + waitForExplicitFinish(); + + openLibrary(function (win) { + let tree = win.document.getElementById("placeContent"); + isnot(tree, null, "sanity check: placeContent tree should exist"); + // Run the tests. + testSortByColAndDir(win, tree, true); + testSortByColAndDir(win, tree, false); + testSortByDir(win, tree, true); + testSortByDir(win, tree, false); + testInvalid(win, tree); + // Reset the sort to SORT_BY_NONE. + setSort(win, tree, false, false); + // Close the window and finish. + win.close(); + finish(); + }); +} diff --git a/comm/suite/components/places/tests/browser/head.js b/comm/suite/components/places/tests/browser/head.js new file mode 100644 index 0000000000..d0fa1cdd49 --- /dev/null +++ b/comm/suite/components/places/tests/browser/head.js @@ -0,0 +1,95 @@ + +// We need to cache this before test runs... +var cachedLeftPaneFolderIdGetter; +var getter = PlacesUIUtils.__lookupGetter__("leftPaneFolderId"); +if (!cachedLeftPaneFolderIdGetter && typeof(getter) == "function") + cachedLeftPaneFolderIdGetter = getter; + +// ...And restore it when test ends. +registerCleanupFunction(function() { + let getter = PlacesUIUtils.__lookupGetter__("leftPaneFolderId"); + if (cachedLeftPaneFolderIdGetter && typeof(getter) != "function") + PlacesUIUtils.__defineGetter__("leftPaneFolderId", + cachedLeftPaneFolderIdGetter); +}); + +function openLibrary(callback) { + var library = window.openDialog( + "chrome://communicator/content/places/places.xul", + "", "chrome,toolbar=yes,dialog=no,resizable"); + waitForFocus(function () { + callback(library); + }, library); +} + +/** + * Waits for completion of a clear history operation, before + * proceeding with aCallback. + * + * @param aCallback + * Function to be called when done. + */ +function waitForClearHistory(aCallback) { + Services.obs.addObserver(function observeCH(aSubject, aTopic, aData) { + Services.obs.removeObserver(observeCH, PlacesUtils.TOPIC_EXPIRATION_FINISHED); + aCallback(); + }, PlacesUtils.TOPIC_EXPIRATION_FINISHED); + PlacesUtils.bhistory.removeAllPages(); +} + +/** + * Asynchronously adds visits to a page, invoking a callback function when done. + * + * @param aPlaceInfo + * Can be an nsIURI, in such a case a single LINK visit will be added. + * Otherwise can be an object describing the visit to add, or an array + * of these objects: + * { uri: nsIURI of the page, + * transition: one of the TRANSITION_* from nsINavHistoryService, + * [optional] title: title of the page, + * [optional] visitDate: visit date in microseconds from the epoch + * [optional] referrer: nsIURI of the referrer for this visit + * } + * @param [optional] aCallback + * Function to be invoked on completion. + */ +function addVisits(aPlaceInfo, aCallback) { + let places = []; + if (aPlaceInfo instanceof Ci.nsIURI) { + places.push({ uri: aPlaceInfo }); + } + else if (Array.isArray(aPlaceInfo)) { + places = places.concat(aPlaceInfo); + } else { + places.push(aPlaceInfo) + } + + // Create mozIVisitInfo for each entry. + let now = Date.now(); + for (let i = 0; i < places.length; i++) { + if (!places[i].title) { + places[i].title = "test visit for " + places[i].uri.spec; + } + places[i].visits = [{ + transitionType: places[i].transition === undefined ? Ci.nsINavHistoryService.TRANSITION_LINK + : places[i].transition, + visitDate: places[i].visitDate || (now++) * 1000, + referrerURI: places[i].referrer + }]; + } + + PlacesUtils.asyncHistory.updatePlaces( + places, + { + handleError: function AAV_handleError() { + throw("Unexpected error in adding visit."); + }, + handleResult: function () {}, + handleCompletion: function UP_handleCompletion() { + if (aCallback) + aCallback(); + } + } + ); +} + diff --git a/comm/suite/components/places/tests/chrome/chrome.ini b/comm/suite/components/places/tests/chrome/chrome.ini new file mode 100644 index 0000000000..38de538ec2 --- /dev/null +++ b/comm/suite/components/places/tests/chrome/chrome.ini @@ -0,0 +1,10 @@ +[DEFAULT] +support-files = head.js + +[test_0_bug510634.xul] +[test_0_multiple_left_pane.xul] +[test_bug427633_no_newfolder_if_noip.xul] +[test_bug485100-change-case-loses-tag.xul] +[test_bug549192.xul] +[test_bug549491.xul] +[test_treeview_date.xul] diff --git a/comm/suite/components/places/tests/chrome/head.js b/comm/suite/components/places/tests/chrome/head.js new file mode 100644 index 0000000000..90d19c9def --- /dev/null +++ b/comm/suite/components/places/tests/chrome/head.js @@ -0,0 +1,55 @@ +/** + * Asynchronously adds visits to a page, invoking a callback function when done. + * + * @param aPlaceInfo + * Can be an nsIURI, in such a case a single LINK visit will be added. + * Otherwise can be an object describing the visit to add, or an array + * of these objects: + * { uri: nsIURI of the page, + * transition: one of the TRANSITION_* from nsINavHistoryService, + * [optional] title: title of the page, + * [optional] visitDate: visit date in microseconds from the epoch + * [optional] referrer: nsIURI of the referrer for this visit + * } + * @param [optional] aCallback + * Function to be invoked on completion. + */ +function addVisits(aPlaceInfo, aCallback) { + let places = []; + if (aPlaceInfo instanceof Ci.nsIURI) { + places.push({ uri: aPlaceInfo }); + } + else if (Array.isArray(aPlaceInfo)) { + places = places.concat(aPlaceInfo); + } else { + places.push(aPlaceInfo) + } + + // Create mozIVisitInfo for each entry. + let now = Date.now(); + for (let i = 0; i < places.length; i++) { + if (!places[i].title) { + places[i].title = "test visit for " + places[i].uri.spec; + } + places[i].visits = [{ + transitionType: places[i].transition === undefined ? PlacesUtils.history.TRANSITION_LINK + : places[i].transition, + visitDate: places[i].visitDate || (now++) * 1000, + referrerURI: places[i].referrer + }]; + } + + PlacesUtils.asyncHistory.updatePlaces( + places, + { + handleError: function AAV_handleError() { + throw("Unexpected error in adding visit."); + }, + handleResult: function () {}, + handleCompletion: function UP_handleCompletion() { + if (aCallback) + aCallback(); + } + } + ); +} diff --git a/comm/suite/components/places/tests/chrome/test_0_bug510634.xul b/comm/suite/components/places/tests/chrome/test_0_bug510634.xul new file mode 100644 index 0000000000..95515b8e9f --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_0_bug510634.xul @@ -0,0 +1,87 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="510634: Wrong icons on bookmarks sidebar" + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + type="places" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" ordinal="1" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script> + <![CDATA[ + + /** + * Bug 510634 - Wrong icons on bookmarks sidebar + * https://bugzilla.mozilla.org/show_bug.cgi?id=510634 + * + * Ensures that properties for special queries are set on their tree nodes, + * even if PlacesUIUtils.leftPaneFolderId was not initialized. + */ + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + // We need to cache and restore this getter in order to simulate + // Bug 510634 + let cachedLeftPaneFolderIdGetter = + PlacesUIUtils.__lookupGetter__("leftPaneFolderId"); + + let leftPaneFolderId = PlacesUIUtils.leftPaneFolderId; + + // restore the getter + PlacesUIUtils.__defineGetter__("leftPaneFolderId", cachedLeftPaneFolderIdGetter); + + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = "place:queryType=1&folder=" + leftPaneFolderId; + + // Open All Bookmarks + PlacesUtils.asContainer(tree.view.nodeForTreeIndex(1)).containerOpen = true; + + // The query-property is set on the title column for each row. + let titleColumn = tree.treeBoxObject.columns.getColumnAt(0); + + ["Tags", "AllBookmarks", "BookmarksToolbar", + "BookmarksMenu", "UnfiledBookmarks"].forEach( + function(aQueryName, aRow) { + let rowProperties = tree.view.getCellProperties(aRow, titleColumn).split(" "); + ok(rowProperties.includes("OrganizerQuery_" + aQueryName), + "OrganizerQuery_" + aQueryName + " is set"); + } + ); + + // Close the root node + tree.result.root.containerOpen = false; + + // Restore the getter for the next test. + PlacesUIUtils.__defineGetter__("leftPaneFolderId", cachedLeftPaneFolderIdGetter); + + SimpleTest.finish(); + } + + ]]> + </script> +</window> diff --git a/comm/suite/components/places/tests/chrome/test_0_multiple_left_pane.xul b/comm/suite/components/places/tests/chrome/test_0_multiple_left_pane.xul new file mode 100644 index 0000000000..574bf3c1c6 --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_0_multiple_left_pane.xul @@ -0,0 +1,82 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<!-- Bug 466422: + - Check that we replace the left pane with a correct one if it gets corrupted + - and we end up having more than one. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> + +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Test handling of multiple left pane folders" + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml"> + <p id="display"></p> + <div id="content" style="display: none"></div> + <pre id="test"></pre> + </body> + + <script> + <![CDATA[ + + function runTest() { + // Sanity checks. + ok(PlacesUtils, "PlacesUtils is running in chrome context"); + ok(PlacesUIUtils, "PlacesUIUtils is running in chrome context"); + ok(PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION > 0, + "Left pane version in chrome context, " + + "current version is: " + PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION ); + + var fakeLeftPanes = []; + var as = PlacesUtils.annotations; + var bs = PlacesUtils.bookmarks; + + // We need 2 left pane folders to simulate a corrupt profile. + do { + let leftPaneItems = as.getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + // Create a fake left pane folder. + let fakeLeftPaneRoot = bs.createFolder(PlacesUtils.placesRootId, "", + bs.DEFAULT_INDEX); + as.setItemAnnotation(fakeLeftPaneRoot, PlacesUIUtils.ORGANIZER_FOLDER_ANNO, + PlacesUIUtils.ORGANIZER_LEFTPANE_VERSION, 0, + as.EXPIRE_NEVER); + fakeLeftPanes.push(fakeLeftPaneRoot); + } while (fakeLeftPanes.length < 2); + + // Initialize the left pane queries. + PlacesUIUtils.leftPaneFolderId; + + // Check left pane. + ok(PlacesUIUtils.leftPaneFolderId > 0, + "Left pane folder correctly created"); + var leftPaneItems = as.getItemsWithAnnotation(PlacesUIUtils.ORGANIZER_FOLDER_ANNO); + is(leftPaneItems.length, 1, + "We correctly have only 1 left pane folder"); + + // Check that all old left pane items have been removed. + fakeLeftPanes.forEach(function(aItemId) { + try { + bs.getItemTitle(aItemId); + throw("This folder should have been removed"); + } catch (ex) {} + }); + + } + ]]> + </script> + +</window> diff --git a/comm/suite/components/places/tests/chrome/test_bug427633_no_newfolder_if_noip.xul b/comm/suite/components/places/tests/chrome/test_bug427633_no_newfolder_if_noip.xul new file mode 100644 index 0000000000..8c1d70ed2a --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_bug427633_no_newfolder_if_noip.xul @@ -0,0 +1,83 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/skin/places/editBookmarkOverlay.css"?> +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> + +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/places/editBookmarkOverlay.xul"?> + +<!DOCTYPE window [ + <!ENTITY % editBookmarkOverlayDTD SYSTEM "chrome://communicator/locale/places/editBookmarkOverlay.dtd"> + %editBookmarkOverlayDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Bug 427633 - Disable creating a New Folder in the bookmarks dialogs if insertionPoint is invalid" + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script src="chrome://communicator/content/places/editBookmarkOverlay.js"/> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <vbox id="editBookmarkPanelContent"/> + + <script> + <![CDATA[ + + /** + * Bug 427633 - Disable creating a New Folder in the bookmarks dialogs if + * insertionPoint is invalid. + */ + + function runTest() { + var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + function uri(spec) { + return Services.io.newURI(spec); + } + + // Add a bookmark. + var itemId = bs.insertBookmark(bs.toolbarFolder, + uri("http://www.mozilla.org/"), + bs.DEFAULT_INDEX, + "mozilla"); + + // Init panel. + ok(gEditItemOverlay, "gEditItemOverlay is in context"); + gEditItemOverlay.initPanel(itemId); + ok(gEditItemOverlay._initialized, "gEditItemOverlay is initialized"); + // We must be sure tree is initialized, so we wait for place to be set. + SimpleTest.waitForExplicitFinish(); + var tree = gEditItemOverlay._element("folderTree"); + tree.addEventListener("DOMAttrModified", function treeDOMAttrMod(event) { + if (event.attrName != "place") + return; + tree.removeEventListener("DOMAttrModified", treeDOMAttrMod, false); + SimpleTest.executeSoon(function() { + tree.view.selection.clearSelection(); + ok(document.getElementById("editBMPanel_newFolderButton").disabled, + "New folder button is disabled if there's no selection"); + + // Cleanup. + bs.removeItem(itemId); + SimpleTest.finish(); + }); + }, false); + // Open the folder tree. + document.getElementById("editBMPanel_foldersExpander").doCommand(); + } + ]]> + </script> + +</window> diff --git a/comm/suite/components/places/tests/chrome/test_bug485100-change-case-loses-tag.xul b/comm/suite/components/places/tests/chrome/test_bug485100-change-case-loses-tag.xul new file mode 100644 index 0000000000..15c0ad4ad0 --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_bug485100-change-case-loses-tag.xul @@ -0,0 +1,82 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/skin/places/editBookmarkOverlay.css"?> +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> + +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> +<?xul-overlay href="chrome://communicator/content/places/editBookmarkOverlay.xul"?> + +<!DOCTYPE window [ + <!ENTITY % editBookmarkOverlayDTD SYSTEM "chrome://communicator/locale/places/editBookmarkOverlay.dtd"> + %editBookmarkOverlayDTD; +]> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="485100: Exchanging a letter of a tag name with its big/small equivalent removes tag from bookmark" + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script src="chrome://communicator/content/places/editBookmarkOverlay.js"/> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <vbox id="editBookmarkPanelContent"/> + + <script> + <![CDATA[ + + function runTest() { + var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + var ts = Cc["@mozilla.org/browser/tagging-service;1"]. + getService(Ci.nsITaggingService); + function uri(spec) { + return Services.io.newURI(spec); + } + + var testURI = uri("http://www.mozilla.org/"); + var testTag = "foo"; + var testTagUpper = "Foo"; + + // Add a bookmark + var itemId = bs.insertBookmark(bs.toolbarFolder, + testURI, + bs.DEFAULT_INDEX, + "mozilla"); + + // Init panel + ok(gEditItemOverlay, "gEditItemOverlay is in context"); + gEditItemOverlay.initPanel(itemId); + + // add a tag + document.getElementById("editBMPanel_tagsField").value = testTag; + gEditItemOverlay.onTagsFieldBlur(); + + // test that the tag has been added in the backend + is(ts.getTagsForURI(testURI)[0], testTag, "tags match"); + + // change the tag + document.getElementById("editBMPanel_tagsField").value = testTagUpper; + gEditItemOverlay.onTagsFieldBlur(); + + // test that the tag has been added in the backend + is(ts.getTagsForURI(testURI)[0], testTagUpper, "tags match"); + + // Cleanup. + ts.untagURI(testURI, [testTag]); + bs.removeItem(itemId); + } + ]]> + </script> + +</window> diff --git a/comm/suite/components/places/tests/chrome/test_bug549192.xul b/comm/suite/components/places/tests/chrome/test_bug549192.xul new file mode 100644 index 0000000000..55e5502764 --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_bug549192.xul @@ -0,0 +1,118 @@ +<?xml version="1.0"?> + +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/licenses/publicdomain/ + --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="549192: History view not updated after deleting entry" + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + type="places" + flatList="true" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" ordinal="1" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script> + <![CDATA[ + /** + * Bug 1388827 / Bug 874407 + * Ensures that history views are updated properly after visits. + * + * Bug 549192 + * Ensures that history views are updated after deleting entries. + */ + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + // The mochitest page is added to history. + waitForClearHistory(continue_test); + } + + function continue_test() { + // Add some visits. + let vtime = Date.now() * 1000; + const ttype = PlacesUtils.history.TRANSITION_TYPED; + let places = + [{ uri: Services.io.newURI("http://example.tld/"), + visitDate: ++vtime, transition: ttype }, + { uri: Services.io.newURI("http://example2.tld/"), + visitDate: ++vtime, transition: ttype }, + { uri: Services.io.newURI("http://example3.tld/"), + visitDate: ++vtime, transition: ttype }]; + + addVisits(places, function() { + // Make a history query. + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.sortingMode = opts.SORT_BY_DATE_DESCENDING; + let queryURI = PlacesUtils.history.queriesToQueryString([query], 1, opts); + + // Setup the places tree contents. + var tree = document.getElementById("tree"); + tree.place = queryURI; + + // loop through the rows and check them. + let treeView = tree.view; + let selection = treeView.selection; + let rc = treeView.rowCount; + + for (let i = 0; i < rc; i++) { + selection.select(i); + let node = tree.selectedNode; + is(node.uri, places[rc - i - 1].uri.spec, + "Found expected node at position " + i + "."); + } + + is(rc, 3, "Found expected number of rows."); + + // First check live-update of the view when adding visits. + places.forEach(place => place.visitDate = ++vtime); + addVisits(places, function() { + for (let i = 0; i < rc; i++) { + selection.select(i); + let node = tree.selectedNode; + is(node.uri, places[rc - i - 1].uri.spec, + "Found expected node at position " + i + "."); + } + + // Now remove the pages and verify live-update again. + for (let i = 0; i < rc; i++) { + selection.select(0); + let node = tree.selectedNode; + tree.controller.remove("Removing page"); + ok(treeView.treeIndexForNode(node) == Ci.nsINavHistoryResultTreeViewer.INDEX_INVISIBLE, + node.uri + " removed."); + ok(treeView.rowCount == rc - i - 1, "Rows count decreased"); + } + + // Cleanup. + waitForClearHistory(SimpleTest.finish); + }); + }); + } + + ]]></script> +</window> diff --git a/comm/suite/components/places/tests/chrome/test_bug549491.xul b/comm/suite/components/places/tests/chrome/test_bug549491.xul new file mode 100644 index 0000000000..f211d62fd2 --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_bug549491.xul @@ -0,0 +1,100 @@ +<?xml version="1.0"?> + +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/licenses/publicdomain/ + --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="549491: 'The root node is never visible' exception when details of the root node are modified " + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + type="places" + flatList="true" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" ordinal="1" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Date" anonid="date" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script> + <![CDATA[ + /** + * Bug 549491 + * https://bugzilla.mozilla.org/show_bug.cgi?id=549491 + * + * Ensures that changing the details of places tree's root-node doesn't + * throw. + */ + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + // The mochitest page is added to history. + waitForClearHistory(continue_test); + } + + function continue_test() { + addVisits( + {uri: Services.io.newURI("http://example.tld/"), + visitDate: Date.now() * 1000, + transition: PlacesUtils.history.TRANSITION_TYPED}, + function() { + // Make a history query. + let query = PlacesUtils.history.getNewQuery(); + let opts = PlacesUtils.history.getNewQueryOptions(); + let queryURI = PlacesUtils.history.queriesToQueryString([query], 1, opts); + + // Setup the places tree contents. + let tree = document.getElementById("tree"); + tree.place = queryURI; + + let rootNode = tree.result.root; + let obs = tree.view.QueryInterface(Ci.nsINavHistoryResultObserver); + obs.nodeHistoryDetailsChanged(rootNode, rootNode.time, rootNode.accessCount); + obs.nodeTitleChanged(rootNode, rootNode.title); + ok(true, "No exceptions thrown"); + + // Cleanup. + waitForClearHistory(SimpleTest.finish); + }); + } + + /** + * Clears history invoking callback when done. + */ + function waitForClearHistory(aCallback) { + const TOPIC_EXPIRATION_FINISHED = "places-expiration-finished"; + let observer = { + observe: function(aSubject, aTopic, aData) { + Services.obs.removeObserver(this, TOPIC_EXPIRATION_FINISHED); + aCallback(); + } + }; + Services.obs.addObserver(observer, TOPIC_EXPIRATION_FINISHED); + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + hs.QueryInterface(Ci.nsIBrowserHistory).removeAllPages(); + } + + ]]></script> +</window> diff --git a/comm/suite/components/places/tests/chrome/test_treeview_date.xul b/comm/suite/components/places/tests/chrome/test_treeview_date.xul new file mode 100644 index 0000000000..c390a66d2b --- /dev/null +++ b/comm/suite/components/places/tests/chrome/test_treeview_date.xul @@ -0,0 +1,179 @@ +<?xml version="1.0"?> + +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + +<?xml-stylesheet href="chrome://communicator/content/places/places.css"?> +<?xml-stylesheet href="chrome://communicator/skin/places/organizer.css"?> +<?xul-overlay href="chrome://communicator/content/places/placesOverlay.xul"?> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="435322: Places tree view's formatting" + onload="runTest();"> + + <script src="chrome://mochikit/content/MochiKit/packed.js" /> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script src="head.js" /> + + <body xmlns="http://www.w3.org/1999/xhtml" /> + + <tree id="tree" + type="places" + flatList="true" + flex="1"> + <treecols> + <treecol label="Title" id="title" anonid="title" primary="true" ordinal="1" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Tags" id="tags" anonid="tags" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Url" id="url" anonid="url" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Visit Date" id="date" anonid="date" flex="1"/> + <splitter class="tree-splitter"/> + <treecol label="Visit Count" id="visitCount" anonid="visitCount" flex="1"/> + </treecols> + <treechildren flex="1"/> + </tree> + + <script> + <![CDATA[ + + /** + * Bug 435322 + * https://bugzilla.mozilla.org/show_bug.cgi?id=435322 + * + * Ensures that date in places treeviews is correctly formatted. + */ + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + SimpleTest.waitForExplicitFinish(); + + function runTest() { + // The mochitest page is added to history. + waitForClearHistory(continue_test); + } + + function continue_test() { + + var hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + var bh = hs.QueryInterface(Ci.nsIBrowserHistory); + var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + + function uri(spec) { + return Services.io.newURI(spec); + } + + var midnight = new Date(); + midnight.setHours(0); + midnight.setMinutes(0); + midnight.setSeconds(0); + midnight.setMilliseconds(0); + + function addVisitsCallback() { + // add a bookmark to the midnight visit + var itemId = bs.insertBookmark(bs.toolbarFolder, + uri("http://at.midnight.com/"), + bs.DEFAULT_INDEX, + "A bookmark at midnight"); + // Make a history query. + var query = hs.getNewQuery(); + var opts = hs.getNewQueryOptions(); + var queryURI = hs.queriesToQueryString([query], 1, opts); + + // Setup the places tree contents. + var tree = document.getElementById("tree"); + tree.place = queryURI; + + // loop through the rows and check formatting + var treeView = tree.view; + var rc = treeView.rowCount; + ok(rc >= 3, "Rows found"); + var columns = tree.columns; + ok(columns.count > 0, "Columns found"); + for (var r = 0; r < rc; r++) { + var node = treeView.nodeForTreeIndex(r); + ok(node, "Places node found"); + for (var ci = 0; ci < columns.count; ci++) { + var c = columns.getColumnAt(ci); + var text = treeView.getCellText(r, c); + switch (c.element.getAttribute("anonid")) { + case "title": + // The title can differ, we did not set any title so we would + // expect null, but in such a case the view will generate a title + // through PlacesUIUtils.getBestTitle. + if (node.title) + is(text, node.title, "Title is correct"); + break; + case "url": + is(text, node.uri, "Uri is correct"); + break; + case "date": + var timeObj = new Date(node.time / 1000); + // Default is short date format. + let dtOptions = { dateStyle: "short", timeStyle: "short" }; + // For today's visits we don't show date portion. + if (node.uri == "http://at.midnight.com/" || + node.uri == "http://after.midnight.com/") { + dtOptions.dateStyle = undefined; + } else if (node.uri != "http://before.midnight.com/") { + // Avoid to test spurious uris, due to how the test works + // a redirecting uri could be put in the tree while we test. + break; + } + let timeStr = new Services.intl.DateTimeFormat(undefined, dtOptions).format(timeObj); + is(text, timeStr, "Date format is correct"); + break; + case "visitCount": + is(text, 1, "Visit count is correct"); + break; + } + } + } + // Cleanup. + bs.removeItem(itemId); + waitForClearHistory(SimpleTest.finish); + } + + // Add a visit 1ms before midnight, a visit at midnight, and a visit 1ms + // after midnight. + addVisits( + [{uri: uri("http://before.midnight.com/"), + visitDate: (midnight.getTime() - 1) * 1000, + transition: hs.TRANSITION_TYPED}, + {uri: uri("http://at.midnight.com/"), + visitDate: (midnight.getTime()) * 1000, + transition: hs.TRANSITION_TYPED}, + {uri: uri("http://after.midnight.com/"), + visitDate: (midnight.getTime() + 1) * 1000, + transition: hs.TRANSITION_TYPED}], + addVisitsCallback); + + } + + /** + * Clears history invoking callback when done. + */ + function waitForClearHistory(aCallback) { + const TOPIC_EXPIRATION_FINISHED = "places-expiration-finished"; + let observer = { + observe: function(aSubject, aTopic, aData) { + Services.obs.removeObserver(this, TOPIC_EXPIRATION_FINISHED); + aCallback(); + } + }; + Services.obs.addObserver(observer, TOPIC_EXPIRATION_FINISHED); + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + hs.QueryInterface(Ci.nsIBrowserHistory).removeAllPages(); + } + + ]]> + </script> +</window> diff --git a/comm/suite/components/places/tests/head_common.js b/comm/suite/components/places/tests/head_common.js new file mode 100644 index 0000000000..88ecb6d6ba --- /dev/null +++ b/comm/suite/components/places/tests/head_common.js @@ -0,0 +1,868 @@ +/* -*- 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/. */ + +const CURRENT_SCHEMA_VERSION = 33; +const FIRST_UPGRADABLE_SCHEMA_VERSION = 11; + +const NS_APP_USER_PROFILE_50_DIR = "ProfD"; +const NS_APP_PROFILE_DIR_STARTUP = "ProfDS"; + +// Shortcuts to transitions type. +const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK; +const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED; +const TRANSITION_BOOKMARK = Ci.nsINavHistoryService.TRANSITION_BOOKMARK; +const TRANSITION_EMBED = Ci.nsINavHistoryService.TRANSITION_EMBED; +const TRANSITION_FRAMED_LINK = Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK; +const TRANSITION_REDIRECT_PERMANENT = Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT; +const TRANSITION_REDIRECT_TEMPORARY = Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY; +const TRANSITION_DOWNLOAD = Ci.nsINavHistoryService.TRANSITION_DOWNLOAD; +const TRANSITION_RELOAD = Ci.nsINavHistoryService.TRANSITION_RELOAD; + +const TITLE_LENGTH_MAX = 4096; + +Cu.importGlobalProperties(["URL"]); + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +ChromeUtils.defineModuleGetter(this, "Promise", + "resource://gre/modules/Promise.jsm"); +ChromeUtils.defineModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter(this, "BookmarkJSONUtils", + "resource://gre/modules/BookmarkJSONUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "BookmarkHTMLUtils", + "resource://gre/modules/BookmarkHTMLUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "PlacesBackups", + "resource://gre/modules/PlacesBackups.jsm"); +ChromeUtils.defineModuleGetter(this, "PlacesTestUtils", + "resource://testing-common/PlacesTestUtils.jsm"); +ChromeUtils.defineModuleGetter(this, "PlacesTransactions", + "resource://gre/modules/PlacesTransactions.jsm"); +ChromeUtils.defineModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); + +// This imports various other objects in addition to PlacesUtils. +var {PlacesUtils} = ChromeUtils.import("resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyGetter(this, "SMALLPNG_DATA_URI", function() { + return NetUtil.newURI( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAA" + + "AAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="); +}); +XPCOMUtils.defineLazyGetter(this, "SMALLSVG_DATA_URI", function() { + return NetUtil.newURI( + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy5" + + "3My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBmaWxs" + + "PSIjNDI0ZTVhIj4NCiAgPGNpcmNsZSBjeD0iNTAiIGN5PSI1MCIgcj0iN" + + "DQiIHN0cm9rZT0iIzQyNGU1YSIgc3Ryb2tlLXdpZHRoPSIxMSIgZmlsbD" + + "0ibm9uZSIvPg0KICA8Y2lyY2xlIGN4PSI1MCIgY3k9IjI0LjYiIHI9IjY" + + "uNCIvPg0KICA8cmVjdCB4PSI0NSIgeT0iMzkuOSIgd2lkdGg9IjEwLjEi" + + "IGhlaWdodD0iNDEuOCIvPg0KPC9zdmc%2BDQo%3D"); +}); + +var gTestDir = do_get_cwd(); + +// Initialize profile. +var gProfD = do_get_profile(); + +// Remove any old database. +clearDB(); + +/** + * Shortcut to create a nsIURI. + * + * @param aSpec + * URLString of the uri. + */ +function uri(aSpec) { + return NetUtil.newURI(aSpec); +} + + +/** + * Gets the database connection. If the Places connection is invalid it will + * try to create a new connection. + * + * @param [optional] aForceNewConnection + * Forces creation of a new connection to the database. When a + * connection is asyncClosed it cannot anymore schedule async statements, + * though connectionReady will keep returning true (Bug 726990). + * + * @return The database connection or null if unable to get one. + */ +var gDBConn; +function DBConn(aForceNewConnection) { + if (!aForceNewConnection) { + let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) + .DBConnection; + if (db.connectionReady) + return db; + } + + // If the Places database connection has been closed, create a new connection. + if (!gDBConn || aForceNewConnection) { + let file = Services.dirsvc.get('ProfD', Ci.nsIFile); + file.append("places.sqlite"); + let dbConn = gDBConn = Services.storage.openDatabase(file); + + // Be sure to cleanly close this connection. + promiseTopicObserved("profile-before-change").then(() => dbConn.asyncClose()); + } + + return gDBConn.connectionReady ? gDBConn : null; +} + +/** + * Reads data from the provided inputstream. + * + * @return an array of bytes. + */ +function readInputStreamData(aStream) { + let bistream = Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(Ci.nsIBinaryInputStream); + try { + bistream.setInputStream(aStream); + let expectedData = []; + let avail; + while ((avail = bistream.available())) { + expectedData = expectedData.concat(bistream.readByteArray(avail)); + } + return expectedData; + } finally { + bistream.close(); + } +} + +/** + * Reads the data from the specified nsIFile. + * + * @param aFile + * The nsIFile to read from. + * @return an array of bytes. + */ +function readFileData(aFile) { + let inputStream = Cc["@mozilla.org/network/file-input-stream;1"]. + createInstance(Ci.nsIFileInputStream); + // init the stream as RD_ONLY, -1 == default permissions. + inputStream.init(aFile, 0x01, -1, null); + + // Check the returned size versus the expected size. + let size = inputStream.available(); + let bytes = readInputStreamData(inputStream); + if (size != bytes.length) { + throw "Didn't read expected number of bytes"; + } + return bytes; +} + +/** + * Reads the data from the named file, verifying the expected file length. + * + * @param aFileName + * This file should be located in the same folder as the test. + * @param aExpectedLength + * Expected length of the file. + * + * @return The array of bytes read from the file. + */ +function readFileOfLength(aFileName, aExpectedLength) { + let data = readFileData(do_get_file(aFileName)); + Assert.equal(data.length, aExpectedLength); + return data; +} + + +/** + * Returns the base64-encoded version of the given string. This function is + * similar to window.btoa, but is available to xpcshell tests also. + * + * @param aString + * Each character in this string corresponds to a byte, and must be a + * code point in the range 0-255. + * + * @return The base64-encoded string. + */ +function base64EncodeString(aString) { + var stream = Cc["@mozilla.org/io/string-input-stream;1"] + .createInstance(Ci.nsIStringInputStream); + stream.setData(aString, aString.length); + var encoder = Cc["@mozilla.org/scriptablebase64encoder;1"] + .createInstance(Ci.nsIScriptableBase64Encoder); + return encoder.encodeToString(stream, aString.length); +} + + +/** + * Compares two arrays, and returns true if they are equal. + * + * @param aArray1 + * First array to compare. + * @param aArray2 + * Second array to compare. + */ +function compareArrays(aArray1, aArray2) { + if (aArray1.length != aArray2.length) { + print("compareArrays: array lengths differ\n"); + return false; + } + + for (let i = 0; i < aArray1.length; i++) { + if (aArray1[i] != aArray2[i]) { + print("compareArrays: arrays differ at index " + i + ": " + + "(" + aArray1[i] + ") != (" + aArray2[i] +")\n"); + return false; + } + } + + return true; +} + + +/** + * Deletes a previously created sqlite file from the profile folder. + */ +function clearDB() { + try { + let file = Services.dirsvc.get('ProfD', Ci.nsIFile); + file.append("places.sqlite"); + if (file.exists()) + file.remove(false); + } catch (ex) { dump("Exception: " + ex); } +} + + +/** + * Dumps the rows of a table out to the console. + * + * @param aName + * The name of the table or view to output. + */ +function dump_table(aName) +{ + let stmt = DBConn().createStatement("SELECT * FROM " + aName); + + print("\n*** Printing data from " + aName); + let count = 0; + while (stmt.executeStep()) { + let columns = stmt.numEntries; + + if (count == 0) { + // Print the column names. + for (let i = 0; i < columns; i++) + dump(stmt.getColumnName(i) + "\t"); + dump("\n"); + } + + // Print the rows. + for (let i = 0; i < columns; i++) { + switch (stmt.getTypeOfIndex(i)) { + case Ci.mozIStorageValueArray.VALUE_TYPE_NULL: + dump("NULL\t"); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_INTEGER: + dump(stmt.getInt64(i) + "\t"); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_FLOAT: + dump(stmt.getDouble(i) + "\t"); + break; + case Ci.mozIStorageValueArray.VALUE_TYPE_TEXT: + dump(stmt.getString(i) + "\t"); + break; + } + } + dump("\n"); + + count++; + } + print("*** There were a total of " + count + " rows of data.\n"); + + stmt.finalize(); +} + + +/** + * Checks if an address is found in the database. + * @param aURI + * nsIURI or address to look for. + * @return place id of the page or 0 if not found + */ +function page_in_database(aURI) +{ + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = DBConn().createStatement( + "SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url" + ); + stmt.params.url = url; + try { + if (!stmt.executeStep()) + return 0; + return stmt.getInt64(0); + } + finally { + stmt.finalize(); + } +} + +/** + * Checks how many visits exist for a specified page. + * @param aURI + * nsIURI or address to look for. + * @return number of visits found. + */ +function visits_in_database(aURI) +{ + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = DBConn().createStatement( + `SELECT count(*) FROM moz_historyvisits v + JOIN moz_places h ON h.id = v.place_id + WHERE url_hash = hash(:url) AND url = :url` + ); + stmt.params.url = url; + try { + if (!stmt.executeStep()) + return 0; + return stmt.getInt64(0); + } + finally { + stmt.finalize(); + } +} + +/** + * Checks that we don't have any bookmark + */ +function check_no_bookmarks() { + let query = PlacesUtils.history.getNewQuery(); + let folders = [ + PlacesUtils.bookmarks.toolbarFolder, + PlacesUtils.bookmarks.bookmarksMenuFolder, + PlacesUtils.bookmarks.unfiledBookmarksFolder, + ]; + query.setFolders(folders, 3); + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + let root = PlacesUtils.history.executeQuery(query, options).root; + root.containerOpen = true; + if (root.childCount != 0) + do_throw("Unable to remove all bookmarks"); + root.containerOpen = false; +} + +/** + * Allows waiting for an observer notification once. + * + * @param aTopic + * Notification topic to observe. + * + * @return {Promise} + * @resolves The array [aSubject, aData] from the observed notification. + * @rejects Never. + */ +function promiseTopicObserved(aTopic) +{ + return new Promise(resolve => { + Services.obs.addObserver(function observe(aSubject, aTopic, aData) { + Services.obs.removeObserver(observe, aTopic); + resolve([aSubject, aData]); + }, aTopic); + }); +} + +/** + * Simulates a Places shutdown. + */ +var shutdownPlaces = function() { + info("shutdownPlaces: starting"); + let promise = new Promise(resolve => { + Services.obs.addObserver(resolve, "places-connection-closed"); + }); + let hs = PlacesUtils.history.QueryInterface(Ci.nsIObserver); + hs.observe(null, "profile-change-teardown", null); + info("shutdownPlaces: sent profile-change-teardown"); + hs.observe(null, "test-simulate-places-shutdown", null); + info("shutdownPlaces: sent test-simulate-places-shutdown"); + return promise.then(() => { + info("shutdownPlaces: complete"); + }); +}; + +const FILENAME_BOOKMARKS_HTML = "bookmarks.html"; +const FILENAME_BOOKMARKS_JSON = "bookmarks-" + + (PlacesBackups.toISODateString(new Date())) + ".json"; + +/** + * Creates a bookmarks.html file in the profile folder from a given source file. + * + * @param aFilename + * Name of the file to copy to the profile folder. This file must + * exist in the directory that contains the test files. + * + * @return nsIFile object for the file. + */ +function create_bookmarks_html(aFilename) { + if (!aFilename) + do_throw("you must pass a filename to create_bookmarks_html function"); + remove_bookmarks_html(); + let bookmarksHTMLFile = gTestDir.clone(); + bookmarksHTMLFile.append(aFilename); + Assert.ok(bookmarksHTMLFile.exists()); + bookmarksHTMLFile.copyTo(gProfD, FILENAME_BOOKMARKS_HTML); + let profileBookmarksHTMLFile = gProfD.clone(); + profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML); + Assert.ok(profileBookmarksHTMLFile.exists()); + return profileBookmarksHTMLFile; +} + + +/** + * Remove bookmarks.html file from the profile folder. + */ +function remove_bookmarks_html() { + let profileBookmarksHTMLFile = gProfD.clone(); + profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML); + if (profileBookmarksHTMLFile.exists()) { + profileBookmarksHTMLFile.remove(false); + Assert.ok(!profileBookmarksHTMLFile.exists()); + } +} + + +/** + * Check bookmarks.html file exists in the profile folder. + * + * @return nsIFile object for the file. + */ +function check_bookmarks_html() { + let profileBookmarksHTMLFile = gProfD.clone(); + profileBookmarksHTMLFile.append(FILENAME_BOOKMARKS_HTML); + Assert.ok(profileBookmarksHTMLFile.exists()); + return profileBookmarksHTMLFile; +} + + +/** + * Creates a JSON backup in the profile folder folder from a given source file. + * + * @param aFilename + * Name of the file to copy to the profile folder. This file must + * exist in the directory that contains the test files. + * + * @return nsIFile object for the file. + */ +function create_JSON_backup(aFilename) { + if (!aFilename) + do_throw("you must pass a filename to create_JSON_backup function"); + let bookmarksBackupDir = gProfD.clone(); + bookmarksBackupDir.append("bookmarkbackups"); + if (!bookmarksBackupDir.exists()) { + bookmarksBackupDir.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt("0755", 8)); + Assert.ok(bookmarksBackupDir.exists()); + } + let profileBookmarksJSONFile = bookmarksBackupDir.clone(); + profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON); + if (profileBookmarksJSONFile.exists()) { + profileBookmarksJSONFile.remove(); + } + let bookmarksJSONFile = gTestDir.clone(); + bookmarksJSONFile.append(aFilename); + Assert.ok(bookmarksJSONFile.exists()); + bookmarksJSONFile.copyTo(bookmarksBackupDir, FILENAME_BOOKMARKS_JSON); + profileBookmarksJSONFile = bookmarksBackupDir.clone(); + profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON); + Assert.ok(profileBookmarksJSONFile.exists()); + return profileBookmarksJSONFile; +} + + +/** + * Remove bookmarksbackup dir and all backups from the profile folder. + */ +function remove_all_JSON_backups() { + let bookmarksBackupDir = gProfD.clone(); + bookmarksBackupDir.append("bookmarkbackups"); + if (bookmarksBackupDir.exists()) { + bookmarksBackupDir.remove(true); + Assert.ok(!bookmarksBackupDir.exists()); + } +} + + +/** + * Check a JSON backup file for today exists in the profile folder. + * + * @param aIsAutomaticBackup The boolean indicates whether it's an automatic + * backup. + * @return nsIFile object for the file. + */ +function check_JSON_backup(aIsAutomaticBackup) { + let profileBookmarksJSONFile; + if (aIsAutomaticBackup) { + let bookmarksBackupDir = gProfD.clone(); + bookmarksBackupDir.append("bookmarkbackups"); + let files = bookmarksBackupDir.directoryEntries; + let backup_date = PlacesBackups.toISODateString(new Date()); + while (files.hasMoreElements()) { + let entry = files.nextFile; + if (PlacesBackups.filenamesRegex.test(entry.leafName)) { + profileBookmarksJSONFile = entry; + break; + } + } + } else { + profileBookmarksJSONFile = gProfD.clone(); + profileBookmarksJSONFile.append("bookmarkbackups"); + profileBookmarksJSONFile.append(FILENAME_BOOKMARKS_JSON); + } + Assert.ok(profileBookmarksJSONFile.exists()); + return profileBookmarksJSONFile; +} + +/** + * Returns the frecency of a url. + * + * @param aURI + * The URI or spec to get frecency for. + * @return the frecency value. + */ +function frecencyForUrl(aURI) +{ + let url = aURI; + if (aURI instanceof Ci.nsIURI) { + url = aURI.spec; + } else if (aURI instanceof URL) { + url = aURI.href; + } + let stmt = DBConn().createStatement( + "SELECT frecency FROM moz_places WHERE url_hash = hash(?1) AND url = ?1" + ); + stmt.bindByIndex(0, url); + try { + if (!stmt.executeStep()) { + throw new Error("No result for frecency."); + } + return stmt.getInt32(0); + } finally { + stmt.finalize(); + } +} + +/** + * Returns the hidden status of a url. + * + * @param aURI + * The URI or spec to get hidden for. + * @return @return true if the url is hidden, false otherwise. + */ +function isUrlHidden(aURI) +{ + let url = aURI instanceof Ci.nsIURI ? aURI.spec : aURI; + let stmt = DBConn().createStatement( + "SELECT hidden FROM moz_places WHERE url_hash = hash(?1) AND url = ?1" + ); + stmt.bindByIndex(0, url); + if (!stmt.executeStep()) + throw new Error("No result for hidden."); + let hidden = stmt.getInt32(0); + stmt.finalize(); + + return !!hidden; +} + +/** + * Compares two times in usecs, considering eventual platform timers skews. + * + * @param aTimeBefore + * The older time in usecs. + * @param aTimeAfter + * The newer time in usecs. + * @return true if times are ordered, false otherwise. + */ +function is_time_ordered(before, after) { + // Windows has an estimated 16ms timers precision, since Date.now() and + // PR_Now() use different code atm, the results can be unordered by this + // amount of time. See bug 558745 and bug 557406. + let isWindows = ("@mozilla.org/windows-registry-key;1" in Cc); + // Just to be safe we consider 20ms. + let skew = isWindows ? 20000000 : 0; + return after - before > -skew; +} + +/** + * Shutdowns Places, invoking the callback when the connection has been closed. + * + * @param aCallback + * Function to be called when done. + */ +function waitForConnectionClosed(aCallback) +{ + promiseTopicObserved("places-connection-closed").then(aCallback); + shutdownPlaces(); +} + +/** + * Tests if a given guid is valid for use in Places or not. + * + * @param aGuid + * The guid to test. + * @param [optional] aStack + * The stack frame used to report the error. + */ +function do_check_valid_places_guid(aGuid, + aStack) +{ + if (!aStack) { + aStack = Components.stack.caller; + } + Assert.ok(/^[a-zA-Z0-9\-_]{12}$/.test(aGuid), aStack); +} + +/** + * Retrieves the guid for a given uri. + * + * @param aURI + * The uri to check. + * @param [optional] aStack + * The stack frame used to report the error. + * @return the associated the guid. + */ +function do_get_guid_for_uri(aURI, + aStack) +{ + if (!aStack) { + aStack = Components.stack.caller; + } + let stmt = DBConn().createStatement( + `SELECT guid + FROM moz_places + WHERE url_hash = hash(:url) AND url = :url` + ); + stmt.params.url = aURI.spec; + Assert.ok(stmt.executeStep(), aStack); + let guid = stmt.row.guid; + stmt.finalize(); + do_check_valid_places_guid(guid, aStack); + return guid; +} + +/** + * Tests that a guid was set in moz_places for a given uri. + * + * @param aURI + * The uri to check. + * @param [optional] aGUID + * The expected guid in the database. + */ +function do_check_guid_for_uri(aURI, + aGUID) +{ + let caller = Components.stack.caller; + let guid = do_get_guid_for_uri(aURI, caller); + if (aGUID) { + do_check_valid_places_guid(aGUID, caller); + Assert.equal(guid, aGUID, caller); + } +} + +/** + * Retrieves the guid for a given bookmark. + * + * @param aId + * The bookmark id to check. + * @param [optional] aStack + * The stack frame used to report the error. + * @return the associated the guid. + */ +function do_get_guid_for_bookmark(aId, + aStack) +{ + if (!aStack) { + aStack = Components.stack.caller; + } + let stmt = DBConn().createStatement( + `SELECT guid + FROM moz_bookmarks + WHERE id = :item_id` + ); + stmt.params.item_id = aId; + Assert.ok(stmt.executeStep(), aStack); + let guid = stmt.row.guid; + stmt.finalize(); + do_check_valid_places_guid(guid, aStack); + return guid; +} + +/** + * Tests that a guid was set in moz_places for a given bookmark. + * + * @param aId + * The bookmark id to check. + * @param [optional] aGUID + * The expected guid in the database. + */ +function do_check_guid_for_bookmark(aId, + aGUID) +{ + let caller = Components.stack.caller; + let guid = do_get_guid_for_bookmark(aId, caller); + if (aGUID) { + do_check_valid_places_guid(aGUID, caller); + Assert.equal(guid, aGUID, caller); + } +} + +/** + * Compares 2 arrays returning whether they contains the same elements. + * + * @param a1 + * First array to compare. + * @param a2 + * Second array to compare. + * @param [optional] sorted + * Whether the comparison should take in count position of the elements. + * @return true if the arrays contain the same elements, false otherwise. + */ +function do_compare_arrays(a1, a2, sorted) +{ + if (a1.length != a2.length) + return false; + + if (sorted) { + return a1.every((e, i) => e == a2[i]); + } + return a1.filter(e => !a2.includes(e)).length == 0 && + a2.filter(e => !a1.includes(e)).length == 0; +} + +/** + * Generic nsINavBookmarkObserver that doesn't implement anything, but provides + * dummy methods to prevent errors about an object not having a certain method. + */ +function NavBookmarkObserver() {} + +NavBookmarkObserver.prototype = { + onBeginUpdateBatch: function () {}, + onEndUpdateBatch: function () {}, + onItemAdded: function () {}, + onItemRemoved: function () {}, + onItemChanged: function () {}, + onItemVisited: function () {}, + onItemMoved: function () {}, + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsINavBookmarkObserver, + ]) +}; + +/** + * Generic nsINavHistoryObserver that doesn't implement anything, but provides + * dummy methods to prevent errors about an object not having a certain method. + */ +function NavHistoryObserver() {} + +NavHistoryObserver.prototype = { + onBeginUpdateBatch: function () {}, + onEndUpdateBatch: function () {}, + onVisit: function () {}, + onTitleChanged: function () {}, + onDeleteURI: function () {}, + onClearHistory: function () {}, + onPageChanged: function () {}, + onDeleteVisits: function () {}, + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsINavHistoryObserver, + ]) +}; + +/** + * Generic nsINavHistoryResultObserver that doesn't implement anything, but + * provides dummy methods to prevent errors about an object not having a certain + * method. + */ +function NavHistoryResultObserver() {} + +NavHistoryResultObserver.prototype = { + batching: function () {}, + containerStateChanged: function () {}, + invalidateContainer: function () {}, + nodeAnnotationChanged: function () {}, + nodeDateAddedChanged: function () {}, + nodeHistoryDetailsChanged: function () {}, + nodeIconChanged: function () {}, + nodeInserted: function () {}, + nodeKeywordChanged: function () {}, + nodeLastModifiedChanged: function () {}, + nodeMoved: function () {}, + nodeRemoved: function () {}, + nodeTagsChanged: function () {}, + nodeTitleChanged: function () {}, + nodeURIChanged: function () {}, + sortingChanged: function () {}, + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsINavHistoryResultObserver, + ]) +}; + +/** + * Asynchronously check a url is visited. + * + * @param aURI The URI. + * @return {Promise} + * @resolves When the check has been added successfully. + * @rejects JavaScript exception. + */ +function promiseIsURIVisited(aURI) { + let deferred = Promise.defer(); + + PlacesUtils.asyncHistory.isURIVisited(aURI, function(aURI, aIsVisited) { + deferred.resolve(aIsVisited); + }); + + return deferred.promise; +} + +/** + * Asynchronously set the favicon associated with a page. + * @param aPageURI + * The page's URI + * @param aIconURI + * The URI of the favicon to be set. + */ +function promiseSetIconForPage(aPageURI, aIconURI) { + let deferred = Promise.defer(); + PlacesUtils.favicons.setAndFetchFaviconForPage( + aPageURI, aIconURI, true, + PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + () => { deferred.resolve(); }, + Services.scriptSecurityManager.getSystemPrincipal()); + return deferred.promise; +} + +function checkBookmarkObject(info) { + do_check_valid_places_guid(info.guid); + do_check_valid_places_guid(info.parentGuid); + Assert.ok(typeof info.index == "number", "index should be a number"); + Assert.ok(info.dateAdded.constructor.name == "Date", "dateAdded should be a Date"); + Assert.ok(info.lastModified.constructor.name == "Date", "lastModified should be a Date"); + Assert.ok(info.lastModified >= info.dateAdded, "lastModified should never be smaller than dateAdded"); + Assert.ok(typeof info.type == "number", "type should be a number"); +} + +/** + * Reads foreign_count value for a given url. + */ +async function foreign_count(url) { + if (url instanceof Ci.nsIURI) + url = url.spec; + let db = await PlacesUtils.promiseDBConnection(); + let rows = await db.executeCached( + `SELECT foreign_count FROM moz_places + WHERE url_hash = hash(:url) AND url = :url + `, { url }); + return rows.length == 0 ? 0 : rows[0].getResultByName("foreign_count"); +} diff --git a/comm/suite/components/places/tests/unit/bookmarks.glue.html b/comm/suite/components/places/tests/unit/bookmarks.glue.html new file mode 100644 index 0000000000..07b22e9b3f --- /dev/null +++ b/comm/suite/components/places/tests/unit/bookmarks.glue.html @@ -0,0 +1,16 @@ +<!DOCTYPE NETSCAPE-Bookmark-file-1> +<!-- This is an automatically generated file. + It will be read and overwritten. + DO NOT EDIT! --> +<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8"> +<TITLE>Bookmarks</TITLE> +<H1>Bookmarks Menu</H1> + +<DL><p> + <DT><A HREF="http://example.com/" ADD_DATE="1233157972" LAST_MODIFIED="1233157984">example</A> + <DT><H3 ADD_DATE="1233157910" LAST_MODIFIED="1233157972" PERSONAL_TOOLBAR_FOLDER="true">Bookmarks Toolbar</H3> +<DD>Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar + <DL><p> + <DT><A HREF="http://example.com/" ADD_DATE="1233157972" LAST_MODIFIED="1233157984">example</A> + </DL><p> +</DL><p> diff --git a/comm/suite/components/places/tests/unit/bookmarks.glue.json b/comm/suite/components/places/tests/unit/bookmarks.glue.json new file mode 100644 index 0000000000..8ca855ad42 --- /dev/null +++ b/comm/suite/components/places/tests/unit/bookmarks.glue.json @@ -0,0 +1 @@ +{"title":"","id":1,"dateAdded":1233157910552624,"lastModified":1233157955206833,"type":"text/x-moz-place-container","root":"placesRoot","children":[{"title":"Bookmarks Menu","id":2,"parent":1,"dateAdded":1233157910552624,"lastModified":1233157993171424,"type":"text/x-moz-place-container","root":"bookmarksMenuFolder","children":[{"title":"examplejson","id":27,"parent":2,"dateAdded":1233157972101126,"lastModified":1233157984999673,"type":"text/x-moz-place","uri":"http://example.com/"}]},{"index":1,"title":"Bookmarks Toolbar","id":3,"parent":1,"dateAdded":1233157910552624,"lastModified":1233157972101126,"annos":[{"name":"bookmarkProperties/description","flags":0,"expires":4,"mimeType":null,"type":3,"value":"Add bookmarks to this folder to see them displayed on the Bookmarks Toolbar"}],"type":"text/x-moz-place-container","root":"toolbarFolder","children":[{"title":"examplejson","id":26,"parent":3,"dateAdded":1233157972101126,"lastModified":1233157984999673,"type":"text/x-moz-place","uri":"http://example.com/"}]},{"index":2,"title":"Tags","id":4,"parent":1,"dateAdded":1233157910552624,"lastModified":1233157910582667,"type":"text/x-moz-place-container","root":"tagsFolder","children":[]},{"index":3,"title":"Unsorted Bookmarks","id":5,"parent":1,"dateAdded":1233157910552624,"lastModified":1233157911033315,"type":"text/x-moz-place-container","root":"unfiledBookmarksFolder","children":[]}]}
\ No newline at end of file diff --git a/comm/suite/components/places/tests/unit/corruptDB.sqlite b/comm/suite/components/places/tests/unit/corruptDB.sqlite Binary files differnew file mode 100644 index 0000000000..b234246cac --- /dev/null +++ b/comm/suite/components/places/tests/unit/corruptDB.sqlite diff --git a/comm/suite/components/places/tests/unit/distribution.ini b/comm/suite/components/places/tests/unit/distribution.ini new file mode 100644 index 0000000000..f94a1be3c5 --- /dev/null +++ b/comm/suite/components/places/tests/unit/distribution.ini @@ -0,0 +1,21 @@ +# Distribution Configuration File +# Bug 516444 demo + +[Global] +id=516444 +version=1.0 +about=Test distribution file + +[BookmarksToolbar] +item.1.title=Toolbar Link Before +item.1.link=http://mozilla.com/ +item.2.type=default +item.3.title=Toolbar Link After +item.3.link=http://mozilla.com/ + +[BookmarksMenu] +item.1.title=Menu Link Before +item.1.link=http://mozilla.com/ +item.2.type=default +item.3.title=Menu Link After +item.3.link=http://mozilla.com/
\ No newline at end of file diff --git a/comm/suite/components/places/tests/unit/head_bookmarks.js b/comm/suite/components/places/tests/unit/head_bookmarks.js new file mode 100644 index 0000000000..0a795620fd --- /dev/null +++ b/comm/suite/components/places/tests/unit/head_bookmarks.js @@ -0,0 +1,46 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 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 {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +// Import common head. +var commonFile = do_get_file("../head_common.js", false); +var uri = Services.io.newFileURI(commonFile); +Services.scriptloader.loadSubScript(uri.spec, this); + +// Put any other stuff relative to this test folder below. + + +XPCOMUtils.defineLazyGetter(this, "PlacesUIUtils", function() { + const {PlacesUIUtils} = ChromeUtils.import("resource:///modules/PlacesUIUtils.jsm"); + return PlacesUIUtils; +}); + + +const ORGANIZER_FOLDER_ANNO = "PlacesOrganizer/OrganizerFolder"; +const ORGANIZER_QUERY_ANNO = "PlacesOrganizer/OrganizerQuery"; + + +// Needed by some test that relies on having an app registered. +ChromeUtils.import("resource://testing-common/AppInfo.jsm", this); +updateAppInfo({ + name: "PlacesTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +// Smart bookmarks constants. +const SMART_BOOKMARKS_VERSION = 4; +// 1 = "Most Visited". +const SMART_BOOKMARKS_ON_TOOLBAR = 1; +// 3 = "Recently Bookmarked", "Recent Tags", separator. +const SMART_BOOKMARKS_ON_MENU = 3; // Takes in count the additional separator. + +// Default bookmarks constants. +// 4 = "SeaMonkey", "mozilla.org", "mozillaZine". +const DEFAULT_BOOKMARKS_ON_TOOLBAR = 3; +// 2 = "SeaMonkey and Mozilla", "Search the Web". +const DEFAULT_BOOKMARKS_ON_MENU = 3; // Takes in count the additional separator. diff --git a/comm/suite/components/places/tests/unit/test_421483.js b/comm/suite/components/places/tests/unit/test_421483.js new file mode 100644 index 0000000000..5315617e58 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_421483.js @@ -0,0 +1,84 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +// Get bookmarks service +try { + var bmsvc = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); +} catch(ex) { + do_throw("Could not get Bookmarks service\n"); +} + +// Get annotation service +try { + var annosvc = Cc["@mozilla.org/browser/annotation-service;1"]. + getService(Ci.nsIAnnotationService); +} catch(ex) { + do_throw("Could not get Annotation service\n"); +} + +// Get browser glue +try { + var gluesvc = Cc["@mozilla.org/suite/suiteglue;1"]. + getService(Ci.nsISuiteGlue); + // Avoid default bookmarks import. + gluesvc.QueryInterface(Ci.nsIObserver).observe(null, "initial-migration", null); +} catch(ex) { + do_throw("Could not get SuiteGlue service\n"); +} + +const SMART_BOOKMARKS_ANNO = "Places/SmartBookmark"; +const SMART_BOOKMARKS_PREF = "browser.places.smartBookmarksVersion"; + +// main +function run_test() { + // TEST 1: smart bookmarks disabled + Services.prefs.setIntPref("browser.places.smartBookmarksVersion", -1); + gluesvc.ensurePlacesDefaultQueriesInitialized(); + var smartBookmarkItemIds = annosvc.getItemsWithAnnotation(SMART_BOOKMARKS_ANNO); + Assert.equal(smartBookmarkItemIds.length, 0); + // check that pref has not been bumped up + Assert.equal(Services.prefs.getIntPref("browser.places.smartBookmarksVersion"), -1); + + // TEST 2: create smart bookmarks + Services.prefs.setIntPref("browser.places.smartBookmarksVersion", 0); + gluesvc.ensurePlacesDefaultQueriesInitialized(); + smartBookmarkItemIds = annosvc.getItemsWithAnnotation(SMART_BOOKMARKS_ANNO); + Assert.notEqual(smartBookmarkItemIds.length, 0); + // check that pref has been bumped up + Assert.ok(Services.prefs.getIntPref("browser.places.smartBookmarksVersion") > 0); + + var smartBookmarksCount = smartBookmarkItemIds.length; + + // TEST 3: smart bookmarks restore + // remove one smart bookmark and restore + bmsvc.removeItem(smartBookmarkItemIds[0]); + Services.prefs.setIntPref("browser.places.smartBookmarksVersion", 0); + gluesvc.ensurePlacesDefaultQueriesInitialized(); + smartBookmarkItemIds = annosvc.getItemsWithAnnotation(SMART_BOOKMARKS_ANNO); + Assert.equal(smartBookmarkItemIds.length, smartBookmarksCount); + // check that pref has been bumped up + Assert.ok(Services.prefs.getIntPref("browser.places.smartBookmarksVersion") > 0); + + // TEST 4: move a smart bookmark, change its title, then restore + // smart bookmark should be restored in place + var parent = bmsvc.getFolderIdForItem(smartBookmarkItemIds[0]); + var oldTitle = bmsvc.getItemTitle(smartBookmarkItemIds[0]); + // create a subfolder and move inside it + var newParent = bmsvc.createFolder(parent, "test", bmsvc.DEFAULT_INDEX); + bmsvc.moveItem(smartBookmarkItemIds[0], newParent, bmsvc.DEFAULT_INDEX); + // change title + bmsvc.setItemTitle(smartBookmarkItemIds[0], "new title"); + // restore + Services.prefs.setIntPref("browser.places.smartBookmarksVersion", 0); + gluesvc.ensurePlacesDefaultQueriesInitialized(); + smartBookmarkItemIds = annosvc.getItemsWithAnnotation(SMART_BOOKMARKS_ANNO); + Assert.equal(smartBookmarkItemIds.length, smartBookmarksCount); + Assert.equal(bmsvc.getFolderIdForItem(smartBookmarkItemIds[0]), newParent); + Assert.equal(bmsvc.getItemTitle(smartBookmarkItemIds[0]), oldTitle); + // check that pref has been bumped up + Assert.ok(Services.prefs.getIntPref("browser.places.smartBookmarksVersion") > 0); +} diff --git a/comm/suite/components/places/tests/unit/test_PUIU_makeTransaction.js b/comm/suite/components/places/tests/unit/test_PUIU_makeTransaction.js new file mode 100644 index 0000000000..ce13f4e2ee --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_PUIU_makeTransaction.js @@ -0,0 +1,355 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function waitForBookmarkNotification(aNotification, aCallback, aProperty) +{ + PlacesUtils.bookmarks.addObserver({ + validate: function (aMethodName, aData) + { + if (aMethodName == aNotification && + (!aProperty || aProperty == aData.property)) { + PlacesUtils.bookmarks.removeObserver(this); + aCallback(aData); + } + }, + + // nsINavBookmarkObserver + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]), + onBeginUpdateBatch: function onBeginUpdateBatch() { + return this.validate(arguments.callee.name, arguments); + }, + onEndUpdateBatch: function onEndUpdateBatch() { + return this.validate(arguments.callee.name, arguments); + }, + onItemAdded: function onItemAdded(aItemId, aParentId, aIndex, aItemType, + aURI, aTitle) + { + return this.validate(arguments.callee.name, { id: aItemId, + index: aIndex, + type: aItemType, + url: aURI ? aURI.spec : null, + title: aTitle }); + }, + onItemRemoved: function onItemRemoved() { + return this.validate(arguments.callee.name, arguments); + }, + onItemChanged: function onItemChanged(aItemId, aProperty, aIsAnno, + aNewValue, aLastModified, aItemType) + { + return this.validate(arguments.callee.name, + { id: aItemId, + get index() { return PlacesUtils.bookmarks.getItemIndex(this.id); }, + type: aItemType, + property: aProperty, + get url() { return aItemType == PlacesUtils.bookmarks.TYPE_BOOKMARK ? + PlacesUtils.bookmarks.getBookmarkURI(this.id).spec : + null; }, + get title() { return PlacesUtils.bookmarks.getItemTitle(this.id); }, + }); + }, + onItemVisited: function onItemVisited() { + return this.validate(arguments.callee.name, arguments); + }, + onItemMoved: function onItemMoved(aItemId, aOldParentId, aOldIndex, + aNewParentId, aNewIndex, aItemType) + { + this.validate(arguments.callee.name, { id: aItemId, + index: aNewIndex, + type: aItemType }); + } + }); +} + +function wrapNodeByIdAndParent(aItemId, aParentId) +{ + let wrappedNode; + let root = PlacesUtils.getFolderContents(aParentId, false, false).root; + for (let i = 0; i < root.childCount; ++i) { + let node = root.getChild(i); + if (node.itemId == aItemId) { + let type; + if (PlacesUtils.nodeIsContainer(node)) { + type = PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER; + } + else if (PlacesUtils.nodeIsURI(node)) { + type = PlacesUtils.TYPE_X_MOZ_PLACE; + } + else if (PlacesUtils.nodeIsSeparator(node)) { + type = PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR; + } + else { + do_throw("Unknown node type"); + } + wrappedNode = PlacesUtils.wrapNode(node, type); + } + } + root.containerOpen = false; + return JSON.parse(wrappedNode); +} + +add_test(function test_text_paste() +{ + const TEST_URL = "http://places.moz.org/" + const TEST_TITLE = "Places bookmark" + + waitForBookmarkNotification("onItemAdded", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.url, TEST_URL); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(aData.index, 0); + run_next_test(); + }); + + let txn = PlacesUIUtils.makeTransaction( + { title: TEST_TITLE, uri: TEST_URL }, + PlacesUtils.TYPE_X_MOZ_URL, + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + true // Unused for text. + ); + PlacesUtils.transactionManager.doTransaction(txn); +}); + +add_test(function test_container() +{ + const TEST_TITLE = "Places folder" + + waitForBookmarkNotification("onItemChanged", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(aData.index, 1); + + waitForBookmarkNotification("onItemAdded", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(aData.index, 2); + let id = aData.id; + + waitForBookmarkNotification("onItemMoved", function(aData) + { + Assert.equal(aData.id, id); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_FOLDER); + Assert.equal(aData.index, 1); + + run_next_test(); + }); + + let txn = PlacesUIUtils.makeTransaction( + wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId), + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + 1, // Move to position 1. + false + ); + PlacesUtils.transactionManager.doTransaction(txn); + }); + + try { + let txn = PlacesUIUtils.makeTransaction( + wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId), + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + true + ); + PlacesUtils.transactionManager.doTransaction(txn); + } catch(ex) { + do_throw(ex); + } + }, "random-anno"); + + let id = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId, + TEST_TITLE, + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.annotations.setItemAnnotation(id, PlacesUIUtils.DESCRIPTION_ANNO, + "description", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + PlacesUtils.annotations.setItemAnnotation(id, "random-anno", + "random-value", 0, + PlacesUtils.annotations.EXPIRE_NEVER); +}); + + +add_test(function test_separator() +{ + waitForBookmarkNotification("onItemChanged", function(aData) + { + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.equal(aData.index, 3); + + waitForBookmarkNotification("onItemAdded", function(aData) + { + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.equal(aData.index, 4); + let id = aData.id; + + waitForBookmarkNotification("onItemMoved", function(aData) + { + Assert.equal(aData.id, id); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_SEPARATOR); + Assert.equal(aData.index, 1); + + run_next_test(); + }); + + let txn = PlacesUIUtils.makeTransaction( + wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId), + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + 1, // Move to position 1. + false + ); + PlacesUtils.transactionManager.doTransaction(txn); + }); + + try { + let txn = PlacesUIUtils.makeTransaction( + wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId), + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + true + ); + PlacesUtils.transactionManager.doTransaction(txn); + } catch(ex) { + do_throw(ex); + } + }, "random-anno"); + + let id = PlacesUtils.bookmarks.insertSeparator(PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.annotations.setItemAnnotation(id, "random-anno", + "random-value", 0, + PlacesUtils.annotations.EXPIRE_NEVER); +}); + +add_test(function test_bookmark() +{ + const TEST_URL = "http://places.moz.org/" + const TEST_TITLE = "Places bookmark" + + waitForBookmarkNotification("onItemChanged", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.url, TEST_URL); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(aData.index, 5); + + waitForBookmarkNotification("onItemAdded", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.url, TEST_URL); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(aData.index, 6); + let id = aData.id; + + waitForBookmarkNotification("onItemMoved", function(aData) + { + Assert.equal(aData.id, id); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(aData.index, 1); + + run_next_test(); + }); + + let txn = PlacesUIUtils.makeTransaction( + wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId), + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + 1, // Move to position 1. + false + ); + PlacesUtils.transactionManager.doTransaction(txn); + }); + + try { + let txn = PlacesUIUtils.makeTransaction( + wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId), + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + true + ); + PlacesUtils.transactionManager.doTransaction(txn); + } catch(ex) { + do_throw(ex); + } + }, "random-anno"); + + let id = PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId, + NetUtil.newURI(TEST_URL), + PlacesUtils.bookmarks.DEFAULT_INDEX, + TEST_TITLE); + PlacesUtils.annotations.setItemAnnotation(id, PlacesUIUtils.DESCRIPTION_ANNO, + "description", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + PlacesUtils.annotations.setItemAnnotation(id, "random-anno", + "random-value", 0, + PlacesUtils.annotations.EXPIRE_NEVER); +}); + +add_test(function test_visit() +{ + const TEST_URL = "http://places.moz.org/" + const TEST_TITLE = "Places bookmark" + + waitForBookmarkNotification("onItemAdded", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.url, TEST_URL); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(aData.index, 7); + + waitForBookmarkNotification("onItemAdded", function(aData) + { + Assert.equal(aData.title, TEST_TITLE); + Assert.equal(aData.url, TEST_URL); + Assert.equal(aData.type, PlacesUtils.bookmarks.TYPE_BOOKMARK); + Assert.equal(aData.index, 8); + run_next_test(); + }); + + try { + let node = wrapNodeByIdAndParent(aData.id, PlacesUtils.unfiledBookmarksFolderId); + // Simulate a not-bookmarked node, will copy it to a new bookmark. + node.id = -1; + let txn = PlacesUIUtils.makeTransaction( + node, + 0, // Unused for real nodes. + PlacesUtils.unfiledBookmarksFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + true + ); + PlacesUtils.transactionManager.doTransaction(txn); + } catch(ex) { + do_throw(ex); + } + }); + + PlacesUtils.bookmarks.insertBookmark(PlacesUtils.unfiledBookmarksFolderId, + NetUtil.newURI(TEST_URL), + PlacesUtils.bookmarks.DEFAULT_INDEX, + TEST_TITLE); +}); + +add_test(function check_annotations() { + // As last step check how many items for each annotation exist. + + // Copies should retain the description annotation. + let descriptions = + PlacesUtils.annotations.getItemsWithAnnotation(PlacesUIUtils.DESCRIPTION_ANNO, {}); + Assert.equal(descriptions.length, 4); + + // Only the original bookmarks should have this annotation. + let others = PlacesUtils.annotations.getItemsWithAnnotation("random-anno", {}); + Assert.equal(others.length, 3); + run_next_test(); +}); + +function run_test() +{ + run_next_test(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_corrupt.js b/comm/suite/components/places/tests/unit/test_browserGlue_corrupt.js new file mode 100644 index 0000000000..b0a3e6fb67 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_corrupt.js @@ -0,0 +1,85 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue correctly restores bookmarks from a JSON backup if + * database is corrupt and one backup is available. + */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "bs", + "@mozilla.org/browser/nav-bookmarks-service;1", + "nsINavBookmarksService"); +XPCOMUtils.defineLazyServiceGetter(this, "anno", + "@mozilla.org/browser/annotation-service;1", + "nsIAnnotationService"); + +var bookmarksObserver = { + onBeginUpdateBatch: function() {}, + onEndUpdateBatch: function() { + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.notEqual(itemId, -1); + if (anno.itemHasAnnotation(itemId, "Places/SmartBookmark")) + continue_test(); + }, + onItemAdded: function() {}, + onItemRemoved: function(id, folder, index, itemType) {}, + onItemChanged: function() {}, + onItemVisited: function(id, visitID, time) {}, + onItemMoved: function() {}, + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]) +}; + +function run_test() { + do_test_pending(); + + // Create our bookmarks.html copying bookmarks.glue.html to the profile + // folder. It should be ignored. + create_bookmarks_html("bookmarks.glue.html"); + + // Create our JSON backup copying bookmarks.glue.json to the profile folder. + create_JSON_backup("bookmarks.glue.json"); + + // Remove current database file. + let db = gProfD.clone(); + db.append("places.sqlite"); + if (db.exists()) { + db.remove(false); + Assert.ok(!db.exists()); + } + // Create a corrupt database. + let corruptDB = gTestDir.clone(); + corruptDB.append("corruptDB.sqlite"); + corruptDB.copyTo(gProfD, "places.sqlite"); + Assert.ok(db.exists()); + + // Initialize nsSuiteGlue before Places. + Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsISuiteGlue); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // Check the database was corrupt. + // nsSuiteGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CORRUPT); + + // The test will continue once restore has finished and smart bookmarks + // have been created. + bs.addObserver(bookmarksObserver); +} + +function continue_test() { + // Check that JSON backup has been restored. + // Notice restore from JSON notification is fired before smart bookmarks creation. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR); + Assert.equal(bs.getItemTitle(itemId), "examplejson"); + + remove_bookmarks_html(); + remove_all_JSON_backups(); + + do_test_finished(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js b/comm/suite/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js new file mode 100644 index 0000000000..aa6ec0e716 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js @@ -0,0 +1,81 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue correctly imports from bookmarks.html if database + * is corrupt but a JSON backup is not available. + */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "bs", + "@mozilla.org/browser/nav-bookmarks-service;1", + "nsINavBookmarksService"); +XPCOMUtils.defineLazyServiceGetter(this, "anno", + "@mozilla.org/browser/annotation-service;1", + "nsIAnnotationService"); + +var bookmarksObserver = { + onBeginUpdateBatch: function() {}, + onEndUpdateBatch: function() { + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.notEqual(itemId, -1); + if (anno.itemHasAnnotation(itemId, "Places/SmartBookmark")) + continue_test(); + }, + onItemAdded: function() {}, + onItemRemoved: function(id, folder, index, itemType) {}, + onItemChanged: function() {}, + onItemVisited: function(id, visitID, time) {}, + onItemMoved: function() {}, + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]) +}; + +function run_test() { + do_test_pending(); + + // Create bookmarks.html in the profile. + create_bookmarks_html("bookmarks.glue.html"); + // Remove JSON backup from profile. + remove_all_JSON_backups(); + + // Remove current database file. + let db = gProfD.clone(); + db.append("places.sqlite"); + if (db.exists()) { + db.remove(false); + Assert.ok(!db.exists()); + } + // Create a corrupt database. + let corruptDB = gTestDir.clone(); + corruptDB.append("corruptDB.sqlite"); + corruptDB.copyTo(gProfD, "places.sqlite"); + Assert.ok(db.exists()); + + // Initialize nsSuiteGlue before Places. + Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsISuiteGlue); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // Check the database was corrupt. + // nsSuiteGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CORRUPT); + + // The test will continue once import has finished and smart bookmarks + // have been created. + bs.addObserver(bookmarksObserver); +} + +function continue_test() { + // Check that bookmarks html has been restored. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR); + Assert.equal(bs.getItemTitle(itemId), "example"); + + remove_bookmarks_html(); + + do_test_finished(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js b/comm/suite/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js new file mode 100644 index 0000000000..54a6bc829f --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js @@ -0,0 +1,80 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue correctly restores default bookmarks if database is + * corrupt, nor a JSON backup nor bookmarks.html are available. + */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "bs", + "@mozilla.org/browser/nav-bookmarks-service;1", + "nsINavBookmarksService"); +XPCOMUtils.defineLazyServiceGetter(this, "anno", + "@mozilla.org/browser/annotation-service;1", + "nsIAnnotationService"); + +var bookmarksObserver = { + onBeginUpdateBatch: function() {}, + onEndUpdateBatch: function() { + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.notEqual(itemId, -1); + if (anno.itemHasAnnotation(itemId, "Places/SmartBookmark")) + continue_test(); + }, + onItemAdded: function() {}, + onItemRemoved: function(id, folder, index, itemType) {}, + onItemChanged: function() {}, + onItemVisited: function(id, visitID, time) {}, + onItemMoved: function() {}, + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]) +}; + +function run_test() { + do_test_pending(); + + // Remove bookmarks.html from profile. + remove_bookmarks_html(); + // Remove JSON backup from profile. + remove_all_JSON_backups(); + + // Remove current database file. + let db = gProfD.clone(); + db.append("places.sqlite"); + if (db.exists()) { + db.remove(false); + Assert.ok(!db.exists()); + } + // Create a corrupt database. + let corruptDB = gTestDir.clone(); + corruptDB.append("corruptDB.sqlite"); + corruptDB.copyTo(gProfD, "places.sqlite"); + Assert.ok(db.exists()); + + // Initialize nsSuiteGlue before Places. + Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsISuiteGlue); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // Check the database was corrupt. + // nsSuiteGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CORRUPT); + + // The test will continue once import has finished and smart bookmarks + // have been created. + bs.addObserver(bookmarksObserver); +} + +function continue_test() { + // Check that default bookmarks have been restored. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR); + Assert.ok(itemId > 0); + Assert.equal(bs.getItemTitle(itemId), "SeaMonkey"); + + do_test_finished(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_distribution.js b/comm/suite/components/places/tests/unit/test_browserGlue_distribution.js new file mode 100644 index 0000000000..8c12711b6a --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_distribution.js @@ -0,0 +1,124 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue does not overwrite bookmarks imported from the + * migrators. They usually run before nsSuiteGlue, so if we find any + * bookmark on init, we should not try to import. + */ + +const PREF_SMART_BOOKMARKS_VERSION = "browser.places.smartBookmarksVersion"; +const PREF_BMPROCESSED = "distribution.516444.bookmarksProcessed"; +const PREF_DISTRIBUTION_ID = "distribution.id"; + +const TOPIC_FINAL_UI_STARTUP = "final-ui-startup"; +const TOPIC_CUSTOMIZATION_COMPLETE = "distribution-customization-complete"; + +function run_test() { + // This is needed but we still have to investigate the reason, could just be + // we try to act too late in the game, moving our shutdown earlier will help. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // TODO: re-enable when bug 523936 is fixed. + return; + + do_test_pending(); + + // Copy distribution.ini file to our app dir. + let distroDir = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + distroDir.append("distribution"); + let iniFile = distroDir.clone(); + iniFile.append("distribution.ini"); + if (iniFile.exists()) { + iniFile.remove(false); + print("distribution.ini already exists, did some test forget to cleanup?"); + } + + let testDistributionFile = gTestDir.clone(); + testDistributionFile.append("distribution.ini"); + testDistributionFile.copyTo(distroDir, "distribution.ini"); + do_check_true(testDistributionFile.exists()); + + // Disable Smart Bookmarks creation. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, -1); + // Avoid migrateUI, we are just simulating a partial startup. + Services.prefs.setIntPref("browser.migration.version", 1); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // Check a new database has been created. + // nsSuiteGlue will use databaseStatus to manage initialization. + do_check_eq(hs.databaseStatus, hs.DATABASE_STATUS_CREATE); + + // Initialize nsSuiteGlue. + let bg = Cc["@mozilla.org/suite/suiteglue;1"]. + getService(Ci.nsISuiteGlue); + + let os = Cc["@mozilla.org/observer-service;1"]. + getService(Ci.nsIObserverService); + let observer = { + observe: function(aSubject, aTopic, aData) { + os.removeObserver(this, PlacesUtils.TOPIC_INIT_COMPLETE); + + // Simulate browser startup. + bg.QueryInterface(Ci.nsIObserver).observe(null, + TOPIC_FINAL_UI_STARTUP, + null); + // Test will continue on customization complete notification. + let cObserver = { + observe: function(aSubject, aTopic, aData) { + os.removeObserver(this, TOPIC_CUSTOMIZATION_COMPLETE); + do_execute_soon(continue_test); + } + } + os.addObserver(cObserver, TOPIC_CUSTOMIZATION_COMPLETE, false); + } + } + os.addObserver(observer, PlacesUtils.TOPIC_INIT_COMPLETE, false); +} + +function continue_test() { + let bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + + dump_table("moz_bookmarks"); + + // Check the custom bookmarks exist on menu. + let menuItemId = bs.getIdForItemAt(bs.bookmarksMenuFolder, 0); + do_check_neq(menuItemId, -1); + do_check_eq(bs.getItemTitle(menuItemId), "Menu Link Before"); + menuItemId = bs.getIdForItemAt(bs.bookmarksMenuFolder, 1 + DEFAULT_BOOKMARKS_ON_MENU); + do_check_neq(menuItemId, -1); + do_check_eq(bs.getItemTitle(menuItemId), "Menu Link After"); + + // Check the custom bookmarks exist on toolbar. + let toolbarItemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + do_check_neq(toolbarItemId, -1); + do_check_eq(bs.getItemTitle(toolbarItemId), "Toolbar Link Before"); + toolbarItemId = bs.getIdForItemAt(bs.toolbarFolder, 1 + DEFAULT_BOOKMARKS_ON_TOOLBAR); + do_check_neq(toolbarItemId, -1); + do_check_eq(bs.getItemTitle(toolbarItemId), "Toolbar Link After"); + + // Check the bmprocessed pref has been created. + do_check_true(Services.prefs.getBoolPref(PREF_BMPROCESSED)); + + // Check distribution prefs have been created. + do_check_eq(Services.prefs.getCharPref(PREF_DISTRIBUTION_ID), "516444"); + + do_test_finished(); +} + +do_register_cleanup(function() { + // Remove the distribution file, even if the test failed, otherwise all + // next tests will import it. + let iniFile = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + iniFile.append("distribution"); + iniFile.append("distribution.ini"); + if (iniFile.exists()) + iniFile.remove(false); + do_check_false(iniFile.exists()); +}); diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_migrate.js b/comm/suite/components/places/tests/unit/test_browserGlue_migrate.js new file mode 100644 index 0000000000..8568454f04 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_migrate.js @@ -0,0 +1,89 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue does not overwrite bookmarks imported from the + * migrators. They usually run before nsSuiteGlue, so if we find any + * bookmark on init, we should not try to import. + */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "bs", + "@mozilla.org/browser/nav-bookmarks-service;1", + "nsINavBookmarksService"); +XPCOMUtils.defineLazyServiceGetter(this, "anno", + "@mozilla.org/browser/annotation-service;1", + "nsIAnnotationService"); + +var bookmarksObserver = { + onBeginUpdateBatch: function() {}, + onEndUpdateBatch: function() { + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.notEqual(itemId, -1); + if (anno.itemHasAnnotation(itemId, "Places/SmartBookmark")) + continue_test(); + }, + onItemAdded: function() {}, + onItemRemoved: function(id, folder, index, itemType) {}, + onItemChanged: function() {}, + onItemVisited: function(id, visitID, time) {}, + onItemMoved: function() {}, + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]) +}; + +const PREF_SMART_BOOKMARKS_VERSION = "browser.places.smartBookmarksVersion"; + +function run_test() { + do_test_pending(); + + // Create our bookmarks.html copying bookmarks.glue.html to the profile + // folder. It will be ignored. + create_bookmarks_html("bookmarks.glue.html"); + + // Remove current database file. + let db = gProfD.clone(); + db.append("places.sqlite"); + if (db.exists()) { + db.remove(false); + Assert.ok(!db.exists()); + } + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // Check a new database has been created. + // nsSuiteGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CREATE); + + // A migrator would run before nsSuiteGlue Places initialization, so mimic + // that behavior adding a bookmark and notifying the migration. + bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://mozilla.org/"), + bs.DEFAULT_INDEX, "migrated"); + + // Initialize nsSuiteGlue. + let bg = Cc["@mozilla.org/suite/suiteglue;1"]. + getService(Ci.nsIObserver); + bg.observe(null, "initial-migration", null) + + // The test will continue once import has finished and smart bookmarks + // have been created. + bs.addObserver(bookmarksObserver); +} + +function continue_test() { + // Check the created bookmarks still exist. + let itemId = bs.getIdForItemAt(bs.bookmarksMenuFolder, SMART_BOOKMARKS_ON_MENU); + Assert.equal(bs.getItemTitle(itemId), "migrated"); + + // Check that we have not imported any new bookmark. + Assert.equal(bs.getIdForItemAt(bs.bookmarksMenuFolder, SMART_BOOKMARKS_ON_MENU + 1), -1); + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_MENU), -1); + + remove_bookmarks_html(); + + do_test_finished(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_prefs.js b/comm/suite/components/places/tests/unit/test_browserGlue_prefs.js new file mode 100644 index 0000000000..49610d1476 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_prefs.js @@ -0,0 +1,272 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue is correctly interpreting the preferences settable + * by the user or by other components. + */ + +/** Bug 539067 + * Test is disabled due to random failures and timeouts, see run_test. + * This is commented out to avoid leaks. +// Initialize SuiteGlue. +var bg = Cc["@mozilla.org/suite/suiteglue;1"]. + getService(Ci.nsISuiteGlue); +*/ + +// Initialize Places through Bookmarks Service. +var bs = Cc["@mozilla.org/browser/nav-bookmarks-service;1"]. + getService(Ci.nsINavBookmarksService); + +// Get other services. +const PREF_IMPORT_BOOKMARKS_HTML = "browser.places.importBookmarksHTML"; +const PREF_RESTORE_DEFAULT_BOOKMARKS = "browser.bookmarks.restore_default_bookmarks"; +const PREF_SMART_BOOKMARKS_VERSION = "browser.places.smartBookmarksVersion"; +const PREF_AUTO_EXPORT_HTML = "browser.bookmarks.autoExportHTML"; + +function waitForImportAndSmartBookmarks(aCallback) { + Services.obs.addObserver(function waitImport() { + Services.obs.removeObserver(waitImport, "bookmarks-restore-success"); + // Delay to test eventual smart bookmarks creation. + executeSoon(function () { + promiseAsyncUpdates().then(aCallback); + }); + }, "bookmarks-restore-success"); +} + +var tests = []; +//------------------------------------------------------------------------------ + +tests.push({ + description: "Import from bookmarks.html if importBookmarksHTML is true.", + exec: function() { + // Sanity check: we should not have any bookmark on the toolbar. + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, 0), -1); + + // Set preferences. + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + + waitForImportAndSmartBookmarks(function () { + // Check bookmarks.html has been imported, and a smart bookmark has been + // created. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, + SMART_BOOKMARKS_ON_TOOLBAR); + Assert.equal(bs.getItemTitle(itemId), "example"); + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + + next_test(); + }); + // Force nsSuiteGlue::_initPlaces(). + do_log_info("Simulate Places init"); + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_INIT_COMPLETE, + null); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "import from bookmarks.html, but don't create smart bookmarks if they are disabled", + exec: function() { + // Sanity check: we should not have any bookmark on the toolbar. + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, 0), -1); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, -1); + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + + waitForImportAndSmartBookmarks(function () { + // Check bookmarks.html has been imported, but smart bookmarks have not + // been created. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.equal(bs.getItemTitle(itemId), "example"); + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + + next_test(); + }); + // Force nsSuiteGlue::_initPlaces(). + do_log_info("Simulate Places init"); + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_INIT_COMPLETE, + null); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "Import from bookmarks.html, but don't create smart bookmarks if autoExportHTML is true and they are at latest version", + exec: function() { + // Sanity check: we should not have any bookmark on the toolbar. + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, 0), -1); + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 999); + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, true); + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + + waitForImportAndSmartBookmarks(function () { + // Check bookmarks.html has been imported, but smart bookmarks have not + // been created. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.equal(bs.getItemTitle(itemId), "example"); + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + // Check preferences have been reverted. + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, false); + + next_test(); + }); + // Force nsSuiteGlue::_initPlaces() + do_log_info("Simulate Places init"); + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_INIT_COMPLETE, + null); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "Import from bookmarks.html, and create smart bookmarks if autoExportHTML is true and they are not at latest version.", + exec: function() { + // Sanity check: we should not have any bookmark on the toolbar. + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, 0), -1); + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 0); + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, true); + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + + waitForImportAndSmartBookmarks(function () { + // Check bookmarks.html has been imported, but smart bookmarks have not + // been created. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR); + Assert.equal(bs.getItemTitle(itemId), "example"); + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + // Check preferences have been reverted. + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, false); + + next_test(); + }); + // Force nsSuiteGlue::_initPlaces() + do_log_info("Simulate Places init"); + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_INIT_COMPLETE, + null); + } +}); + +//------------------------------------------------------------------------------ +tests.push({ + description: "restore from default bookmarks.html if restore_default_bookmarks is true.", + exec: function() { + // Sanity check: we should not have any bookmark on the toolbar. + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, 0), -1); + // Set preferences. + Services.prefs.setBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS, true); + + waitForImportAndSmartBookmarks(function () { + // Check bookmarks.html has been restored. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR + 1); + Assert.ok(itemId > 0); + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); + + next_test(); + }); + // Force nsSuiteGlue::_initPlaces() + do_log_info("Simulate Places init"); + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_INIT_COMPLETE, + null); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "setting both importBookmarksHTML and restore_default_bookmarks should restore defaults.", + exec: function() { + // Sanity check: we should not have any bookmark on the toolbar. + Assert.equal(bs.getIdForItemAt(bs.toolbarFolder, 0), -1); + // Set preferences. + Services.prefs.setBoolPref(PREF_IMPORT_BOOKMARKS_HTML, true); + Services.prefs.setBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS, true); + + waitForImportAndSmartBookmarks(function () { + // Check bookmarks.html has been restored. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR + 1); + Assert.ok(itemId > 0); + // Check preferences have been reverted. + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + + do_test_finished(); + }); + // Force nsSuiteGlue::_initPlaces() + do_log_info("Simulate Places init"); + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_INIT_COMPLETE, + null); + } +}); + +//------------------------------------------------------------------------------ + +function finish_test() { + // Clean up database from all bookmarks. + remove_all_bookmarks(); + remove_bookmarks_html(); + remove_all_JSON_backups(); + + do_test_finished(); +} +var testIndex = 0; +function next_test() { + // Clean up database from all bookmarks. + remove_all_bookmarks(); + // nsSuiteGlue stops observing topics after first notification, + // so we add back the observer to test additional runs. + Services.obs.addObserver(bg.QueryInterface(Ci.nsIObserver), + PlacesUtils.TOPIC_INIT_COMPLETE); + Services.obs.addObserver(bg.QueryInterface(Ci.nsIObserver), + PlacesUtils.TOPIC_DATABASE_LOCKED); + // Execute next test. + let test = tests.shift(); + print("\nTEST " + (++testIndex) + ": " + test.description); + test.exec(); +} +function run_test() { + // Bug 539067: disabled due to random failures and timeouts. + return; + + do_test_pending(); + // Enqueue test, so it will consume the default places-init-complete + // notification created at Places init. + do_timeout(0, start_tests); +} + +function start_tests() { + // Clean up database from all bookmarks. + remove_all_bookmarks(); + + // Ensure preferences status. + Assert.ok(!Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML)); + try { + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + do_throw("importBookmarksHTML pref should not exist"); + } + catch(ex) {} + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); + + // Create our bookmarks.html from bookmarks.glue.html. + create_bookmarks_html("bookmarks.glue.html"); + // Create our JSON backup from bookmarks.glue.json. + create_JSON_backup("bookmarks.glue.json"); + // Kick-off tests. + next_test(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_restore.js b/comm/suite/components/places/tests/unit/test_browserGlue_restore.js new file mode 100644 index 0000000000..c84bdb0b69 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_restore.js @@ -0,0 +1,81 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue correctly restores bookmarks from a JSON backup if + * database has been created and one backup is available. + */ + +var {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyServiceGetter(this, "bs", + "@mozilla.org/browser/nav-bookmarks-service;1", + "nsINavBookmarksService"); +XPCOMUtils.defineLazyServiceGetter(this, "anno", + "@mozilla.org/browser/annotation-service;1", + "nsIAnnotationService"); + +var bookmarksObserver = { + onBeginUpdateBatch: function() {}, + onEndUpdateBatch: function() { + let itemId = bs.getIdForItemAt(bs.toolbarFolder, 0); + Assert.notEqual(itemId, -1); + if (anno.itemHasAnnotation(itemId, "Places/SmartBookmark")) + continue_test(); + }, + onItemAdded: function() {}, + onItemRemoved: function(id, folder, index, itemType) {}, + onItemChanged: function() {}, + onItemVisited: function(id, visitID, time) {}, + onItemMoved: function() {}, + QueryInterface: XPCOMUtils.generateQI([Ci.nsINavBookmarkObserver]) +}; + +function run_test() { + do_test_pending(); + + // Create our bookmarks.html copying bookmarks.glue.html to the profile + // folder. It will be ignored. + create_bookmarks_html("bookmarks.glue.html"); + + // Create our JSON backup copying bookmarks.glue.json to the profile + // folder. It will be ignored. + create_JSON_backup("bookmarks.glue.json"); + + // Remove current database file. + let db = gProfD.clone(); + db.append("places.sqlite"); + if (db.exists()) { + db.remove(false); + Assert.ok(!db.exists()); + } + + // Initialize nsSuiteGlue before Places. + Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsISuiteGlue); + + // Initialize Places through the History Service. + let hs = Cc["@mozilla.org/browser/nav-history-service;1"]. + getService(Ci.nsINavHistoryService); + // Check a new database has been created. + // nsSuiteGlue uses databaseStatus to manage initialization. + Assert.equal(hs.databaseStatus, hs.DATABASE_STATUS_CREATE); + + // The test will continue once restore has finished and smart bookmarks + // have been created. + bs.addObserver(bookmarksObserver); +} + +function continue_test() { + // Check that JSON backup has been restored. + // Notice restore from JSON notification is fired before smart bookmarks creation. + let itemId = bs.getIdForItemAt(bs.toolbarFolder, SMART_BOOKMARKS_ON_TOOLBAR); + Assert.equal(bs.getItemTitle(itemId), "examplejson"); + + remove_bookmarks_html(); + remove_all_JSON_backups(); + + do_test_finished(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_shutdown.js b/comm/suite/components/places/tests/unit/test_browserGlue_shutdown.js new file mode 100644 index 0000000000..b4e5f17247 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_shutdown.js @@ -0,0 +1,152 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue is correctly exporting based on preferences values, + * and creating bookmarks backup if one does not exist for today. + */ + +// Initialize nsSuiteGlue after Places. +var bg = Cc["@mozilla.org/suite/suiteglue;1"]. + getService(Ci.nsISuiteGlue); + +// Initialize Places through Bookmarks Service. +var bs = PlacesUtils.bookmarks; + +// Get other services. +const PREF_AUTO_EXPORT_HTML = "browser.bookmarks.autoExportHTML"; + +var tests = []; + +//------------------------------------------------------------------------------ + +tests.push({ + description: "Export to bookmarks.html if autoExportHTML is true.", + exec: function() { + // Sanity check: we should have bookmarks on the toolbar. + do_check_true(bs.getIdForItemAt(bs.toolbarFolder, 0) > 0); + + // Set preferences. + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML); + + // Force nsSuiteGlue::_shutdownPlaces(). + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_SHUTDOWN, + null); + + // Check bookmarks.html has been created. + check_bookmarks_html(); + // Check JSON backup has been created. + check_JSON_backup(); + + // Check preferences have not been reverted. + do_check_true(Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML)); + // Reset preferences. + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, false); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "Export to bookmarks.html if autoExportHTML is true and a bookmarks.html exists.", + exec: function() { + // Sanity check: we should have bookmarks on the toolbar. + do_check_true(bs.getIdForItemAt(bs.toolbarFolder, 0) > 0); + + // Set preferences. + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, true); + + // Create a bookmarks.html in the profile. + let profileBookmarksHTMLFile = create_bookmarks_html("bookmarks.glue.html"); + // Get file lastModified and size. + let lastMod = profileBookmarksHTMLFile.lastModifiedTime; + let fileSize = profileBookmarksHTMLFile.fileSize; + + // Force nsSuiteGlue::_shutdownPlaces(). + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_SHUTDOWN, + null); + + // Check a new bookmarks.html has been created. + let profileBookmarksHTMLFile = check_bookmarks_html(); + //XXX not working on Linux unit boxes. Could be filestats caching issue. + //do_check_true(profileBookmarksHTMLFile.lastModifiedTime > lastMod); + do_check_neq(profileBookmarksHTMLFile.fileSize, fileSize); + + // Check preferences have not been reverted. + do_check_true(Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML)); + // Reset preferences. + Services.prefs.setBoolPref(PREF_AUTO_EXPORT_HTML, false); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "Backup to JSON should be a no-op if a backup for today already exists.", + exec: function() { + // Sanity check: we should have bookmarks on the toolbar. + do_check_true(bs.getIdForItemAt(bs.toolbarFolder, 0) > 0); + + // Create a JSON backup in the profile. + let profileBookmarksJSONFile = create_JSON_backup("bookmarks.glue.json"); + // Get file lastModified and size. + let lastMod = profileBookmarksJSONFile.lastModifiedTime; + let fileSize = profileBookmarksJSONFile.fileSize; + + // Force nsSuiteGlue::_shutdownPlaces(). + bg.QueryInterface(Ci.nsIObserver).observe(null, + PlacesUtils.TOPIC_SHUTDOWN, + null); + + // Check a new JSON backup has not been created. + do_check_true(profileBookmarksJSONFile.exists()); + do_check_eq(profileBookmarksJSONFile.lastModifiedTime, lastMod); + do_check_eq(profileBookmarksJSONFile.fileSize, fileSize); + + do_test_finished(); + } +}); + +//------------------------------------------------------------------------------ + +function finish_test() { + do_test_finished(); +} + +var testIndex = 0; +function next_test() { + // Remove bookmarks.html from profile. + remove_bookmarks_html(); + // Remove JSON backups from profile. + remove_all_JSON_backups(); + + // Execute next test. + let test = tests.shift(); + dump("\nTEST " + (++testIndex) + ": " + test.description); + test.exec(); +} + +function run_test() { + do_test_pending(); + + // Clean up bookmarks. + remove_all_bookmarks(); + + // Create some bookmarks. + bs.insertBookmark(bs.bookmarksMenuFolder, uri("http://mozilla.org/"), + bs.DEFAULT_INDEX, "bookmark-on-menu"); + bs.insertBookmark(bs.toolbarFolder, uri("http://mozilla.org/"), + bs.DEFAULT_INDEX, "bookmark-on-toolbar"); + + // Kick-off tests. + next_test(); +} diff --git a/comm/suite/components/places/tests/unit/test_browserGlue_smartBookmarks.js b/comm/suite/components/places/tests/unit/test_browserGlue_smartBookmarks.js new file mode 100644 index 0000000000..bf0b93b807 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_browserGlue_smartBookmarks.js @@ -0,0 +1,351 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that nsSuiteGlue is correctly interpreting the preferences settable + * by the user or by other components. + */ + +const PREF_SMART_BOOKMARKS_VERSION = "browser.places.smartBookmarksVersion"; +const PREF_AUTO_EXPORT_HTML = "browser.bookmarks.autoExportHTML"; +const PREF_IMPORT_BOOKMARKS_HTML = "browser.places.importBookmarksHTML"; +const PREF_RESTORE_DEFAULT_BOOKMARKS = "browser.bookmarks.restore_default_bookmarks"; + +const SMART_BOOKMARKS_ANNO = "Places/SmartBookmark"; + +/** + * Rebuilds smart bookmarks listening to console output to report any message or + * exception generated when calling ensurePlacesDefaultQueriesInitialized(). + */ +function rebuildSmartBookmarks() { + let consoleListener = { + observe: function(aMsg) { + print("Got console message: " + aMsg.message); + }, + + QueryInterface: XPCOMUtils.generateQI([ + Ci.nsIConsoleListener + ]), + }; + Services.console.reset(); + Services.console.registerListener(consoleListener); + Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsISuiteGlue) + .ensurePlacesDefaultQueriesInitialized(); + Services.console.unregisterListener(consoleListener); +} + + +var tests = []; +//------------------------------------------------------------------------------ + +tests.push({ + description: "All smart bookmarks are created if smart bookmarks version is 0.", + exec: function() { + // Sanity check: we should have default bookmark. + Assert.notEqual(PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.toolbarFolderId, 0), -1); + Assert.notEqual(PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 0), -1); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 0); + + rebuildSmartBookmarks(); + + // Count items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Check version has been updated. + Assert.equal(Services.prefs.getIntPref(PREF_SMART_BOOKMARKS_VERSION), + SMART_BOOKMARKS_VERSION); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "An existing smart bookmark is replaced when version changes.", + exec: function() { + // Sanity check: we have a smart bookmark on the toolbar. + let itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.toolbarFolderId, 0); + Assert.notEqual(itemId, -1); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId, SMART_BOOKMARKS_ANNO)); + // Change its title. + PlacesUtils.bookmarks.setItemTitle(itemId, "new title"); + Assert.equal(PlacesUtils.bookmarks.getItemTitle(itemId), "new title"); + + // Sanity check items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 1); + + rebuildSmartBookmarks(); + + // Count items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Check smart bookmark has been replaced, itemId has changed. + itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.toolbarFolderId, 0); + Assert.notEqual(itemId, -1); + Assert.notEqual(PlacesUtils.bookmarks.getItemTitle(itemId), "new title"); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId, SMART_BOOKMARKS_ANNO)); + + // Check version has been updated. + Assert.equal(Services.prefs.getIntPref(PREF_SMART_BOOKMARKS_VERSION), + SMART_BOOKMARKS_VERSION); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "bookmarks position is retained when version changes.", + exec: function() { + // Sanity check items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + let itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 0); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId, SMART_BOOKMARKS_ANNO)); + let firstItemTitle = PlacesUtils.bookmarks.getItemTitle(itemId); + + itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 1); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId, SMART_BOOKMARKS_ANNO)); + let secondItemTitle = PlacesUtils.bookmarks.getItemTitle(itemId); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 1); + + rebuildSmartBookmarks(); + + // Count items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Check smart bookmarks are still in correct position. + itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 0); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId, SMART_BOOKMARKS_ANNO)); + Assert.equal(PlacesUtils.bookmarks.getItemTitle(itemId), firstItemTitle); + + itemId = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 1); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId, SMART_BOOKMARKS_ANNO)); + Assert.equal(PlacesUtils.bookmarks.getItemTitle(itemId), secondItemTitle); + + // Check version has been updated. + Assert.equal(Services.prefs.getIntPref(PREF_SMART_BOOKMARKS_VERSION), + SMART_BOOKMARKS_VERSION); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "moved bookmarks position is retained when version changes.", + exec: function() { + // Sanity check items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + let itemId1 = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 0); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId1, SMART_BOOKMARKS_ANNO)); + let firstItemTitle = PlacesUtils.bookmarks.getItemTitle(itemId1); + + let itemId2 = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 1); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId2, SMART_BOOKMARKS_ANNO)); + let secondItemTitle = PlacesUtils.bookmarks.getItemTitle(itemId2); + + // Move the first smart bookmark to the end of the menu. + PlacesUtils.bookmarks.moveItem(itemId1, PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX); + + Assert.equal(itemId1, PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX)); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 1); + + rebuildSmartBookmarks(); + + // Count items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Check smart bookmarks are still in correct position. + itemId2 = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, 0); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId2, SMART_BOOKMARKS_ANNO)); + Assert.equal(PlacesUtils.bookmarks.getItemTitle(itemId2), secondItemTitle); + + itemId1 = PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX); + Assert.ok(PlacesUtils.annotations.itemHasAnnotation(itemId1, SMART_BOOKMARKS_ANNO)); + Assert.equal(PlacesUtils.bookmarks.getItemTitle(itemId1), firstItemTitle); + + // Move back the smart bookmark to the original position. + PlacesUtils.bookmarks.moveItem(itemId1, PlacesUtils.bookmarksMenuFolderId, 1); + + // Check version has been updated. + Assert.equal(Services.prefs.getIntPref(PREF_SMART_BOOKMARKS_VERSION), + SMART_BOOKMARKS_VERSION); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "An explicitly removed smart bookmark should not be recreated.", + exec: function() { + // Remove toolbar's smart bookmarks + PlacesUtils.bookmarks.removeItem(PlacesUtils.bookmarks.getIdForItemAt(PlacesUtils.toolbarFolderId, 0)); + + // Sanity check items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 1); + + rebuildSmartBookmarks(); + + // Count items. + // We should not have recreated the smart bookmark on toolbar. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Check version has been updated. + Assert.equal(Services.prefs.getIntPref(PREF_SMART_BOOKMARKS_VERSION), + SMART_BOOKMARKS_VERSION); + + next_test(); + } +}); + +//------------------------------------------------------------------------------ + +tests.push({ + description: "Even if a smart bookmark has been removed recreate it if version is 0.", + exec: function() { + // Sanity check items. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Set preferences. + Services.prefs.setIntPref(PREF_SMART_BOOKMARKS_VERSION, 0); + + rebuildSmartBookmarks(); + + // Count items. + // We should not have recreated the smart bookmark on toolbar. + Assert.equal(countFolderChildren(PlacesUtils.toolbarFolderId), + SMART_BOOKMARKS_ON_TOOLBAR + DEFAULT_BOOKMARKS_ON_TOOLBAR); + Assert.equal(countFolderChildren(PlacesUtils.bookmarksMenuFolderId), + SMART_BOOKMARKS_ON_MENU + DEFAULT_BOOKMARKS_ON_MENU); + + // Check version has been updated. + Assert.equal(Services.prefs.getIntPref(PREF_SMART_BOOKMARKS_VERSION), + SMART_BOOKMARKS_VERSION); + + next_test(); + } +}); +//------------------------------------------------------------------------------ + +function countFolderChildren(aFolderItemId) { + let rootNode = PlacesUtils.getFolderContents(aFolderItemId).root; + let cc = rootNode.childCount; + // Dump contents. + for (let i = 0; i < cc ; i++) { + let node = rootNode.getChild(i); + let title = PlacesUtils.nodeIsSeparator(node) ? "---" : node.title; + print("Found child(" + i + "): " + title); + } + rootNode.containerOpen = false; + return cc; +} + +function next_test() { + if (tests.length) { + // Execute next test. + let test = tests.shift(); + print("\nTEST: " + test.description); + test.exec(); + } + else { + // Clean up database from all bookmarks. + remove_all_bookmarks(); + do_test_finished(); + } +} + +function run_test() { + do_test_pending(); + + remove_bookmarks_html(); + remove_all_JSON_backups(); + + // Initialize SuiteGlue, but remove its listener to places-init-complete. + let bg = Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsIObserver); + // Initialize Places. + PlacesUtils.history; + // Usually places init would async notify to glue, but we want to avoid + // randomness here, thus we fire the notification synchronously. + bg.observe(null, "places-init-complete", null); + + // Ensure preferences status. + Assert.ok(!Services.prefs.getBoolPref(PREF_AUTO_EXPORT_HTML)); + // XXXkairo: might get set due to the different logic of SeaMonkey imports + // but there could be some real bug so import is set and restore not + try { + Assert.ok(!Services.prefs.getBoolPref(PREF_RESTORE_DEFAULT_BOOKMARKS)); + } + catch(ex) {} + try { + Assert.ok(!Services.prefs.getBoolPref(PREF_IMPORT_BOOKMARKS_HTML)); + // do_throw("importBookmarksHTML pref should not exist"); + } + catch(ex) {} + + waitForImportAndSmartBookmarks(next_test); +} + +function waitForImportAndSmartBookmarks(aCallback) { + Services.obs.addObserver(function waitImport() { + Services.obs.removeObserver(waitImport, "bookmarks-restore-success"); + // Delay to test eventual smart bookmarks creation. + executeSoon(function () { + promiseAsyncUpdates().then(aCallback); + }); + }, "bookmarks-restore-success"); +} diff --git a/comm/suite/components/places/tests/unit/test_clearHistory_shutdown.js b/comm/suite/components/places/tests/unit/test_clearHistory_shutdown.js new file mode 100644 index 0000000000..2fa7a42372 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_clearHistory_shutdown.js @@ -0,0 +1,181 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that requesting clear history at shutdown will really clear history. + */ + +const URIS = [ + "http://a.example1.com/" +, "http://b.example1.com/" +, "http://b.example2.com/" +, "http://c.example3.com/" +]; + +const TOPIC_CONNECTION_CLOSED = "places-connection-closed"; + +var EXPECTED_NOTIFICATIONS = [ + "places-shutdown" +, "places-will-close-connection" +, "places-expiration-finished" +, "places-connection-closed" +]; + +const UNEXPECTED_NOTIFICATIONS = [ + "xpcom-shutdown" +]; + +const URL = "ftp://localhost/clearHistoryOnShutdown/"; + +var notificationIndex = 0; + +var notificationsObserver = { + observe: function observe(aSubject, aTopic, aData) { + print("Received notification: " + aTopic); + + // Note that some of these notifications could arrive multiple times, for + // example in case of sync, we allow that. + if (EXPECTED_NOTIFICATIONS[notificationIndex] != aTopic) + notificationIndex++; + Assert.equal(EXPECTED_NOTIFICATIONS[notificationIndex], aTopic); + + if (aTopic != TOPIC_CONNECTION_CLOSED) + return; + + getDistinctNotifications().forEach( + topic => Services.obs.removeObserver(notificationsObserver, topic) + ); + + print("Looking for uncleared stuff."); + + let stmt = DBConn().createStatement( + "SELECT id FROM moz_places WHERE url = :page_url " + ); + + try { + URIS.forEach(function(aUrl) { + stmt.params.page_url = aUrl; + Assert.ok(!stmt.executeStep()); + stmt.reset(); + }); + } finally { + stmt.finalize(); + } + + // Check cache. + checkCache(URL); + } +} + +var timeInMicroseconds = Date.now() * 1000; + +function run_test() { + run_next_test(); +} + +add_task(async function test_execute() { + do_test_pending(); + + print("Initialize suiteglue before Places"); + // Avoid default bookmarks import. + Cc["@mozilla.org/suite/suiteglue;1"].getService(Ci.nsIObserver) + .observe(null, "initial-migration", null); + + Services.prefs.setBoolPref("privacy.clearOnShutdown.history", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.urlbar", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.formdata", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.passwords", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.downloads", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.cookies", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.cache", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.sessions", true); + Services.prefs.setBoolPref("privacy.clearOnShutdown.offlineApps", true); + + Services.prefs.setBoolPref("privacy.sanitize.sanitizeOnShutdown", true); + // Unlike Firefox, SeaMonkey still supports the confirmation dialog + // which is called from Sanitizer's init method checkSettings(). + Services.prefs.setBoolPref("privacy.sanitize.promptOnSanitize", false); + + print("Add visits."); + for (let aUrl of URIS) { + await promiseAddVisits({uri: uri(aUrl), visitDate: timeInMicroseconds++, + transition: PlacesUtils.history.TRANSITION_TYPED}) + } + print("Add cache."); + storeCache(URL, "testData"); +}); + +function run_test_continue() +{ + print("Simulate and wait shutdown."); + getDistinctNotifications().forEach( + topic => + Services.obs.addObserver(notificationsObserver, topic) + ); + + // Simulate an exit so that Sanitizer's init method checkSettings() is called. + print("Simulate 'quit-application-granted' too for SeaMonkey."); + Services.obs.notifyObservers(null, "quit-application-granted"); + + shutdownPlaces(); + + // Shutdown the download manager. + Services.obs.notifyObservers(null, "quit-application"); +} + +function getDistinctNotifications() { + let ar = EXPECTED_NOTIFICATIONS.concat(UNEXPECTED_NOTIFICATIONS); + return [...new Set(ar)]; +} + +function storeCache(aURL, aContent) { + let cache = Cc["@mozilla.org/network/cache-service;1"]. + getService(Ci.nsICacheService); + let session = cache.createSession("FTP", Ci.nsICache.STORE_ANYWHERE, + Ci.nsICache.STREAM_BASED); + + + var storeCacheListener = { + onCacheEntryAvailable: function (entry, access, status) { + Assert.equal(status, Cr.NS_OK); + + entry.setMetaDataElement("servertype", "0"); + var os = entry.openOutputStream(0); + + var written = os.write(aContent, aContent.length); + if (written != aContent.length) { + do_throw("os.write has not written all data!\n" + + " Expected: " + written + "\n" + + " Actual: " + aContent.length + "\n"); + } + os.close(); + entry.close(); + executeSoon(run_test_continue); + } + }; + + session.asyncOpenCacheEntry(aURL, + Ci.nsICache.ACCESS_READ_WRITE, + storeCacheListener); +} + +function checkCache(aURL) { + let cache = Cc["@mozilla.org/network/cache-service;1"]. + getService(Ci.nsICacheService); + let session = cache.createSession("FTP", Ci.nsICache.STORE_ANYWHERE, + Ci.nsICache.STREAM_BASED); + + var checkCacheListener = { + onCacheEntryAvailable: function (entry, access, status) { + Assert.equal(status, Cr.NS_ERROR_CACHE_KEY_NOT_FOUND); + do_test_finished(); + } + }; + + session.asyncOpenCacheEntry(aURL, + Ci.nsICache.ACCESS_READ, + checkCacheListener); +} diff --git a/comm/suite/components/places/tests/unit/test_leftpane_corruption_handling.js b/comm/suite/components/places/tests/unit/test_leftpane_corruption_handling.js new file mode 100644 index 0000000000..c8b275e543 --- /dev/null +++ b/comm/suite/components/places/tests/unit/test_leftpane_corruption_handling.js @@ -0,0 +1,189 @@ +/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* 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/. */ + +/** + * Tests that we build a working leftpane in various corruption situations. + */ + +// Used to store the original leftPaneFolderId getter. +var gLeftPaneFolderIdGetter; +var gAllBookmarksFolderIdGetter; +// Used to store the original left Pane status as a JSON string. +var gReferenceJSON; +var gLeftPaneFolderId; +// Third party annotated folder. +var gFolderId; + +// Corruption cases. +var gTests = [ + + function test1() { + print("1. Do nothing, checks test calibration."); + }, + + function test2() { + print("2. Delete the left pane folder."); + PlacesUtils.bookmarks.removeItem(gLeftPaneFolderId); + }, + + function test3() { + print("3. Delete a child of the left pane folder."); + let id = PlacesUtils.bookmarks.getIdForItemAt(gLeftPaneFolderId, 0); + PlacesUtils.bookmarks.removeItem(id); + }, + + function test4() { + print("4. Delete AllBookmarks."); + PlacesUtils.bookmarks.removeItem(PlacesUIUtils.allBookmarksFolderId); + }, + + function test5() { + print("5. Create a duplicated left pane folder."); + let id = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId, + "PlacesRoot", + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.annotations.setItemAnnotation(id, ORGANIZER_FOLDER_ANNO, + "PlacesRoot", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + }, + + function test6() { + print("6. Create a duplicated left pane query."); + let id = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId, + "AllBookmarks", + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.annotations.setItemAnnotation(id, ORGANIZER_QUERY_ANNO, + "AllBookmarks", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + }, + + function test7() { + print("7. Remove the left pane folder annotation."); + PlacesUtils.annotations.removeItemAnnotation(gLeftPaneFolderId, + ORGANIZER_FOLDER_ANNO); + }, + + function test8() { + print("8. Remove a left pane query annotation."); + PlacesUtils.annotations.removeItemAnnotation(PlacesUIUtils.allBookmarksFolderId, + ORGANIZER_QUERY_ANNO); + }, + + function test9() { + print("9. Remove a child of AllBookmarks."); + let id = PlacesUtils.bookmarks.getIdForItemAt(PlacesUIUtils.allBookmarksFolderId, 0); + PlacesUtils.bookmarks.removeItem(id); + }, + +]; + +function run_test() { + // We want empty roots. + remove_all_bookmarks(); + + // Sanity check. + Assert.ok(!!PlacesUIUtils); + + // Check getters. + gLeftPaneFolderIdGetter = PlacesUIUtils.__lookupGetter__("leftPaneFolderId"); + Assert.equal(typeof(gLeftPaneFolderIdGetter), "function"); + gAllBookmarksFolderIdGetter = PlacesUIUtils.__lookupGetter__("allBookmarksFolderId"); + Assert.equal(typeof(gAllBookmarksFolderIdGetter), "function"); + + // Add a third party bogus annotated item. Should not be removed. + gFolderId = PlacesUtils.bookmarks.createFolder(PlacesUtils.unfiledBookmarksFolderId, + "test", + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.annotations.setItemAnnotation(gFolderId, ORGANIZER_QUERY_ANNO, + "test", 0, + PlacesUtils.annotations.EXPIRE_NEVER); + + // Create the left pane, and store its current status, it will be used + // as reference value. + gLeftPaneFolderId = PlacesUIUtils.leftPaneFolderId; + gReferenceJSON = folderToJSON(gLeftPaneFolderId); + + // Kick-off tests. + do_test_pending(); + do_timeout(0, run_next_test); +} + +function run_next_test() { + if (gTests.length) { + // Create corruption. + let test = gTests.shift(); + test(); + // Regenerate getters. + PlacesUIUtils.__defineGetter__("leftPaneFolderId", gLeftPaneFolderIdGetter); + gLeftPaneFolderId = PlacesUIUtils.leftPaneFolderId; + PlacesUIUtils.__defineGetter__("allBookmarksFolderId", gAllBookmarksFolderIdGetter); + // Check the new left pane folder. + let leftPaneJSON = folderToJSON(gLeftPaneFolderId); + Assert.ok(compareJSON(gReferenceJSON, leftPaneJSON)); + Assert.equal(PlacesUtils.bookmarks.getItemTitle(gFolderId), "test"); + // Go to next test. + do_timeout(0, run_next_test); + } + else { + // All tests finished. + remove_all_bookmarks(); + do_test_finished(); + } +} + +/** + * Convert a folder item id to a JSON representation of it and its contents. + */ +function folderToJSON(aItemId) { + let query = PlacesUtils.history.getNewQuery(); + query.setFolders([aItemId], 1); + let options = PlacesUtils.history.getNewQueryOptions(); + options.queryType = Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS; + let root = PlacesUtils.history.executeQuery(query, options).root; + let writer = { + value: "", + write: function PU_wrapNode__write(aStr, aLen) { + this.value += aStr; + } + }; + PlacesUtils.serializeNodeAsJSONToOutputStream(root, writer, false, false); + Assert.ok(writer.value.length > 0); + return writer.value; +} + +/** + * Compare the JSON representation of 2 nodes, skipping everchanging properties + * like dates. + */ +function compareJSON(aNodeJSON_1, aNodeJSON_2) { + let node1 = JSON.parse(aNodeJSON_1); + let node2 = JSON.parse(aNodeJSON_2); + + // List of properties we should not compare (expected to be different). + const SKIP_PROPS = ["dateAdded", "lastModified", "id"]; + + function compareObjects(obj1, obj2) { + function count(o) { var n = 0; for (let p in o) n++; return n; } + Assert.equal(count(obj1), count(obj2)); + for (let prop in obj1) { + // Skip everchanging values. + if (SKIP_PROPS.includes(prop)) + continue; + // Skip undefined objects, otherwise we hang on them. + if (!obj1[prop]) + continue; + if (typeof(obj1[prop]) == "object") + return compareObjects(obj1[prop], obj2[prop]); + if (obj1[prop] !== obj2[prop]) { + print(prop + ": " + obj1[prop] + "!=" + obj2[prop]); + return false; + } + } + return true; + } + + return compareObjects(node1, node2); +} diff --git a/comm/suite/components/places/tests/unit/xpcshell.ini b/comm/suite/components/places/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..a2753df42b --- /dev/null +++ b/comm/suite/components/places/tests/unit/xpcshell.ini @@ -0,0 +1,19 @@ +[DEFAULT] +head = head_bookmarks.js +tail = +run-sequentially = Avoid bustage. +support-files = distribution.ini corruptDB.sqlite bookmarks.glue.html bookmarks.glue.json + +[test_421483.js] +[test_browserGlue_corrupt.js] +[test_browserGlue_corrupt_nobackup.js] +[test_browserGlue_corrupt_nobackup_default.js] +[test_browserGlue_distribution.js] +[test_browserGlue_migrate.js] +[test_browserGlue_prefs.js] +[test_browserGlue_restore.js] +[test_browserGlue_shutdown.js] +[test_browserGlue_smartBookmarks.js] +[test_clearHistory_shutdown.js] +[test_leftpane_corruption_handling.js] +[test_PUIU_makeTransaction.js] |