From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- browser/components/places/PlacesUIUtils.jsm | 1774 +++++++++++++++++ .../places/content/bookmarkProperties.js | 693 +++++++ .../places/content/bookmarkProperties.xul | 43 + .../components/places/content/bookmarksPanel.js | 24 + .../components/places/content/bookmarksPanel.xul | 54 + .../places/content/browserPlacesViews.js | 1996 ++++++++++++++++++++ browser/components/places/content/controller.js | 1742 +++++++++++++++++ .../places/content/downloadsViewOverlay.xul | 47 + .../places/content/editBookmarkOverlay.js | 1168 ++++++++++++ .../places/content/editBookmarkOverlay.xul | 188 ++ browser/components/places/content/history-panel.js | 98 + .../components/places/content/history-panel.xul | 95 + browser/components/places/content/menu.xml | 633 +++++++ browser/components/places/content/moveBookmarks.js | 65 + .../components/places/content/moveBookmarks.xul | 53 + browser/components/places/content/organizer.css | 7 + browser/components/places/content/places.css | 25 + browser/components/places/content/places.js | 1405 ++++++++++++++ browser/components/places/content/places.xul | 438 +++++ .../components/places/content/placesOverlay.xul | 233 +++ browser/components/places/content/sidebarUtils.js | 106 ++ browser/components/places/content/tree.xml | 801 ++++++++ browser/components/places/content/treeView.js | 1726 +++++++++++++++++ browser/components/places/jar.mn | 34 + browser/components/places/moz.build | 18 + .../components/places/tests/browser/.eslintrc.js | 7 + .../places/tests/browser/bookmark_dummy_1.html | 9 + .../places/tests/browser/bookmark_dummy_2.html | 9 + .../components/places/tests/browser/browser.ini | 58 + .../browser_0_library_left_pane_migration.js | 90 + .../browser/browser_410196_paste_into_tags.js | 114 ++ .../places/tests/browser/browser_416459_cut.js | 83 + .../places/tests/browser/browser_423515.js | 173 ++ .../places/tests/browser/browser_425884.js | 127 ++ .../tests/browser/browser_435851_copy_query.js | 59 + .../places/tests/browser/browser_475045.js | 65 + .../places/tests/browser/browser_555547.js | 66 + ...er_bookmarkProperties_addFolderDefaultButton.js | 53 + ...r_bookmarkProperties_addKeywordForThisSearch.js | 110 ++ .../browser_bookmarkProperties_addLivemark.js | 39 + .../browser_bookmarkProperties_editTagContainer.js | 71 + .../browser_bookmarkProperties_readOnlyRoot.js | 42 + .../tests/browser/browser_bookmark_all_tabs.js | 37 + .../browser/browser_bookmarklet_windowOpen.js | 61 + .../tests/browser/browser_bookmarksProperties.js | 450 +++++ .../browser/browser_drag_bookmarks_on_toolbar.js | 256 +++ .../tests/browser/browser_forgetthissite_single.js | 78 + .../browser/browser_history_sidebar_search.js | 64 + .../tests/browser/browser_library_batch_delete.js | 114 ++ .../tests/browser/browser_library_commands.js | 235 +++ .../tests/browser/browser_library_downloads.js | 70 + .../tests/browser/browser_library_infoBox.js | 197 ++ .../browser/browser_library_left_pane_fixnames.js | 94 + .../browser_library_left_pane_select_hierarchy.js | 41 + .../tests/browser/browser_library_middleclick.js | 279 +++ .../browser/browser_library_openFlatContainer.js | 42 + .../tests/browser/browser_library_open_leak.js | 23 + .../tests/browser/browser_library_panel_leak.js | 54 + .../places/tests/browser/browser_library_search.js | 182 ++ .../browser/browser_library_views_liveupdate.js | 300 +++ .../browser/browser_markPageAsFollowedLink.js | 68 + .../tests/browser/browser_sidebarpanels_click.js | 157 ++ .../tests/browser/browser_sort_in_library.js | 249 +++ .../browser/browser_toolbarbutton_menu_context.js | 53 + .../tests/browser/browser_views_liveupdate.js | 475 +++++ .../components/places/tests/browser/frameLeft.html | 8 + .../places/tests/browser/frameRight.html | 8 + .../places/tests/browser/framedPage.html | 9 + browser/components/places/tests/browser/head.js | 460 +++++ .../places/tests/browser/keyword_form.html | 17 + .../places/tests/browser/pageopeningwindow.html | 9 + .../browser/sidebarpanels_click_test_page.html | 7 + .../components/places/tests/chrome/.eslintrc.js | 7 + browser/components/places/tests/chrome/chrome.ini | 15 + browser/components/places/tests/chrome/head.js | 7 + .../places/tests/chrome/test_0_bug510634.xul | 99 + .../tests/chrome/test_0_multiple_left_pane.xul | 85 + ...est_bug1163447_selectItems_through_shortcut.xul | 89 + .../chrome/test_bug427633_no_newfolder_if_noip.xul | 91 + .../test_bug485100-change-case-loses-tag.xul | 83 + .../places/tests/chrome/test_bug549192.xul | 120 ++ .../places/tests/chrome/test_bug549491.xul | 78 + .../chrome/test_bug631374_tags_selector_scroll.xul | 170 ++ .../chrome/test_editBookmarkOverlay_keywords.xul | 99 + .../test_editBookmarkOverlay_tags_liveUpdate.xul | 204 ++ .../chrome/test_selectItems_on_nested_tree.xul | 86 + .../places/tests/chrome/test_treeview_date.xul | 159 ++ browser/components/places/tests/unit/.eslintrc.js | 7 + .../places/tests/unit/bookmarks.glue.html | 16 + .../places/tests/unit/bookmarks.glue.json | 1 + .../components/places/tests/unit/corruptDB.sqlite | Bin 0 -> 32772 bytes .../components/places/tests/unit/distribution.ini | 27 + .../components/places/tests/unit/head_bookmarks.js | 133 ++ .../components/places/tests/unit/test_421483.js | 103 + .../places/tests/unit/test_PUIU_makeTransaction.js | 361 ++++ .../tests/unit/test_browserGlue_bookmarkshtml.js | 33 + .../places/tests/unit/test_browserGlue_corrupt.js | 59 + .../unit/test_browserGlue_corrupt_nobackup.js | 52 + .../test_browserGlue_corrupt_nobackup_default.js | 55 + .../tests/unit/test_browserGlue_distribution.js | 125 ++ .../places/tests/unit/test_browserGlue_migrate.js | 70 + .../places/tests/unit/test_browserGlue_prefs.js | 240 +++ .../places/tests/unit/test_browserGlue_restore.js | 62 + .../tests/unit/test_browserGlue_smartBookmarks.js | 285 +++ ...browserGlue_urlbar_defaultbehavior_migration.js | 150 ++ .../tests/unit/test_clearHistory_shutdown.js | 181 ++ .../unit/test_leftpane_corruption_handling.js | 174 ++ browser/components/places/tests/unit/xpcshell.ini | 25 + 108 files changed, 22289 insertions(+) create mode 100644 browser/components/places/PlacesUIUtils.jsm create mode 100644 browser/components/places/content/bookmarkProperties.js create mode 100644 browser/components/places/content/bookmarkProperties.xul create mode 100644 browser/components/places/content/bookmarksPanel.js create mode 100644 browser/components/places/content/bookmarksPanel.xul create mode 100644 browser/components/places/content/browserPlacesViews.js create mode 100644 browser/components/places/content/controller.js create mode 100644 browser/components/places/content/downloadsViewOverlay.xul create mode 100644 browser/components/places/content/editBookmarkOverlay.js create mode 100644 browser/components/places/content/editBookmarkOverlay.xul create mode 100644 browser/components/places/content/history-panel.js create mode 100644 browser/components/places/content/history-panel.xul create mode 100644 browser/components/places/content/menu.xml create mode 100644 browser/components/places/content/moveBookmarks.js create mode 100644 browser/components/places/content/moveBookmarks.xul create mode 100644 browser/components/places/content/organizer.css create mode 100644 browser/components/places/content/places.css create mode 100644 browser/components/places/content/places.js create mode 100644 browser/components/places/content/places.xul create mode 100644 browser/components/places/content/placesOverlay.xul create mode 100644 browser/components/places/content/sidebarUtils.js create mode 100644 browser/components/places/content/tree.xml create mode 100644 browser/components/places/content/treeView.js create mode 100644 browser/components/places/jar.mn create mode 100644 browser/components/places/moz.build create mode 100644 browser/components/places/tests/browser/.eslintrc.js create mode 100644 browser/components/places/tests/browser/bookmark_dummy_1.html create mode 100644 browser/components/places/tests/browser/bookmark_dummy_2.html create mode 100644 browser/components/places/tests/browser/browser.ini create mode 100644 browser/components/places/tests/browser/browser_0_library_left_pane_migration.js create mode 100644 browser/components/places/tests/browser/browser_410196_paste_into_tags.js create mode 100644 browser/components/places/tests/browser/browser_416459_cut.js create mode 100644 browser/components/places/tests/browser/browser_423515.js create mode 100644 browser/components/places/tests/browser/browser_425884.js create mode 100644 browser/components/places/tests/browser/browser_435851_copy_query.js create mode 100644 browser/components/places/tests/browser/browser_475045.js create mode 100644 browser/components/places/tests/browser/browser_555547.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_addFolderDefaultButton.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_addKeywordForThisSearch.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_addLivemark.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_editTagContainer.js create mode 100644 browser/components/places/tests/browser/browser_bookmarkProperties_readOnlyRoot.js create mode 100644 browser/components/places/tests/browser/browser_bookmark_all_tabs.js create mode 100644 browser/components/places/tests/browser/browser_bookmarklet_windowOpen.js create mode 100644 browser/components/places/tests/browser/browser_bookmarksProperties.js create mode 100644 browser/components/places/tests/browser/browser_drag_bookmarks_on_toolbar.js create mode 100644 browser/components/places/tests/browser/browser_forgetthissite_single.js create mode 100644 browser/components/places/tests/browser/browser_history_sidebar_search.js create mode 100644 browser/components/places/tests/browser/browser_library_batch_delete.js create mode 100644 browser/components/places/tests/browser/browser_library_commands.js create mode 100644 browser/components/places/tests/browser/browser_library_downloads.js create mode 100644 browser/components/places/tests/browser/browser_library_infoBox.js create mode 100644 browser/components/places/tests/browser/browser_library_left_pane_fixnames.js create mode 100644 browser/components/places/tests/browser/browser_library_left_pane_select_hierarchy.js create mode 100644 browser/components/places/tests/browser/browser_library_middleclick.js create mode 100644 browser/components/places/tests/browser/browser_library_openFlatContainer.js create mode 100644 browser/components/places/tests/browser/browser_library_open_leak.js create mode 100644 browser/components/places/tests/browser/browser_library_panel_leak.js create mode 100644 browser/components/places/tests/browser/browser_library_search.js create mode 100644 browser/components/places/tests/browser/browser_library_views_liveupdate.js create mode 100644 browser/components/places/tests/browser/browser_markPageAsFollowedLink.js create mode 100644 browser/components/places/tests/browser/browser_sidebarpanels_click.js create mode 100644 browser/components/places/tests/browser/browser_sort_in_library.js create mode 100644 browser/components/places/tests/browser/browser_toolbarbutton_menu_context.js create mode 100644 browser/components/places/tests/browser/browser_views_liveupdate.js create mode 100644 browser/components/places/tests/browser/frameLeft.html create mode 100644 browser/components/places/tests/browser/frameRight.html create mode 100644 browser/components/places/tests/browser/framedPage.html create mode 100644 browser/components/places/tests/browser/head.js create mode 100644 browser/components/places/tests/browser/keyword_form.html create mode 100644 browser/components/places/tests/browser/pageopeningwindow.html create mode 100644 browser/components/places/tests/browser/sidebarpanels_click_test_page.html create mode 100644 browser/components/places/tests/chrome/.eslintrc.js create mode 100644 browser/components/places/tests/chrome/chrome.ini create mode 100644 browser/components/places/tests/chrome/head.js create mode 100644 browser/components/places/tests/chrome/test_0_bug510634.xul create mode 100644 browser/components/places/tests/chrome/test_0_multiple_left_pane.xul create mode 100644 browser/components/places/tests/chrome/test_bug1163447_selectItems_through_shortcut.xul create mode 100644 browser/components/places/tests/chrome/test_bug427633_no_newfolder_if_noip.xul create mode 100644 browser/components/places/tests/chrome/test_bug485100-change-case-loses-tag.xul create mode 100644 browser/components/places/tests/chrome/test_bug549192.xul create mode 100644 browser/components/places/tests/chrome/test_bug549491.xul create mode 100644 browser/components/places/tests/chrome/test_bug631374_tags_selector_scroll.xul create mode 100644 browser/components/places/tests/chrome/test_editBookmarkOverlay_keywords.xul create mode 100644 browser/components/places/tests/chrome/test_editBookmarkOverlay_tags_liveUpdate.xul create mode 100644 browser/components/places/tests/chrome/test_selectItems_on_nested_tree.xul create mode 100644 browser/components/places/tests/chrome/test_treeview_date.xul create mode 100644 browser/components/places/tests/unit/.eslintrc.js create mode 100644 browser/components/places/tests/unit/bookmarks.glue.html create mode 100644 browser/components/places/tests/unit/bookmarks.glue.json create mode 100644 browser/components/places/tests/unit/corruptDB.sqlite create mode 100644 browser/components/places/tests/unit/distribution.ini create mode 100644 browser/components/places/tests/unit/head_bookmarks.js create mode 100644 browser/components/places/tests/unit/test_421483.js create mode 100644 browser/components/places/tests/unit/test_PUIU_makeTransaction.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_bookmarkshtml.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_corrupt.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_corrupt_nobackup_default.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_distribution.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_migrate.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_prefs.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_restore.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_smartBookmarks.js create mode 100644 browser/components/places/tests/unit/test_browserGlue_urlbar_defaultbehavior_migration.js create mode 100644 browser/components/places/tests/unit/test_clearHistory_shutdown.js create mode 100644 browser/components/places/tests/unit/test_leftpane_corruption_handling.js create mode 100644 browser/components/places/tests/unit/xpcshell.ini (limited to 'browser/components/places') diff --git a/browser/components/places/PlacesUIUtils.jsm b/browser/components/places/PlacesUIUtils.jsm new file mode 100644 index 000000000..b25835a71 --- /dev/null +++ b/browser/components/places/PlacesUIUtils.jsm @@ -0,0 +1,1774 @@ +/* -*- 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/. */ + +this.EXPORTED_SYMBOLS = ["PlacesUIUtils"]; + +var Ci = Components.interfaces; +var Cc = Components.classes; +var Cr = Components.results; +var Cu = Components.utils; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", + "resource://gre/modules/PluralForm.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow", + "resource:///modules/RecentWindow.jsm"); + +// PlacesUtils exposes multiple symbols, so we can't use defineLazyModuleGetter. +Cu.import("resource://gre/modules/PlacesUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesTransactions", + "resource://gre/modules/PlacesTransactions.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "CloudSync", + "resource://gre/modules/CloudSync.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "Weave", + "resource://services-sync/main.js"); + +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. +let gFaviconLoadDataMap = new Map(); + +// copied from utilityOverlay.js +const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab"; + +// This function isn't public both because it's synchronous and because it is +// going to be removed in bug 1072833. +function IsLivemark(aItemId) { + // Since this check may be done on each dragover event, it's worth maintaining + // a cache. + let self = IsLivemark; + if (!("ids" in self)) { + const LIVEMARK_ANNO = PlacesUtils.LMANNO_FEEDURI; + + let idsVec = PlacesUtils.annotations.getItemsWithAnnotation(LIVEMARK_ANNO); + self.ids = new Set(idsVec); + + let obs = Object.freeze({ + QueryInterface: XPCOMUtils.generateQI(Ci.nsIAnnotationObserver), + + onItemAnnotationSet(itemId, annoName) { + if (annoName == LIVEMARK_ANNO) + self.ids.add(itemId); + }, + + onItemAnnotationRemoved(itemId, annoName) { + // If annoName is set to an empty string, the item is gone. + if (annoName == LIVEMARK_ANNO || annoName == "") + self.ids.delete(itemId); + }, + + onPageAnnotationSet() { }, + onPageAnnotationRemoved() { }, + }); + PlacesUtils.annotations.addObserver(obs); + PlacesUtils.registerShutdownFunction(() => { + PlacesUtils.annotations.removeObserver(obs); + }); + } + return self.ids.has(aItemId); +} + +let 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", false); + Services.ppmm.addMessageListener("Toolkit:inner-window-destroyed", msg => { + this.removeRequestsForInner(msg.data); + }); + }, + + loadFavicon(browser, principal, uri) { + 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; + + // Immediately cancel any earlier requests + this.removeRequestsForInner(innerWindowID); + + // 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); + + // 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); + }, +}; + +this.PlacesUIUtils = { + ORGANIZER_LEFTPANE_VERSION: 7, + 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 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); + }, + + get _copyableAnnotations() { + return [ + this.DESCRIPTION_ANNO, + this.LOAD_IN_SIDEBAR_ANNO, + PlacesUtils.READ_ONLY_ANNO, + ]; + }, + + /** + * Get a transaction for copying a uri item (either a bookmark or a history + * entry) from one container to another. + * + * @param aData + * JSON object of dropped or pasted item properties + * @param aContainer + * The container being copied into + * @param aIndex + * The index within the container the item is copied to + * @return A nsITransaction object that performs the copy. + * + * @note Since a copy creates a completely new item, only some internal + * annotations are synced from the old one. + * @see this._copyableAnnotations for the list of copyable annotations. + */ + _getURIItemCopyTransaction: + function PUIU__getURIItemCopyTransaction(aData, aContainer, aIndex) + { + let transactions = []; + if (aData.dateAdded) { + transactions.push( + new PlacesEditItemDateAddedTransaction(null, aData.dateAdded) + ); + } + if (aData.lastModified) { + transactions.push( + new PlacesEditItemLastModifiedTransaction(null, aData.lastModified) + ); + } + + let annos = []; + if (aData.annos) { + annos = aData.annos.filter(function (aAnno) { + return this._copyableAnnotations.includes(aAnno.name); + }, this); + } + + // There's no need to copy the keyword since it's bound to the bookmark url. + return new PlacesCreateBookmarkTransaction(PlacesUtils._uri(aData.uri), + aContainer, aIndex, aData.title, + null, annos, transactions); + }, + + /** + * Gets a transaction for copying (recursively nesting to include children) + * a folder (or container) and its contents from one folder to another. + * + * @param aData + * Unwrapped dropped folder data - Obj containing folder and children + * @param aContainer + * The container we are copying into + * @param aIndex + * The index in the destination container to insert the new items + * @return A nsITransaction object that will perform the copy. + * + * @note Since a copy creates a completely new item, only some internal + * annotations are synced from the old one. + * @see this._copyableAnnotations for the list of copyable annotations. + */ + _getFolderCopyTransaction(aData, aContainer, aIndex) { + function getChildItemsTransactions(aRoot) { + let transactions = []; + let index = aIndex; + for (let i = 0; i < aRoot.childCount; ++i) { + let child = aRoot.getChild(i); + // Temporary hacks until we switch to PlacesTransactions.jsm. + let isLivemark = + PlacesUtils.annotations.itemHasAnnotation(child.itemId, + PlacesUtils.LMANNO_FEEDURI); + let [node] = PlacesUtils.unwrapNodes( + PlacesUtils.wrapNode(child, PlacesUtils.TYPE_X_MOZ_PLACE, isLivemark), + PlacesUtils.TYPE_X_MOZ_PLACE + ); + + // Make sure that items are given the correct index, this will be + // passed by the transaction manager to the backend for the insertion. + // Insertion behaves differently for DEFAULT_INDEX (append). + if (aIndex != PlacesUtils.bookmarks.DEFAULT_INDEX) { + index = i; + } + + if (node.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + if (node.livemark && node.annos) { + transactions.push( + PlacesUIUtils._getLivemarkCopyTransaction(node, aContainer, index) + ); + } + else { + transactions.push( + PlacesUIUtils._getFolderCopyTransaction(node, aContainer, index) + ); + } + } + else if (node.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) { + transactions.push(new PlacesCreateSeparatorTransaction(-1, index)); + } + else if (node.type == PlacesUtils.TYPE_X_MOZ_PLACE) { + transactions.push( + PlacesUIUtils._getURIItemCopyTransaction(node, -1, index) + ); + } + else { + throw new Error("Unexpected item under a bookmarks folder"); + } + } + return transactions; + } + + if (aContainer == PlacesUtils.tagsFolderId) { // Copying into a tag folder. + let transactions = []; + if (!aData.livemark && aData.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) { + let {root} = PlacesUtils.getFolderContents(aData.id, false, false); + let urls = PlacesUtils.getURLsForContainerNode(root); + root.containerOpen = false; + for (let { uri } of urls) { + transactions.push( + new PlacesTagURITransaction(NetUtil.newURI(uri), [aData.title]) + ); + } + } + return new PlacesAggregatedTransaction("addTags", transactions); + } + + if (aData.livemark && aData.annos) { // Copying a livemark. + return this._getLivemarkCopyTransaction(aData, aContainer, aIndex); + } + + let {root} = PlacesUtils.getFolderContents(aData.id, false, false); + let transactions = getChildItemsTransactions(root); + root.containerOpen = false; + + if (aData.dateAdded) { + transactions.push( + new PlacesEditItemDateAddedTransaction(null, aData.dateAdded) + ); + } + if (aData.lastModified) { + transactions.push( + new PlacesEditItemLastModifiedTransaction(null, aData.lastModified) + ); + } + + let annos = []; + if (aData.annos) { + annos = aData.annos.filter(function (aAnno) { + return this._copyableAnnotations.includes(aAnno.name); + }, this); + } + + return new PlacesCreateFolderTransaction(aData.title, aContainer, aIndex, + annos, transactions); + }, + + /** + * Gets a transaction for copying a live bookmark item from one container to + * another. + * + * @param aData + * Unwrapped live bookmarkmark data + * @param aContainer + * The container we are copying into + * @param aIndex + * The index in the destination container to insert the new items + * @return A nsITransaction object that will perform the copy. + * + * @note Since a copy creates a completely new item, only some internal + * annotations are synced from the old one. + * @see this._copyableAnnotations for the list of copyable annotations. + */ + _getLivemarkCopyTransaction: + function PUIU__getLivemarkCopyTransaction(aData, aContainer, aIndex) + { + if (!aData.livemark || !aData.annos) { + throw new Error("node is not a livemark"); + } + + let feedURI, siteURI; + let annos = []; + if (aData.annos) { + annos = aData.annos.filter(function (aAnno) { + if (aAnno.name == PlacesUtils.LMANNO_FEEDURI) { + feedURI = PlacesUtils._uri(aAnno.value); + } + else if (aAnno.name == PlacesUtils.LMANNO_SITEURI) { + siteURI = PlacesUtils._uri(aAnno.value); + } + return this._copyableAnnotations.includes(aAnno.name) + }, this); + } + + return new PlacesCreateLivemarkTransaction(feedURI, siteURI, aData.title, + aContainer, aIndex, annos); + }, + + /** + * Constructs a Transaction for the drop or paste of a blob of data into + * a container. + * @param data + * The unwrapped data blob of dropped or pasted data. + * @param type + * The content type of the data + * @param container + * The container the data was dropped or pasted into + * @param index + * The index within the container the item was dropped or pasted at + * @param copy + * The drag action was copy, so don't move folders or links. + * @return An object implementing nsITransaction that can perform + * the move/insert. + */ + makeTransaction: + function PUIU_makeTransaction(data, type, container, index, copy) + { + switch (data.type) { + case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: + if (copy) { + return this._getFolderCopyTransaction(data, container, index); + } + + // Otherwise move the item. + return new PlacesMoveItemTransaction(data.id, container, index); + case PlacesUtils.TYPE_X_MOZ_PLACE: + if (copy || data.id == -1) { // Id is -1 if the place is not bookmarked. + return this._getURIItemCopyTransaction(data, container, index); + } + + // Otherwise move the item. + return new PlacesMoveItemTransaction(data.id, container, index); + case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: + if (copy) { + // There is no data in a separator, so copying it just amounts to + // inserting a new separator. + return new PlacesCreateSeparatorTransaction(container, index); + } + + // Otherwise move the item. + return new PlacesMoveItemTransaction(data.id, container, index); + default: + if (type == PlacesUtils.TYPE_X_MOZ_URL || + type == PlacesUtils.TYPE_UNICODE || + type == TAB_DROP_TYPE) { + let title = type != PlacesUtils.TYPE_UNICODE ? data.title + : data.uri; + return new PlacesCreateBookmarkTransaction(PlacesUtils._uri(data.uri), + container, index, title); + } + } + return null; + }, + + /** + * ********* PlacesTransactions version of the function defined above ******** + * + * 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 aType + * The content type of the 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: function(aData, aType, aNewParentGuid, aIndex, aCopy) { + if (!this.SUPPORTED_FLAVORS.includes(aData.type)) + throw new Error(`Unsupported '${aData.type}' data type`); + + if ("itemGuid" in aData) { + 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({ uri: NetUtil.newURI(aData.uri) + , title: title + , parentGuid: aNewParentGuid + , index: aIndex }); + }, + + /** + * 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: + function PUIU_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://browser/content/places/bookmarkProperties2.xul" : + "chrome://browser/content/places/bookmarkProperties.xul"; + + let features = "centerscreen,chrome,modal,resizable=yes"; + aParentWindow.openDialog(dialogURL, "", features, aInfo); + return ("performed" in aInfo && aInfo.performed); + }, + + _getTopBrowserWin: function PUIU__getTopBrowserWin() { + return RecentWindow.getMostRecentBrowserWindow(); + }, + + /** + * 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) { + if (gInContentProcess) { + throw new Error("Can't track loads from within the child process!"); + } + InternalFaviconLoader.loadFavicon(browser, principal, uri); + }, + + /** + * 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; + + // The view for a of which its associated menupopup is a places + // view, is the menupopup. + if (node.localName == "menu" && !node._placesNode && + node.lastChild._placesView) + return node.lastChild._placesView; + + while (node instanceof Ci.nsIDOMElement) { + if (node._placesView) + return node._placesView; + if (node.localName == "tree" && node.getAttribute("type") == "places") + return node; + + node = node.parentNode; + } + + return null; + }, + + /** + * 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 = PlacesUtils._uri(aURINode.uri); + if (uri.schemeIs("javascript") || uri.schemeIs("data")) { + const BRANDING_BUNDLE_URI = "chrome://branding/locale/brand.properties"; + var brandShortName = Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService). + createBundle(BRANDING_BUNDLE_URI). + GetStringFromName("brandShortName"); + + var errorStr = this.getString("load-js-data-url-error"); + Services.prompt.alert(aWindow, brandShortName, errorStr); + return false; + } + return true; + }, + + /** + * Get the description associated with a document, as specified in a + * element. + * @param doc + * A DOM Document to get a description for + * @return A description string if a META element was discovered with a + * "description" or "httpequiv" attribute, empty string otherwise. + */ + getDescriptionFromDocument: function PUIU_getDescriptionFromDocument(doc) { + var metaElements = doc.getElementsByTagName("META"); + for (var i = 0; i < metaElements.length; ++i) { + if (metaElements[i].name.toLowerCase() == "description" || + metaElements[i].httpEquiv.toLowerCase() == "description") { + return metaElements[i].content; + } + } + return ""; + }, + + /** + * Retrieve the description of an item + * @param aItemId + * item identifier + * @return the description of the given item, or an empty string if it is + * not set. + */ + getItemDescription: function PUIU_getItemDescription(aItemId) { + if (PlacesUtils.annotations.itemHasAnnotation(aItemId, this.DESCRIPTION_ANNO)) + return PlacesUtils.annotations.getItemAnnotation(aItemId, this.DESCRIPTION_ANNO); + return ""; + }, + + /** + * Check whether or not the given node represents a removable entry (either in + * history or in bookmarks). + * + * @param aNode + * a node, except the root node of a query. + * @return true if the aNode represents a removable entry, false otherwise. + */ + canUserRemove: function (aNode) { + let parentNode = aNode.parent; + if (!parentNode) { + // canUserRemove doesn't accept root nodes. + 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.isContentsReadOnly(parentNode); + }, + + /** + * 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 node or item-id 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 item ids or folder nodes for aNodeOrItemId. + * While this is only enforced for the node case (if an item id of a separator + * or a bookmark is passed, false is returned), it's considered the caller's + * job to ensure that it checks a folder. + * Also note that folder-shortcuts should only be passed as result nodes. + * Otherwise they are just treated as bookmarks (i.e. false is returned). + * + * @param aNodeOrItemId + * any item id or result node. + * @throws if aNodeOrItemId is neither an item id nor a folder result node. + * @note livemark "folders" are considered read-only (but see bug 1072833). + * @return true if aItemId points to a read-only folder, false otherwise. + */ + isContentsReadOnly: function (aNodeOrItemId) { + let itemId; + if (typeof(aNodeOrItemId) == "number") { + itemId = aNodeOrItemId; + } + else if (PlacesUtils.nodeIsFolder(aNodeOrItemId)) { + itemId = PlacesUtils.getConcreteItemId(aNodeOrItemId); + } + else { + throw new Error("invalid value for aNodeOrItemId"); + } + + if (itemId == PlacesUtils.placesRootId || IsLivemark(itemId)) + return true; + + // leftPaneFolderId, and as a result, allBookmarksFolderId, 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 ("get" in Object.getOwnPropertyDescriptor(this, "leftPaneFolderId")) + return false; + + return itemId == this.leftPaneFolderId || + itemId == this.allBookmarksFolderId; + }, + + /** + * Gives the user a chance to cancel loading lots of tabs at once + */ + confirmOpenInTabs(numTabsToOpen, aWindow) { + const WARN_ON_OPEN_PREF = "browser.tabs.warnOnOpen"; + var reallyOpen = true; + + if (Services.prefs.getBoolPref(WARN_ON_OPEN_PREF)) { + if (numTabsToOpen >= Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn")) { + // default to true: if it were false, we wouldn't get this far + var warnOnOpen = { value: true }; + + var messageKey = "tabs.openWarningMultipleBranded"; + var openKey = "tabs.openButtonMultiple"; + const BRANDING_BUNDLE_URI = "chrome://branding/locale/brand.properties"; + var brandShortName = Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService). + createBundle(BRANDING_BUNDLE_URI). + GetStringFromName("brandShortName"); + + var buttonPressed = Services.prompt.confirmEx( + aWindow, + this.getString("tabs.openWarningTitle"), + this.getFormattedString(messageKey, [numTabsToOpen, brandShortName]), + (Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0) + + (Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1), + this.getString(openKey), null, null, + this.getFormattedString("tabs.openWarningPromptMeBranded", + [brandShortName]), + warnOnOpen + ); + + reallyOpen = (buttonPressed == 0); + // don't set the pref unless they press OK and it's false + if (reallyOpen && !warnOnOpen.value) + Services.prefs.setBoolPref(WARN_ON_OPEN_PREF, false); + } + } + + return reallyOpen; + }, + + /** 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 : this._getTopBrowserWin(); + + 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, /* weak =*/ false); + browserWindow = Services.ww.openWindow(aWindow, + "chrome://browser/content/browser.xul", + null, "chrome,dialog=no,all", args); + return; + } + + var loadInBackground = where == "tabshifted" ? true : false; + // 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, loadInBackground, false); + }, + + 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 (this.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 (this.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 aView + * The controller associated with aNode. + */ + openNodeWithEvent: + function PUIU_openNodeWithEvent(aNode, aEvent, aView) { + let window = aView.ownerWindow; + this._openNodeIn(aNode, window.whereToOpenLink(aEvent, false, true), 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 + 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.loadBookmarksInBackground"), + 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 + // PlacesUtils._uri() will throw if aNode.uri is not a valid URI + try { + var uri = PlacesUtils._uri(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.path; + } else { + title = host + (fileName ? + (host ? "/" + this.ellipsis + "/" : "") + fileName : + uri.path); + } + } + 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 the folder id for the organizer left-pane folder. + get leftPaneFolderId() { + let leftPaneRoot = -1; + let allBookmarksId; + + // 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") }, + "Downloads": { title: this.getString("OrganizerQueryDownloads") }, + "Tags": { title: this.getString("OrganizerQueryTags") }, + "AllBookmarks": { title: this.getString("OrganizerQueryAllBookmarks") }, + "BookmarksToolbar": + { title: null, + concreteTitle: PlacesUtils.getString("BookmarksToolbarFolderTitle"), + concreteId: PlacesUtils.toolbarFolderId }, + "BookmarksMenu": + { title: null, + concreteTitle: PlacesUtils.getString("BookmarksMenuFolderTitle"), + concreteId: PlacesUtils.bookmarksMenuFolderId }, + "UnfiledBookmarks": + { title: null, + concreteTitle: PlacesUtils.getString("OtherBookmarksFolderTitle"), + concreteId: PlacesUtils.unfiledBookmarksFolderId }, + }; + // All queries but PlacesRoot. + const EXPECTED_QUERY_COUNT = 7; + + // 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.getItemIndex(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 items = 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 < items.length; i++) { + let queryName = as.getItemAnnotation(items[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 = items[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 (!items.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. + items.forEach(safeRemoveItem); + safeRemoveItem(leftPaneRoot); + } + else { + // Everything is fine, return the current left pane folder. + delete this.leftPaneFolderId; + return this.leftPaneFolderId = 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, + PlacesUtils._uri(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); + + // Downloads. + this.create_query("Downloads", leftPaneRoot, + "place:transition=" + + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + + "&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. + allBookmarksId = this.create_folder("AllBookmarks", leftPaneRoot, false); + + // All Bookmarks->Bookmarks Toolbar Query. + this.create_query("BookmarksToolbar", allBookmarksId, + "place:folder=TOOLBAR"); + + // All Bookmarks->Bookmarks Menu Query. + this.create_query("BookmarksMenu", allBookmarksId, + "place:folder=BOOKMARKS_MENU"); + + // All Bookmarks->Unfiled Bookmarks Query. + this.create_query("UnfiledBookmarks", allBookmarksId, + "place:folder=UNFILED_BOOKMARKS"); + } + }; + bs.runInBatchMode(callback, null); + + delete this.leftPaneFolderId; + return this.leftPaneFolderId = leftPaneRoot; + }, + + /** + * Get the folder id for the organizer left-pane folder. + */ + get allBookmarksFolderId() { + // ensure the left-pane root is initialized; + this.leftPaneFolderId; + delete this.allBookmarksFolderId; + return this.allBookmarksFolderId = this.leftPaneQueries["AllBookmarks"]; + }, + + /** + * 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: function() { + 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; + }, + + /** + * WARNING TO ADDON AUTHORS: DO NOT USE THIS METHOD. IT"S LIKELY TO BE REMOVED IN A + * FUTURE RELEASE. + * + * Helpers for consumers of editBookmarkOverlay which don't have a node as their input. + * Given a partial node-like object, having at least the itemId property set, this + * method completes the rest of the properties necessary for initialising the edit + * overlay with it. + * + * @param aNodeLike + * an object having at least the itemId nsINavHistoryResultNode property set, + * along with any other properties available. + */ + completeNodeLikeObjectForItemId(aNodeLike) { + if (this.useAsyncTransactions) { + // When async-transactions are enabled, node-likes must have + // bookmarkGuid set, and we cannot set it synchronously. + throw new Error("completeNodeLikeObjectForItemId cannot be used when " + + "async transactions are enabled"); + } + if (!("itemId" in aNodeLike)) + throw new Error("itemId missing in aNodeLike"); + + let itemId = aNodeLike.itemId; + let defGetter = XPCOMUtils.defineLazyGetter.bind(XPCOMUtils, aNodeLike); + + if (!("title" in aNodeLike)) + defGetter("title", () => PlacesUtils.bookmarks.getItemTitle(itemId)); + + if (!("uri" in aNodeLike)) { + defGetter("uri", () => { + let uri = null; + try { + uri = PlacesUtils.bookmarks.getBookmarkURI(itemId); + } + catch (ex) { } + return uri ? uri.spec : ""; + }); + } + + if (!("type" in aNodeLike)) { + defGetter("type", () => { + if (aNodeLike.uri.length > 0) { + if (/^place:/.test(aNodeLike.uri)) { + if (this.isFolderShortcutQueryString(aNodeLike.uri)) + return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT; + + return Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY; + } + + return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI; + } + + let itemType = PlacesUtils.bookmarks.getItemType(itemId); + if (itemType == PlacesUtils.bookmarks.TYPE_FOLDER) + return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER; + + throw new Error("Unexpected item type"); + }); + } + }, + + /** + * 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. + */ + promiseNodeLikeFromFetchInfo: Task.async(function* (aFetchInfo) { + if (aFetchInfo.itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR) + throw new Error("promiseNodeLike doesn't support separators"); + + return Object.freeze({ + itemId: yield 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; + } + }); + }), + + /** + * Shortcut for calling promiseNodeLikeFromFetchInfo on the result of + * Bookmarks.fetch for the given guid/info object. + * + * @see promiseNodeLikeFromFetchInfo above and Bookmarks.fetch in Bookmarks.jsm. + */ + fetchNodeLike: Task.async(function* (aGuidOrInfo) { + let info = yield PlacesUtils.bookmarks.fetch(aGuidOrInfo); + if (!info) + return null; + return (yield this.promiseNodeLikeFromFetchInfo(info)); + }) +}; + + +PlacesUIUtils.PLACES_FLAVORS = [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER, + PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR, + PlacesUtils.TYPE_X_MOZ_PLACE]; + +PlacesUIUtils.URI_FLAVORS = [PlacesUtils.TYPE_X_MOZ_URL, + TAB_DROP_TYPE, + PlacesUtils.TYPE_UNICODE], + +PlacesUIUtils.SUPPORTED_FLAVORS = [...PlacesUIUtils.PLACES_FLAVORS, + ...PlacesUIUtils.URI_FLAVORS]; + +XPCOMUtils.defineLazyServiceGetter(PlacesUIUtils, "RDF", + "@mozilla.org/rdf/rdf-service;1", + "nsIRDFService"); + +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function() { + return Services.prefs.getComplexValue("intl.ellipsis", + Ci.nsIPrefLocalizedString).data; +}); + +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "useAsyncTransactions", function() { + try { + return Services.prefs.getBoolPref("browser.places.useAsyncTransactions"); + } + catch (ex) { } + return false; +}); + +XPCOMUtils.defineLazyServiceGetter(this, "URIFixup", + "@mozilla.org/docshell/urifixup;1", + "nsIURIFixup"); + +XPCOMUtils.defineLazyGetter(this, "bundle", function() { + const PLACES_STRING_BUNDLE_URI = + "chrome://browser/locale/places/places.properties"; + return Cc["@mozilla.org/intl/stringbundle;1"]. + getService(Ci.nsIStringBundleService). + createBundle(PLACES_STRING_BUNDLE_URI); +}); + +/** + * This is a compatibility shim for old PUIU.ptm users. + * + * If you're looking for transactions and writing new code using them, directly + * use the transactions objects exported by the PlacesUtils.jsm module. + * + * This object will be removed once enough users are converted to the new API. + */ +XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ptm", function() { + // Ensure PlacesUtils is imported in scope. + PlacesUtils; + + return { + aggregateTransactions: (aName, aTransactions) => + new PlacesAggregatedTransaction(aName, aTransactions), + + createFolder: (aName, aContainer, aIndex, aAnnotations, + aChildItemsTransactions) => + new PlacesCreateFolderTransaction(aName, aContainer, aIndex, aAnnotations, + aChildItemsTransactions), + + createItem: (aURI, aContainer, aIndex, aTitle, aKeyword, + aAnnotations, aChildTransactions) => + new PlacesCreateBookmarkTransaction(aURI, aContainer, aIndex, aTitle, + aKeyword, aAnnotations, + aChildTransactions), + + createSeparator: (aContainer, aIndex) => + new PlacesCreateSeparatorTransaction(aContainer, aIndex), + + createLivemark: (aFeedURI, aSiteURI, aName, aContainer, aIndex, + aAnnotations) => + new PlacesCreateLivemarkTransaction(aFeedURI, aSiteURI, aName, aContainer, + aIndex, aAnnotations), + + moveItem: (aItemId, aNewContainer, aNewIndex) => + new PlacesMoveItemTransaction(aItemId, aNewContainer, aNewIndex), + + removeItem: (aItemId) => + new PlacesRemoveItemTransaction(aItemId), + + editItemTitle: (aItemId, aNewTitle) => + new PlacesEditItemTitleTransaction(aItemId, aNewTitle), + + editBookmarkURI: (aItemId, aNewURI) => + new PlacesEditBookmarkURITransaction(aItemId, aNewURI), + + setItemAnnotation: (aItemId, aAnnotationObject) => + new PlacesSetItemAnnotationTransaction(aItemId, aAnnotationObject), + + setPageAnnotation: (aURI, aAnnotationObject) => + new PlacesSetPageAnnotationTransaction(aURI, aAnnotationObject), + + editBookmarkKeyword: (aItemId, aNewKeyword) => + new PlacesEditBookmarkKeywordTransaction(aItemId, aNewKeyword), + + editLivemarkSiteURI: (aLivemarkId, aSiteURI) => + new PlacesEditLivemarkSiteURITransaction(aLivemarkId, aSiteURI), + + editLivemarkFeedURI: (aLivemarkId, aFeedURI) => + new PlacesEditLivemarkFeedURITransaction(aLivemarkId, aFeedURI), + + editItemDateAdded: (aItemId, aNewDateAdded) => + new PlacesEditItemDateAddedTransaction(aItemId, aNewDateAdded), + + editItemLastModified: (aItemId, aNewLastModified) => + new PlacesEditItemLastModifiedTransaction(aItemId, aNewLastModified), + + sortFolderByName: (aFolderId) => + new PlacesSortFolderByNameTransaction(aFolderId), + + tagURI: (aURI, aTags) => + new PlacesTagURITransaction(aURI, aTags), + + untagURI: (aURI, aTags) => + new PlacesUntagURITransaction(aURI, aTags), + + /** + * Transaction for setting/unsetting Load-in-sidebar annotation. + * + * @param aBookmarkId + * id of the bookmark where to set Load-in-sidebar annotation. + * @param aLoadInSidebar + * boolean value. + * @return nsITransaction object. + */ + setLoadInSidebar: function(aItemId, aLoadInSidebar) + { + let annoObj = { name: PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, + type: Ci.nsIAnnotationService.TYPE_INT32, + flags: 0, + value: aLoadInSidebar, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + return new PlacesSetItemAnnotationTransaction(aItemId, annoObj); + }, + + /** + * Transaction for editing the description of a bookmark or a folder. + * + * @param aItemId + * id of the item to edit. + * @param aDescription + * new description. + * @return nsITransaction object. + */ + editItemDescription: function(aItemId, aDescription) + { + let annoObj = { name: PlacesUIUtils.DESCRIPTION_ANNO, + type: Ci.nsIAnnotationService.TYPE_STRING, + flags: 0, + value: aDescription, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + return new PlacesSetItemAnnotationTransaction(aItemId, annoObj); + }, + + // nsITransactionManager forwarders. + + beginBatch: () => + PlacesUtils.transactionManager.beginBatch(null), + + endBatch: () => + PlacesUtils.transactionManager.endBatch(false), + + doTransaction: (txn) => + PlacesUtils.transactionManager.doTransaction(txn), + + undoTransaction: () => + PlacesUtils.transactionManager.undoTransaction(), + + redoTransaction: () => + PlacesUtils.transactionManager.redoTransaction(), + + get numberOfUndoItems() { + return PlacesUtils.transactionManager.numberOfUndoItems; + }, + get numberOfRedoItems() { + return PlacesUtils.transactionManager.numberOfRedoItems; + }, + get maxTransactionCount() { + return PlacesUtils.transactionManager.maxTransactionCount; + }, + set maxTransactionCount(val) { + PlacesUtils.transactionManager.maxTransactionCount = val; + }, + + clear: () => + PlacesUtils.transactionManager.clear(), + + peekUndoStack: () => + PlacesUtils.transactionManager.peekUndoStack(), + + peekRedoStack: () => + PlacesUtils.transactionManager.peekRedoStack(), + + getUndoStack: () => + PlacesUtils.transactionManager.getUndoStack(), + + getRedoStack: () => + PlacesUtils.transactionManager.getRedoStack(), + + AddListener: (aListener) => + PlacesUtils.transactionManager.AddListener(aListener), + + RemoveListener: (aListener) => + PlacesUtils.transactionManager.RemoveListener(aListener) + } +}); diff --git a/browser/components/places/content/bookmarkProperties.js b/browser/components/places/content/bookmarkProperties.js new file mode 100644 index 000000000..afcf65736 --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.js @@ -0,0 +1,693 @@ +/* -*- 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. + */ + +Components.utils.import('resource://gre/modules/XPCOMUtils.jsm'); +XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.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: [], + _batching: false, + + /** + * 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 + NS_ASSERT(this._itemType == BOOKMARK_FOLDER, "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 + */ + _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) { + NS_ASSERT("type" in dialogInfo, "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 InsertionPoint(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + Ci.nsITreeView.DROP_ON); + } + + switch (dialogInfo.type) { + case "bookmark": + this._itemType = BOOKMARK_ITEM; + if ("uri" in dialogInfo) { + NS_ASSERT(dialogInfo.uri instanceof Ci.nsIURI, + "uri property should be a uri object"); + this._uri = dialogInfo.uri; + if (typeof(this._title) != "string") { + this._title = this._getURITitleFromHistory(this._uri) || + this._uri.spec; + } + } + else { + this._uri = PlacesUtils._uri("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 = this._getURITitleFromHistory(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 returns the title string corresponding to a given URI. + * If none is available from the bookmark service (probably because + * the given URI doesn't appear in bookmarks or history), we synthesize + * a title from the first 100 characters of the URI. + * + * @param aURI + * nsIURI object for which we want the title + * + * @returns a title string + */ + _getURITitleFromHistory: function BPP__getURITitleFromHistory(aURI) { + NS_ASSERT(aURI instanceof Ci.nsIURI); + + // get the title from History + return PlacesUtils.history.getPageTitle(aURI); + }, + + /** + * This method should be called by the onload of the Bookmark Properties + * dialog to initialize the state of the panel. + */ + onDialogLoad: Task.async(function* () { + this._determineItemInfo(); + + document.title = this._getDialogTitle(); + var acceptButton = document.documentElement.getButton("accept"); + acceptButton.label = this._getAcceptLabel(); + + // 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); + + this._beginBatch(); + + switch (this._action) { + case ACTION_EDIT: + gEditItemOverlay.initPanel({ node: this._node + , hiddenRows: this._hiddenRows + , focusedElement: "first" }); + acceptButton.disabled = gEditItemOverlay.readOnly; + break; + case ACTION_ADD: + this._node = yield 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) + acceptButton.disabled = !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, false); + if (this._isAddKeywordDialog) { + this._element("keywordField") + .addEventListener("input", this, false); + } + } + } + }), + + // 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; + } + }, + + // Hack for implementing batched-Undo around the editBookmarkOverlay + // instant-apply code. For all the details see the comment above beginBatch + // in browser-places.js + _batchBlockingDeferred: null, + _beginBatch() { + if (this._batching) + return; + if (PlacesUIUtils.useAsyncTransactions) { + this._batchBlockingDeferred = PromiseUtils.defer(); + PlacesTransactions.batch(function* () { + yield this._batchBlockingDeferred.promise; + }.bind(this)); + } + else { + PlacesUtils.transactionManager.beginBatch(null); + } + this._batching = true; + }, + + _endBatch() { + if (!this._batching) + return; + + if (PlacesUIUtils.useAsyncTransactions) { + this._batchBlockingDeferred.resolve(); + this._batchBlockingDeferred = null; + } + else { + PlacesUtils.transactionManager.endBatch(false); + } + this._batching = false; + }, + + // 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, false); + }, + + onDialogAccept() { + // We must blur current focused element to save its changes correctly + document.commandDispatcher.focusedElement.blur(); + // The order here is important! We have to uninit the panel first, otherwise + // late changes could force it to commit more transactions. + gEditItemOverlay.uninitPanel(true); + this._endBatch(); + window.arguments[0].performed = true; + }, + + onDialogCancel() { + // The order here is important! We have to uninit the panel first, otherwise + // changes done as part of Undo may change the panel contents and by + // that force it to commit more transactions. + gEditItemOverlay.uninitPanel(true); + this._endBatch(); + if (PlacesUIUtils.useAsyncTransactions) + PlacesTransactions.undo().catch(Components.utils.reportError); + else + PlacesUtils.transactionManager.undoTransaction(); + 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] + */ + _getInsertionPointDetails: function BPP__getInsertionPointDetails() { + var containerId = this._defaultInsertionPoint.itemId; + var indexInContainer = this._defaultInsertionPoint.index; + + return [containerId, indexInContainer]; + }, + + /** + * Returns a transaction for creating a new bookmark item representing the + * various fields and opening arguments of the dialog. + */ + _getCreateNewBookmarkTransaction: + function BPP__getCreateNewBookmarkTransaction(aContainer, aIndex) { + var annotations = []; + var childTransactions = []; + + if (this._description) { + let annoObj = { name : PlacesUIUtils.DESCRIPTION_ANNO, + type : Ci.nsIAnnotationService.TYPE_STRING, + flags : 0, + value : this._description, + expires: Ci.nsIAnnotationService.EXPIRE_NEVER }; + let editItemTxn = new PlacesSetItemAnnotationTransaction(-1, annoObj); + childTransactions.push(editItemTxn); + } + + if (this._loadInSidebar) { + let annoObj = { name : PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO, + value : true }; + let setLoadTxn = new PlacesSetItemAnnotationTransaction(-1, annoObj); + childTransactions.push(setLoadTxn); + } + + // XXX TODO: this should be in a transaction! + if (this._charSet && !PrivateBrowsingUtils.isWindowPrivate(window)) + PlacesUtils.setCharsetForURI(this._uri, this._charSet); + + let createTxn = new PlacesCreateBookmarkTransaction(this._uri, + aContainer, + aIndex, + this._title, + this._keyword, + annotations, + childTransactions, + this._postData); + + return new PlacesAggregatedTransaction(this._getDialogTitle(), + [createTxn]); + }, + + /** + * Returns a childItems-transactions array representing the URIList with + * which the dialog has been opened. + */ + _getTransactionsForURIList: function BPP__getTransactionsForURIList() { + var transactions = []; + for (let uri of this._URIs) { + // uri should be an object in the form { url, title }. Though add-ons + // could still use the legacy form, where it's an nsIURI. + let [_uri, _title] = uri instanceof Ci.nsIURI ? + [uri, this._getURITitleFromHistory(uri)] : [uri.uri, uri.title]; + + let createTxn = + new PlacesCreateBookmarkTransaction(_uri, -1, + PlacesUtils.bookmarks.DEFAULT_INDEX, + _title); + transactions.push(createTxn); + } + return transactions; + }, + + /** + * Returns a transaction for creating a new folder item representing the + * various fields and opening arguments of the dialog. + */ + _getCreateNewFolderTransaction: + function BPP__getCreateNewFolderTransaction(aContainer, aIndex) { + var annotations = []; + var childItemsTransactions; + if (this._URIs.length) + childItemsTransactions = this._getTransactionsForURIList(); + + if (this._description) + annotations.push(this._getDescriptionAnnotation(this._description)); + + return new PlacesCreateFolderTransaction(this._title, aContainer, + aIndex, annotations, + childItemsTransactions); + }, + + _createNewItem: Task.async(function* () { + let [container, index] = this._getInsertionPointDetails(); + let txn; + switch (this._itemType) { + case BOOKMARK_FOLDER: + txn = this._getCreateNewFolderTransaction(container, index); + break; + case LIVEMARK_CONTAINER: + txn = new PlacesCreateLivemarkTransaction(this._feedURI, this._siteURI, + this._title, container, index); + break; + default: // BOOKMARK_ITEM + txn = this._getCreateNewBookmarkTransaction(container, index); + } + + PlacesUtils.transactionManager.doTransaction(txn); + // This is a temporary hack until we use PlacesTransactions.jsm + if (txn._promise) { + yield txn._promise; + } + + let folderGuid = yield PlacesUtils.promiseItemGuid(container); + let bm = yield PlacesUtils.bookmarks.fetch({ + parentGuid: folderGuid, + index: index + }); + this._itemId = yield PlacesUtils.promiseItemId(bm.guid); + + return Object.freeze({ + itemId: this._itemId, + bookmarkGuid: bm.guid, + title: this._title, + uri: this._uri ? this._uri.spec : "", + type: this._itemType == BOOKMARK_ITEM ? + Ci.nsINavHistoryResultNode.RESULT_TYPE_URI : + Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER + }); + }), + + _promiseNewItem: Task.async(function* () { + if (!PlacesUIUtils.useAsyncTransactions) + return this._createNewItem(); + + let [containerId, index] = this._getInsertionPointDetails(); + let parentGuid = yield PlacesUtils.promiseItemGuid(containerId); + 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 = yield PlacesTransactions.NewBookmark(info).transact(); + } + else if (this._itemType == LIVEMARK_CONTAINER) { + info.feedUrl = this._feedURI; + if (this._siteURI) + info.siteUrl = this._siteURI; + + itemGuid = yield PlacesTransactions.NewLivemark(info).transact(); + } + else if (this._itemType == BOOKMARK_FOLDER) { + itemGuid = yield PlacesTransactions.NewFolder(info).transact(); + for (let uri of this._URIs) { + let placeInfo = yield PlacesUtils.promisePlaceInfo(uri); + let title = placeInfo ? placeInfo.title : ""; + yield PlacesTransactions.transact({ parentGuid: itemGuid, uri, title }); + } + } + else { + throw new Error(`unexpected value for _itemType: ${this._itemType}`); + } + + this._itemGuid = itemGuid; + this._itemId = yield 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 + }); + }) +}; diff --git a/browser/components/places/content/bookmarkProperties.xul b/browser/components/places/content/bookmarkProperties.xul new file mode 100644 index 000000000..2c04f8b05 --- /dev/null +++ b/browser/components/places/content/bookmarkProperties.xul @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + %editBookmarkOverlayDTD; +]> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/places/content/editBookmarkOverlay.js b/browser/components/places/content/editBookmarkOverlay.js new file mode 100644 index 000000000..e26cfb138 --- /dev/null +++ b/browser/components/places/content/editBookmarkOverlay.js @@ -0,0 +1,1168 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Task", + "resource://gre/modules/Task.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"); + + 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 = PlacesUIUtils.useAsyncTransactions && 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 ? NetUtil.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; + if (node && "parent" in node) { + let parent = node.parent; + if (parent) { + isParentReadOnly = !PlacesUtils.nodeIsFolder(parent) || + PlacesUIUtils.isContentsReadOnly(parent); + } + } + let focusedElement = aInitInfo.focusedElement; + + return this._paneInfo = { itemId, itemGuid, isItem, + isURI, uri, title, + isBookmark, isFolderShortcut, isParentReadOnly, + bulkTagging, uris, + visibleRows, postData, isTag, focusedElement }; + }, + + 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)); + }, + + _initKeywordField: Task.async(function* (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 = []; + yield 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: Task.async(function* () { + 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 { itemId, isItem, isURI, + isBookmark, bulkTagging, uris, + visibleRows, focusedElement } = this._setPaneInfo(aInfo); + + let showOrCollapse = + (rowId, isAppropriateForInput, nameInHiddenRows = null) => { + let visible = isAppropriateForInput; + if (visible && "hiddenRows" in aInfo && nameInHiddenRows) + visible &= aInfo.hiddenRows.indexOf(nameInHiddenRows) == -1; + 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(Components.utils.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().catch(Components.utils.reportError); + else if (!this._element("tagsSelectorRow").collapsed) + this.toggleTagsSelector().catch(Components.utils.reportError); + + // 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")) { + let containerId = PlacesUtils.bookmarks.getFolderIdForItem(itemId); + this._initFolderMenuList(containerId); + } + + // 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, false); + window.addEventListener("unload", this, false); + this._observersAdded = true; + } + + // 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") { + elt = this._element(gPrefService.getCharPref("browser.bookmarks.editDialog.firstEditField")); + } else if (focusedElement === "first") { + elt = document.querySelector("textbox:not([collapsed=true])"); + } + if (elt) { + elt.focus(); + elt.select(); + } + }, + + /** + * 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 undo stack + let editor = aElement.editor; + if (editor) + editor.transactionManager.clear(); + } + }, + + /** + * 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. + * @return the new menu item. + */ + _appendFolderItemToMenupopup(aMenupopup, aFolderId) { + // First make sure the folders-separator is visible + this._element("foldersSeparator").hidden = false; + + var folderMenuItem = document.createElement("menuitem"); + var folderTitle = PlacesUtils.bookmarks.getItemTitle(aFolderId) + folderMenuItem.folderId = aFolderId; + folderMenuItem.setAttribute("label", folderTitle); + folderMenuItem.className = "menuitem-iconic folder-icon"; + aMenupopup.appendChild(folderMenuItem); + return folderMenuItem; + }, + + _initFolderMenuList: function EIO__initFolderMenuList(aSelectedFolder) { + // clean up first + var menupopup = this._folderMenuList.menupopup; + while (menupopup.childNodes.length > 6) + menupopup.removeChild(menupopup.lastChild); + + const bms = PlacesUtils.bookmarks; + const annos = PlacesUtils.annotations; + + // Build the static list + var unfiledItem = this._element("unfiledRootItem"); + if (!this._staticFoldersListBuilt) { + unfiledItem.label = bms.getItemTitle(PlacesUtils.unfiledBookmarksFolderId); + unfiledItem.folderId = PlacesUtils.unfiledBookmarksFolderId; + var bmMenuItem = this._element("bmRootItem"); + bmMenuItem.label = bms.getItemTitle(PlacesUtils.bookmarksMenuFolderId); + bmMenuItem.folderId = PlacesUtils.bookmarksMenuFolderId; + var toolbarItem = this._element("toolbarFolderItem"); + toolbarItem.label = bms.getItemTitle(PlacesUtils.toolbarFolderId); + toolbarItem.folderId = PlacesUtils.toolbarFolderId; + this._staticFoldersListBuilt = true; + } + + // List of recently used folders: + var folderIds = annos.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 i = 0; i < folderIds.length; i++) { + var lastUsed = annos.getItemAnnotation(folderIds[i], LAST_USED_ANNO); + this._recentFolders.push({ folderId: folderIds[i], lastUsed: 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++) { + this._appendFolderItemToMenupopup(menupopup, + this._recentFolders[i].folderId); + } + + var defaultItem = this._getFolderMenuItem(aSelectedFolder); + 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([Components.interfaces.nsIDOMEventListener, + Components.interfaces.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() { + if (this._paneInfo.isURI || this._paneInfo.bulkTagging) { + this._updateTags().then( + anyChanges => { + if (anyChanges) + this._mayUpdateFirstEditField("tagsField"); + }, Components.utils.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: [] }; + + let removedTags = aCurrentTags.filter(t => !inputTags.includes(t)); + let newTags = inputTags.filter(t => !aCurrentTags.includes(t)); + return { removedTags, newTags }; + }, + + // Adds and removes tags for one or more uris. + _setTagsFromTagsInputField: Task.async(function* (aCurrentTags, aURIs) { + let { removedTags, newTags } = this._getTagsChanges(aCurrentTags); + if (removedTags.length + newTags.length == 0) + return false; + + if (!PlacesUIUtils.useAsyncTransactions) { + let txns = []; + for (let uri of aURIs) { + if (removedTags.length > 0) + txns.push(new PlacesUntagURITransaction(uri, removedTags)); + if (newTags.length > 0) + txns.push(new PlacesTagURITransaction(uri, newTags)); + } + + PlacesUtils.transactionManager.doTransaction( + new PlacesAggregatedTransaction("Update tags", txns)); + return true; + } + + let setTags = function* () { + if (newTags.length > 0) { + yield PlacesTransactions.Tag({ urls: aURIs, tags: newTags }) + .transact(); + } + if (removedTags.length > 0) { + yield PlacesTransactions.Untag({ urls: aURIs, tags: removedTags }) + .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(Components.utils.reportError); + else + Task.spawn(setTags).catch(Components.utils.reportError); + return true; + }), + + _updateTags: Task.async(function*() { + let uris = this._paneInfo.bulkTagging ? + this._paneInfo.uris : [this._paneInfo.uri]; + let currentTags = this._paneInfo.bulkTagging ? + yield this._getCommonTags() : + PlacesUtils.tagging.getTagsForURI(uris[0]); + let anyChanges = yield 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 + var prefs = Cc["@mozilla.org/preferences-service;1"]. + getService(Ci.nsIPrefBranch); + prefs.setCharPref("browser.bookmarks.editDialog.firstEditField", aNewField); + }, + + 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 && + PlacesUtils.bookmarks.getFolderIdForItem(this._paneInfo.itemId) == PlacesUtils.tagsFolderId) { + // We don't allow setting an empty title for a tag, restore the old one. + this._initNamePicker(); + } + else { + this._mayUpdateFirstEditField("namePicker"); + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesEditItemTitleTransaction(this._paneInfo.itemId, + newTitle); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + Task.spawn(function* () { + let guid = this._paneInfo.isTag + ? (yield PlacesUtils.promiseItemGuid(this._paneInfo.itemId)) + : this._paneInfo.itemGuid; + PlacesTransactions.EditTitle({ guid, title: newTitle }) + .transact().catch(Components.utils.reportError); + }).catch(Components.utils.reportError); + } + }, + + onDescriptionFieldChange() { + if (this.readOnly || !this._paneInfo.isItem) + return; + + let itemId = this._paneInfo.itemId; + let description = this._element("descriptionField").value; + if (description != PlacesUIUtils.getItemDescription(this._paneInfo.itemId)) { + let annotation = + { name: PlacesUIUtils.DESCRIPTION_ANNO, value: description }; + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesSetItemAnnotationTransaction(itemId, + annotation); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Components.utils.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; + + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesEditBookmarkURITransaction(this._paneInfo.itemId, newURI); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.EditUrl({ guid, url: newURI }) + .transact().catch(Components.utils.reportError); + }, + + onKeywordFieldChange() { + if (this.readOnly || !this._paneInfo.isBookmark) + return; + + let itemId = this._paneInfo.itemId; + let oldKeyword = this._keyword; + let keyword = this._keyword = this._keywordField.value; + let postData = this._paneInfo.postData; + if (!PlacesUIUtils.useAsyncTransactions) { + let txn = new PlacesEditBookmarkKeywordTransaction(itemId, + keyword, + postData, + oldKeyword); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.EditKeyword({ guid, keyword, postData, oldKeyword }) + .transact().catch(Components.utils.reportError); + }, + + onLoadInSidebarCheckboxCommand() { + if (!this.initialized || !this._paneInfo.isBookmark) + return; + + let annotation = { name : PlacesUIUtils.LOAD_IN_SIDEBAR_ANNO }; + if (this._loadInSidebarCheckbox.checked) + annotation.value = true; + + if (!PlacesUIUtils.useAsyncTransactions) { + let itemId = this._paneInfo.itemId; + let txn = new PlacesSetItemAnnotationTransaction(itemId, + annotation); + PlacesUtils.transactionManager.doTransaction(txn); + return; + } + let guid = this._paneInfo.itemGuid; + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Components.utils.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&folder=" + + PlacesUIUtils.allBookmarksFolderId; + this._folderTree.place = FOLDER_TREE_PLACE_URI; + + this._element("chooseFolderSeparator").hidden = + this._element("chooseFolderMenuItem").hidden = true; + var currentFolder = this._getFolderIdFromMenuList(); + this._folderTree.selectItems([currentFolder]); + this._folderTree.focus(); + } + }, + + _getFolderIdFromMenuList() { + var selectedItem = this._folderMenuList.selectedItem; + NS_ASSERT("folderId" in selectedItem, + "Invalid menuitem in the folders-menulist"); + return selectedItem.folderId; + }, + + /** + * 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. + */ + _getFolderMenuItem(aFolderId) { + let menupopup = this._folderMenuList.menupopup; + let menuItem = Array.prototype.find.call( + menupopup.childNodes, menuItem => menuItem.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); + }, + + onFolderMenuListCommand(aEvent) { + // 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 containerId = PlacesUtils.bookmarks.getFolderIdForItem(this._paneInfo.itemId); + let item = this._getFolderMenuItem(containerId); + 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._getFolderIdFromMenuList(); + if (PlacesUtils.bookmarks.getFolderIdForItem(this._paneInfo.itemId) != containerId && + this._paneInfo.itemId != containerId) { + if (PlacesUIUtils.useAsyncTransactions) { + Task.spawn(function* () { + let newParentGuid = yield PlacesUtils.promiseItemGuid(containerId); + let guid = this._paneInfo.itemGuid; + yield PlacesTransactions.Move({ guid, newParentGuid }).transact(); + }.bind(this)); + } + else { + let txn = new PlacesMoveItemTransaction(this._paneInfo.itemId, + containerId, + PlacesUtils.bookmarks.DEFAULT_INDEX); + PlacesUtils.transactionManager.doTransaction(txn); + } + + // 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(Components.utils.reportError); + } + + // Auto-show the bookmarks toolbar when adding / moving an item there. + if (containerId == PlacesUtils.toolbarFolderId) { + Services.obs.notifyObservers(null, "autoshow-bookmarks-toolbar", null); + } + } + + // 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._getFolderIdFromMenuList() == folderId) + return; + + var folderItem = this._getFolderMenuItem(folderId); + this._folderMenuList.selectedItem = folderItem; + folderItem.doCommand(); + }, + + _markFolderAsRecentlyUsed: Task.async(function* (aFolderId) { + if (!PlacesUIUtils.useAsyncTransactions) { + let txns = []; + + // Expire old unused recent folders. + let annotation = this._getLastUsedAnnotationObject(false); + while (this._recentFolders.length > MAX_FOLDER_ITEM_IN_MENU_LIST) { + let folderId = this._recentFolders.pop().folderId; + let annoTxn = new PlacesSetItemAnnotationTransaction(folderId, + annotation); + txns.push(annoTxn); + } + + // Mark folder as recently used + annotation = this._getLastUsedAnnotationObject(true); + let annoTxn = new PlacesSetItemAnnotationTransaction(aFolderId, + annotation); + txns.push(annoTxn); + + let aggregate = + new PlacesAggregatedTransaction("Update last used folders", txns); + PlacesUtils.transactionManager.doTransaction(aggregate); + return; + } + + // 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 = yield PlacesUtils.promiseItemGuid(folderId); + guids.push(guid); + } + if (guids.length > 0) { + let annotation = this._getLastUsedAnnotationObject(false); + PlacesTransactions.Annotate({ guids, annotation }) + .transact().catch(Components.utils.reportError); + } + + // Mark folder as recently used + let annotation = this._getLastUsedAnnotationObject(true); + let guid = yield PlacesUtils.promiseItemGuid(aFolderId); + PlacesTransactions.Annotate({ guid, annotation }) + .transact().catch(Components.utils.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: Task.async(function* () { + 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 (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: Task.async(function* () { + 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; + yield this._rebuildTagsSelectorList(); + + // This is a no-op if we've added the listener. + tagsSelector.addEventListener("CheckboxStateChange", this, false); + } + 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. + }, + + newFolder: Task.async(function* () { + let ip = this._folderTree.insertionPoint; + + // default to the bookmarks menu folder + if (!ip || ip.itemId == PlacesUIUtils.allBookmarksFolderId) { + ip = new InsertionPoint(PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.bookmarks.DEFAULT_INDEX, + Ci.nsITreeView.DROP_ON); + } + + // XXXmano: add a separate "New Folder" string at some point... + let title = this._element("newFolderButton").label; + if (PlacesUIUtils.useAsyncTransactions) { + let parentGuid = yield ip.promiseGuid(); + yield PlacesTransactions.NewFolder({ parentGuid, title, index: ip.index }) + .transact().catch(Components.utils.reportError); + } + else { + let txn = new PlacesCreateFolderTransaction(title, ip.itemId, ip.index); + PlacesUtils.transactionManager.doTransaction(txn); + } + + 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: Task.async(function* () { + 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(", ")); + }), + + _onTagsChange(aItemId) { + let paneInfo = this._paneInfo; + let updateTagsField = false; + if (paneInfo.isURI) { + if (paneInfo.isBookmark && aItemId == paneInfo.itemId) { + updateTagsField = true; + } + else if (!paneInfo.isBookmark) { + let changedURI = PlacesUtils.bookmarks.getBookmarkURI(aItemId); + updateTagsField = changedURI.equals(paneInfo.uri); + } + } + else if (paneInfo.bulkTagging) { + let changedURI = PlacesUtils.bookmarks.getBookmarkURI(aItemId); + 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().catch(Components.utils.reportError); + + // Any tags change should be reflected in the tags selector. + if (this._element("tagsSelector")) + this._rebuildTagsSelectorList().catch(Components.utils.reportError); + }, + + _onItemTitleChange(aItemId, aNewTitle) { + if (!this._paneInfo.isBookmark) + return; + 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 (menuitem of menupopup.childNodes) { + if ("folderId" in menuitem && menuitem.folderId == aItemId) { + menuitem.label = aNewTitle; + break; + } + } + } + }, + + // nsINavBookmarkObserver + onItemChanged(aItemId, aProperty, aIsAnnotationProperty, aValue, + aLastModified, aItemType) { + if (aProperty == "tags" && this._paneInfo.visibleRows.has("tagsRow")) { + this._onTagsChange(aItemId); + } + else if (aProperty == "title" && this._paneInfo.isItem) { + // This also updates titles of folders in the folder menu list. + this._onItemTitleChange(aItemId, aValue); + } + else if (!this._paneInfo.isItem || this._paneInfo.itemId != aItemId) { + return; + } + + switch (aProperty) { + case "uri": + let newURI = NetUtil.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(aItemId); + } + } + break; + case "keyword": + if (this._paneInfo.visibleRows.has("keywordRow")) + this._initKeywordField(aValue).catch(Components.utils.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(aItemId, aOldParent, aOldIndex, + aNewParent, aNewIndex, aItemType) { + if (!this._paneInfo.isItem || + !this._paneInfo.visibleRows.has("folderPicker") || + this._paneInfo.itemId != aItemOd || + aNewParent == this._getFolderIdFromMenuList()) { + return; + } + + // Just setting selectItem _does not_ trigger oncommand, so we don't + // recurse. + this._folderMenuList.selectedItem = this._getFolderMenuItem(aNewParent); + }, + + 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/browser/components/places/content/editBookmarkOverlay.xul b/browser/components/places/content/editBookmarkOverlay.xul new file mode 100644 index 000000000..140e752c0 --- /dev/null +++ b/browser/components/places/content/editBookmarkOverlay.xul @@ -0,0 +1,188 @@ + + + +%editBookmarkOverlayDTD; +]> + + + + + + + + + + + + + + + + + + + + + + + +