--- a/browser/base/content/browser-places.js
+++ b/browser/base/content/browser-places.js
@@ -14,16 +14,19 @@ var StarUI = {
_batching: false,
_isNewBookmark: false,
_isComposing: false,
_autoCloseTimer: 0,
// The autoclose timer is diasbled if the user interacts with the
// popup, such as making a change through typing or clicking on
// the popup.
_autoCloseTimerEnabled: true,
+ // The autoclose timeout length. 3500ms matches the timeout that Pocket uses
+ // in browser/extensions/pocket/content/panels/js/saved.js.
+ _autoCloseTimeout: 3500,
_removeBookmarksOnPopupHidden: false,
_element(aID) {
return document.getElementById(aID);
},
// Edit-bookmark panel
get panel() {
@@ -79,18 +82,23 @@ var StarUI = {
case "mousemove":
clearTimeout(this._autoCloseTimer);
// The autoclose timer is not disabled on generic mouseout
// because the user may not have actually interacted with the popup.
break;
case "popuphidden": {
clearTimeout(this._autoCloseTimer);
if (aEvent.originalTarget == this.panel) {
- if (!this._element("editBookmarkPanelContent").hidden)
+ let selectedFolderGuid;
+
+ if (!this._element("editBookmarkPanelContent").hidden) {
+ // Get the folder first, before we uninit the overlay.
+ selectedFolderGuid = gEditItemOverlay.selectedFolderGuid;
this.quitEditMode();
+ }
if (this._anchorToolbarButton) {
this._anchorToolbarButton.removeAttribute("open");
this._anchorToolbarButton = null;
}
this._restoreCommandsState();
let removeBookmarksOnPopupHidden = this._removeBookmarksOnPopupHidden;
this._removeBookmarksOnPopupHidden = false;
@@ -108,16 +116,20 @@ var StarUI = {
}
// Remove all bookmarks for the bookmark's url, this also removes
// the tags for the url.
PlacesTransactions.Remove(guidsForRemoval)
.transact().catch(Cu.reportError);
} else if (this._isNewBookmark) {
LibraryUI.triggerLibraryAnimation("bookmark");
}
+
+ if (!removeBookmarksOnPopupHidden) {
+ this._storeRecentlyUsedFolder(selectedFolderGuid).catch(console.error);
+ }
}
break;
}
case "keypress":
clearTimeout(this._autoCloseTimer);
this._autoCloseTimerEnabled = false;
if (aEvent.defaultPrevented) {
@@ -181,19 +193,17 @@ var StarUI = {
// Explicit fall-through
case "popupshown":
// Don't handle events for descendent elements.
if (aEvent.target != aEvent.currentTarget) {
break;
}
// auto-close if new and not interacted with
if (this._isNewBookmark && !this._isComposing) {
- // 3500ms matches the timeout that Pocket uses in
- // browser/extensions/pocket/content/panels/js/saved.js
- let delay = 3500;
+ let delay = this._autoCloseTimeout;
if (this._closePanelQuickForTesting) {
delay /= 10;
}
clearTimeout(this._autoCloseTimer);
this._autoCloseTimer = setTimeout(() => {
if (!this.panel.mozMatchesSelector(":hover")) {
this.panel.hidePopup(true);
}
@@ -335,16 +345,43 @@ var StarUI = {
endBatch() {
if (!this._batching)
return;
this._batchBlockingDeferred.resolve();
this._batchBlockingDeferred = null;
this._batching = false;
+ },
+
+ async _storeRecentlyUsedFolder(selectedFolderGuid) {
+ // These are displayed by default, so don't save the folder for them.
+ if (!selectedFolderGuid ||
+ PlacesUtils.bookmarks.userContentRoots.includes(selectedFolderGuid)) {
+ return;
+ }
+
+ // List of recently used folders:
+ let lastUsedFolderGuids =
+ await PlacesUtils.metadata.get(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, []);
+
+ let index = lastUsedFolderGuids.indexOf(selectedFolderGuid);
+ if (index > 1) {
+ // The guid is in the array but not the most recent.
+ lastUsedFolderGuids.splice(index, 1);
+ lastUsedFolderGuids.unshift(selectedFolderGuid);
+ } else if (index == -1) {
+ lastUsedFolderGuids.unshift(selectedFolderGuid);
+ }
+ if (lastUsedFolderGuids.length > 5) {
+ lastUsedFolderGuids.pop();
+ }
+
+ await PlacesUtils.metadata.set(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
+ lastUsedFolderGuids);
}
};
var PlacesCommandHook = {
/**
* Adds a bookmark to the page loaded in the given browser.
*
* @param aBrowser
--- a/browser/components/places/PlacesUIUtils.jsm
+++ b/browser/components/places/PlacesUIUtils.jsm
@@ -206,16 +206,17 @@ let InternalFaviconLoader = {
let loadDataForWindow = gFaviconLoadDataMap.get(win);
loadDataForWindow.push(loadData);
},
};
var PlacesUIUtils = {
LOAD_IN_SIDEBAR_ANNO: "bookmarkProperties/loadInSidebar",
DESCRIPTION_ANNO: "bookmarkProperties/description",
+ LAST_USED_FOLDERS_META_KEY: "bookmarks/lastusedfolders",
/**
* 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) {
@@ -1027,17 +1028,17 @@ var PlacesUIUtils = {
setMouseoverURL(url, win) {
// When the browser window is closed with an open sidebar, the sidebar
// unload event happens after the browser's one. In this case
// top.XULBrowserWindow has been nullified already.
if (win.top.XULBrowserWindow) {
win.top.XULBrowserWindow.setOverLink(url, null);
}
- }
+ },
};
// These are lazy getters to avoid importing PlacesUtils immediately.
XPCOMUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => {
return [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER,
PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR,
PlacesUtils.TYPE_X_MOZ_PLACE];
});
--- a/browser/components/places/content/editBookmark.js
+++ b/browser/components/places/content/editBookmark.js
@@ -1,15 +1,14 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed";
const MAX_FOLDER_ITEM_IN_MENU_LIST = 5;
var gEditItemOverlay = {
_observersAdded: false,
_staticFoldersListBuilt: false,
_paneInfo: null,
_setPaneInfo(aInitInfo) {
@@ -41,33 +40,31 @@ var gEditItemOverlay = {
let uri = isURI || isTag ? Services.io.newURI(node.uri) : null;
let title = node ? node.title : null;
let isBookmark = isItem && isURI;
let bulkTagging = !node;
let uris = bulkTagging ? aInitInfo.uris : null;
let visibleRows = new Set();
let isParentReadOnly = false;
let postData = aInitInfo.postData;
- let parentId = -1;
let parentGuid = null;
if (node && isItem) {
if (!node.parent || (node.parent.itemId > 0 && !node.parent.bookmarkGuid)) {
throw new Error("Cannot use an incomplete node to initialize the edit bookmark panel");
}
let parent = node.parent;
isParentReadOnly = !PlacesUtils.nodeIsFolder(parent);
- parentId = parent.itemId;
parentGuid = parent.bookmarkGuid;
}
let focusedElement = aInitInfo.focusedElement;
let onPanelReady = aInitInfo.onPanelReady;
- return this._paneInfo = { itemId, itemGuid, parentId, parentGuid, isItem,
+ return this._paneInfo = { itemId, itemGuid, parentGuid, isItem,
isURI, uri, title,
isBookmark, isFolderShortcut, isParentReadOnly,
bulkTagging, uris,
visibleRows, postData, isTag, focusedElement,
onPanelReady, tag };
},
get initialized() {
@@ -204,17 +201,17 @@ var gEditItemOverlay = {
}
}
// For sanity ensure that the implementer has uninited the panel before
// trying to init it again, or we could end up leaking due to observers.
if (this.initialized)
this.uninitPanel(false);
- let { parentId, isItem, isURI,
+ let { parentGuid, isItem, isURI,
isBookmark, bulkTagging, uris,
visibleRows, focusedElement,
onPanelReady } = this._setPaneInfo(aInfo);
let showOrCollapse =
(rowId, isAppropriateForInput, nameInHiddenRows = null) => {
let visible = isAppropriateForInput;
if (visible && "hiddenRows" in aInfo && nameInHiddenRows)
@@ -253,17 +250,17 @@ var gEditItemOverlay = {
this._initLoadInSidebar();
}
// Folder picker.
// Technically we should check that the item is not moveable, but that's
// not cheap (we don't always have the parent), and there's no use case for
// this (it's only the Star UI that shows the folderPicker)
if (showOrCollapse("folderRow", isItem, "folderPicker")) {
- this._initFolderMenuList(parentId).catch(Cu.reportError);
+ this._initFolderMenuList(parentGuid).catch(Cu.reportError);
}
// Selection count.
if (showOrCollapse("selectionCount", bulkTagging)) {
this._element("itemsCountText").value =
PlacesUIUtils.getPluralString("detailsPane.itemsCountLabel",
uris.length,
[uris.length]);
@@ -352,97 +349,84 @@ var gEditItemOverlay = {
}
}
},
/**
* 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
+ * @param aFolderGuid
* The identifier of the bookmarks folder.
* @param aTitle
* The title to use as a label.
* @return the new menu item.
*/
- _appendFolderItemToMenupopup(aMenupopup, aFolderId, aTitle) {
+ _appendFolderItemToMenupopup(aMenupopup, aFolderGuid, aTitle) {
// First make sure the folders-separator is visible
this._element("foldersSeparator").hidden = false;
var folderMenuItem = document.createElement("menuitem");
- var folderTitle = aTitle;
- folderMenuItem.folderId = aFolderId;
- folderMenuItem.setAttribute("label", folderTitle);
+ folderMenuItem.folderGuid = aFolderGuid;
+ folderMenuItem.setAttribute("label", aTitle);
folderMenuItem.className = "menuitem-iconic folder-icon";
aMenupopup.appendChild(folderMenuItem);
return folderMenuItem;
},
- async _initFolderMenuList(aSelectedFolder) {
+ async _initFolderMenuList(aSelectedFolderGuid) {
// clean up first
var menupopup = this._folderMenuList.menupopup;
while (menupopup.childNodes.length > 6)
menupopup.removeChild(menupopup.lastChild);
// Build the static list
if (!this._staticFoldersListBuilt) {
let unfiledItem = this._element("unfiledRootItem");
unfiledItem.label = PlacesUtils.getString("OtherBookmarksFolderTitle");
- unfiledItem.folderId = PlacesUtils.unfiledBookmarksFolderId;
+ unfiledItem.folderGuid = PlacesUtils.bookmarks.unfiledGuid;
let bmMenuItem = this._element("bmRootItem");
bmMenuItem.label = PlacesUtils.getString("BookmarksMenuFolderTitle");
- bmMenuItem.folderId = PlacesUtils.bookmarksMenuFolderId;
+ bmMenuItem.folderGuid = PlacesUtils.bookmarks.menuGuid;
let toolbarItem = this._element("toolbarFolderItem");
toolbarItem.label = PlacesUtils.getString("BookmarksToolbarFolderTitle");
- toolbarItem.folderId = PlacesUtils.toolbarFolderId;
+ toolbarItem.folderGuid = PlacesUtils.bookmarks.toolbarGuid;
this._staticFoldersListBuilt = true;
}
// List of recently used folders:
- var folderIds =
- PlacesUtils.annotations.getItemsWithAnnotation(LAST_USED_ANNO);
+ let lastUsedFolderGuids =
+ await PlacesUtils.metadata.get(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY, []);
/**
- * 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.
+ * The list of last used folders is sorted in most-recent first order.
*
* First we build the annotated folders array, each item has both the
* folder identifier and the time at which it was last-used by this dialog
* set. Then we sort it descendingly based on the time field.
*/
this._recentFolders = [];
- for (let folderId of folderIds) {
- var lastUsed =
- PlacesUtils.annotations.getItemAnnotation(folderId, LAST_USED_ANNO);
- let guid = await PlacesUtils.promiseItemGuid(folderId);
+ for (let guid of lastUsedFolderGuids) {
let bm = await PlacesUtils.bookmarks.fetch(guid);
- // Since this could be a root mobile folder, we should get the proper
- // title.
- let title = PlacesUtils.bookmarks.getLocalizedTitle(bm);
- this._recentFolders.push({ folderId, guid, title, lastUsed });
+ if (bm) {
+ let title = PlacesUtils.bookmarks.getLocalizedTitle(bm);
+ this._recentFolders.push({ guid, title });
+ }
}
- this._recentFolders.sort(function(a, b) {
- if (b.lastUsed < a.lastUsed)
- return -1;
- if (b.lastUsed > a.lastUsed)
- return 1;
- return 0;
- });
var numberOfItems = Math.min(MAX_FOLDER_ITEM_IN_MENU_LIST,
this._recentFolders.length);
for (let i = 0; i < numberOfItems; i++) {
await this._appendFolderItemToMenupopup(menupopup,
- this._recentFolders[i].folderId,
+ this._recentFolders[i].guid,
this._recentFolders[i].title);
}
- let selectedFolderGuid = await PlacesUtils.promiseItemGuid(aSelectedFolder);
- let title = (await PlacesUtils.bookmarks.fetch(selectedFolderGuid)).title;
- var defaultItem = this._getFolderMenuItem(aSelectedFolder, title);
+ let title = (await PlacesUtils.bookmarks.fetch(aSelectedFolderGuid)).title;
+ var defaultItem = this._getFolderMenuItem(aSelectedFolderGuid, title);
this._folderMenuList.selectedItem = defaultItem;
// Set a selectedIndex attribute to show special icons
this._folderMenuList.setAttribute("selectedIndex",
this._folderMenuList.selectedIndex);
// Hide the folders-separator if no folder is annotated as recently-used
this._element("foldersSeparator").hidden = (menupopup.childNodes.length <= 6);
@@ -473,16 +457,20 @@ var gEditItemOverlay = {
PlacesUtils.bookmarks.removeObserver(this);
this._observersAdded = false;
}
this._setPaneInfo(null);
this._firstEditedField = "";
},
+ get selectedFolderGuid() {
+ return this._folderMenuList.selectedItem && this._folderMenuList.selectedItem.folderGuid;
+ },
+
onTagsFieldChange() {
// Check for _paneInfo existing as the dialog may be closing but receiving
// async updates from unresolved promises.
if (this._paneInfo &&
(this._paneInfo.isURI || this._paneInfo.bulkTagging)) {
this._updateTags().then(
anyChanges => {
// Check _paneInfo here as we might be closing the dialog.
@@ -690,145 +678,102 @@ var gEditItemOverlay = {
}
},
/**
* 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
+ * @param aFolderGuid
* The identifier of the bookmarks folder.
* @param aTitle
* The title to use in case of menuitem creation.
* @return handle to the menuitem.
*/
- _getFolderMenuItem(aFolderId, aTitle) {
+ _getFolderMenuItem(aFolderGuid, aTitle) {
let menupopup = this._folderMenuList.menupopup;
let menuItem = Array.prototype.find.call(
- menupopup.childNodes, item => item.folderId === aFolderId);
+ menupopup.childNodes, item => item.folderGuid === aFolderGuid);
if (menuItem !== undefined)
return menuItem;
// 3 special folders + separator + folder-items-count limit
if (menupopup.childNodes.length == 4 + MAX_FOLDER_ITEM_IN_MENU_LIST)
menupopup.removeChild(menupopup.lastChild);
- return this._appendFolderItemToMenupopup(menupopup, aFolderId, aTitle);
+ return this._appendFolderItemToMenupopup(menupopup, aFolderGuid, aTitle);
},
async onFolderMenuListCommand(aEvent) {
// Check for _paneInfo existing as the dialog may be closing but receiving
// async updates from unresolved promises.
if (!this._paneInfo) {
return;
}
// Set a selectedIndex attribute to show special icons
this._folderMenuList.setAttribute("selectedIndex",
this._folderMenuList.selectedIndex);
if (aEvent.target.id == "editBMPanel_chooseFolderMenuItem") {
// reset the selection back to where it was and expand the tree
// (this menu-item is hidden when the tree is already visible
- let item = this._getFolderMenuItem(this._paneInfo.parentId,
+ let item = this._getFolderMenuItem(this._paneInfo.parentGuid,
this._paneInfo.title);
this._folderMenuList.selectedItem = item;
// XXXmano HACK: setTimeout 100, otherwise focus goes back to the
// menulist right away
setTimeout(() => this.toggleFolderTreeVisibility(), 100);
return;
}
// Move the item
- let containerId = this._folderMenuList.selectedItem.folderId;
- if (this._paneInfo.parentId != containerId &&
- this._paneInfo.itemId != containerId) {
- let newParentGuid = await PlacesUtils.promiseItemGuid(containerId);
- let guid = this._paneInfo.itemGuid;
- await PlacesTransactions.Move({ guid, newParentGuid }).transact();
-
- // Mark the containing folder as recently-used if it isn't in the
- // static list
- if (containerId != PlacesUtils.unfiledBookmarksFolderId &&
- containerId != PlacesUtils.toolbarFolderId &&
- containerId != PlacesUtils.bookmarksMenuFolderId) {
- this._markFolderAsRecentlyUsed(containerId)
- .catch(Cu.reportError);
- }
+ let containerGuid = this._folderMenuList.selectedItem.folderGuid;
+ if (this._paneInfo.parentGuid != containerGuid &&
+ this._paneInfo.itemGuid != containerGuid) {
+ await PlacesTransactions.Move({
+ guid: this._paneInfo.itemGuid,
+ newParentGuid: containerGuid
+ }).transact();
// Auto-show the bookmarks toolbar when adding / moving an item there.
- if (containerId == PlacesUtils.toolbarFolderId) {
+ if (containerGuid == PlacesUtils.bookmarks.toolbarGuid) {
Services.obs.notifyObservers(null, "autoshow-bookmarks-toolbar");
}
}
// Update folder-tree selection
var folderTreeRow = this._element("folderTreeRow");
if (!folderTreeRow.collapsed) {
var selectedNode = this._folderTree.selectedNode;
if (!selectedNode ||
- PlacesUtils.getConcreteItemId(selectedNode) != containerId)
- this._folderTree.selectItems([containerId]);
+ PlacesUtils.getConcreteItemGuid(selectedNode) != containerGuid)
+ this._folderTree.selectItems([containerGuid]);
}
},
onFolderTreeSelect() {
var selectedNode = this._folderTree.selectedNode;
// Disable the "New Folder" button if we cannot create a new folder
this._element("newFolderButton")
.disabled = !this._folderTree.insertionPoint || !selectedNode;
if (!selectedNode)
return;
- var folderId = PlacesUtils.getConcreteItemId(selectedNode);
- if (this._folderMenuList.selectedItem.folderId == folderId)
+ var folderGuid = PlacesUtils.getConcreteItemGuid(selectedNode);
+ if (this._folderMenuList.selectedItem.folderGuid == folderGuid)
return;
- var folderItem = this._getFolderMenuItem(folderId, selectedNode.title);
+ var folderItem = this._getFolderMenuItem(folderGuid, selectedNode.title);
this._folderMenuList.selectedItem = folderItem;
folderItem.doCommand();
},
- async _markFolderAsRecentlyUsed(aFolderId) {
- // Expire old unused recent folders.
- let guids = [];
- while (this._recentFolders.length > MAX_FOLDER_ITEM_IN_MENU_LIST) {
- let folderId = this._recentFolders.pop().folderId;
- let guid = await PlacesUtils.promiseItemGuid(folderId);
- guids.push(guid);
- }
- if (guids.length > 0) {
- let annotation = this._getLastUsedAnnotationObject(false);
- PlacesTransactions.Annotate({ guids, annotation })
- .transact().catch(Cu.reportError);
- }
-
- // Mark folder as recently used
- let annotation = this._getLastUsedAnnotationObject(true);
- let guid = await PlacesUtils.promiseItemGuid(aFolderId);
- PlacesTransactions.Annotate({ guid, annotation })
- .transact().catch(Cu.reportError);
- },
-
- /**
- * Returns an object which could then be used to set/unset the
- * LAST_USED_ANNO annotation for a folder.
- *
- * @param aLastUsed
- * Whether to set or unset the LAST_USED_ANNO annotation.
- * @returns an object representing the annotation which could then be used
- * with the transaction manager.
- */
- _getLastUsedAnnotationObject(aLastUsed) {
- return { name: LAST_USED_ANNO,
- value: aLastUsed ? new Date().getTime() : null };
- },
-
_rebuildTagsSelectorList() {
let tagsSelector = this._element("tagsSelector");
let tagsSelectorRow = this._element("tagsSelectorRow");
if (tagsSelectorRow.collapsed)
return;
// Save the current scroll position and restore it after the rebuild.
let firstIndex = tagsSelector.getIndexOfFirstVisibleRow();
@@ -1069,28 +1014,27 @@ var gEditItemOverlay = {
},
onItemMoved(id, oldParentId, oldIndex, newParentId, newIndex, type, guid,
oldParentGuid, newParentGuid) {
if (!this._paneInfo.isItem || this._paneInfo.itemId != id) {
return;
}
- this._paneInfo.parentId = newParentId;
this._paneInfo.parentGuid = newParentGuid;
if (!this._paneInfo.visibleRows.has("folderRow") ||
- newParentId == this._folderMenuList.selectedItem.folderId) {
+ newParentGuid == this._folderMenuList.selectedItem.folderGuid) {
return;
}
// Just setting selectItem _does not_ trigger oncommand, so we don't
// recurse.
PlacesUtils.bookmarks.fetch(newParentGuid).then(bm => {
- this._folderMenuList.selectedItem = this._getFolderMenuItem(newParentId,
+ this._folderMenuList.selectedItem = this._getFolderMenuItem(newParentGuid,
bm.title);
});
},
onItemAdded(aItemId, aParentId, aIndex, aItemType, aURI) {
this._lastNewItem = aItemId;
},
--- a/browser/components/places/tests/browser/browser.ini
+++ b/browser/components/places/tests/browser/browser.ini
@@ -30,16 +30,17 @@ support-files =
[browser_bookmarkProperties_addKeywordForThisSearch.js]
[browser_bookmarkProperties_addLivemark.js]
[browser_bookmarkProperties_bookmarkAllTabs.js]
[browser_bookmarkProperties_cancel.js]
[browser_bookmarkProperties_editFolder.js]
[browser_bookmarkProperties_editTagContainer.js]
[browser_bookmarkProperties_no_user_actions.js]
[browser_bookmarkProperties_readOnlyRoot.js]
+[browser_bookmarkProperties_remember_folders.js]
[browser_bookmarksProperties.js]
[browser_check_correct_controllers.js]
[browser_click_bookmarks_on_toolbar.js]
[browser_controller_onDrop_sidebar.js]
[browser_controller_onDrop_tagFolder.js]
[browser_controller_onDrop.js]
[browser_copy_query_without_tree.js]
subsuite = clipboard
new file mode 100644
--- /dev/null
+++ b/browser/components/places/tests/browser/browser_bookmarkProperties_remember_folders.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+/**
+ * Tests that multiple tags can be added to a bookmark using the star-shaped button, the library and the sidebar.
+ */
+
+const bookmarkPanel = document.getElementById("editBookmarkPanel");
+let folders;
+
+async function clickBookmarkStar() {
+ let shownPromise = promisePopupShown(bookmarkPanel);
+ BookmarkingUI.star.click();
+ await shownPromise;
+}
+
+async function hideBookmarksPanel() {
+ let hiddenPromise = promisePopupHidden(bookmarkPanel);
+ // Confirm and close the dialog.
+ document.getElementById("editBookmarkPanelDoneButton").click();
+ await hiddenPromise;
+}
+
+async function openPopupAndSelectFolder(guid) {
+ await clickBookmarkStar();
+
+ // Expand the folder tree.
+ document.getElementById("editBMPanel_foldersExpander").click();
+ document.getElementById("editBMPanel_folderTree").selectItems([guid]);
+
+ await hideBookmarksPanel();
+ // Ensure the meta data has had chance to be written to disk.
+ await PlacesTestUtils.promiseAsyncUpdates();
+}
+
+async function assertRecentFolders(expectedGuids, msg) {
+ await clickBookmarkStar();
+
+ let actualGuids = [];
+ function getGuids() {
+ const folderMenuPopup = document.getElementById("editBMPanel_folderMenuList").children[0];
+
+ let separatorFound = false;
+ // The list of folders goes from editBMPanel_foldersSeparator to the end.
+ for (let child of folderMenuPopup.children) {
+ if (separatorFound) {
+ actualGuids.push(child.folderGuid);
+ } else if (child.id == "editBMPanel_foldersSeparator") {
+ separatorFound = true;
+ }
+ }
+ }
+
+ await TestUtils.waitForCondition(() => {
+ getGuids();
+ return actualGuids.length == expectedGuids.length;
+ }, msg);
+
+ Assert.deepEqual(actualGuids, expectedGuids, msg);
+
+ await hideBookmarksPanel();
+}
+
+add_task(async function setup() {
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY);
+
+ bookmarkPanel.setAttribute("animate", false);
+
+ let oldTimeout = StarUI._autoCloseTimeout;
+ // Make the timeout something big, so it doesn't iteract badly with tests.
+ StarUI._autoCloseTimeout = 6000000;
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:robots",
+ waitForStateStop: true
+ });
+
+ folders = await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.unfiledGuid,
+ children: [{
+ title: "Bob",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ }, {
+ title: "Place",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ }, {
+ title: "Delight",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ }, {
+ title: "Surprise",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ }, {
+ title: "Treble Bob",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ }, {
+ title: "Principal",
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ }]
+ });
+
+ registerCleanupFunction(async () => {
+ StarUI._autoCloseTimeout = oldTimeout;
+ BrowserTestUtils.removeTab(tab);
+ bookmarkPanel.removeAttribute("animate");
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.metadata.delete(PlacesUIUtils.LAST_USED_FOLDERS_META_KEY);
+ });
+});
+
+add_task(async function test_remember_last_folder() {
+ await assertRecentFolders([], "Should have no recent folders to start with.");
+
+ await openPopupAndSelectFolder(folders[0].guid);
+
+ await assertRecentFolders([folders[0].guid], "Should have one folder in the list.");
+});
+
+add_task(async function test_forget_oldest_folder() {
+ // Add some more folders.
+ let expectedFolders = [folders[0].guid];
+ for (let i = 1; i < folders.length; i++) {
+ await assertRecentFolders(expectedFolders,
+ "Should have only the expected folders in the list");
+
+ await openPopupAndSelectFolder(folders[i].guid);
+
+ expectedFolders.unshift(folders[i].guid);
+ if (expectedFolders.length > 5) {
+ expectedFolders.pop();
+ }
+ }
+
+ await assertRecentFolders(expectedFolders,
+ "Should have expired the original folder");
+});
+
+add_task(async function test_reorder_folders() {
+ let expectedFolders = [
+ folders[2].guid,
+ folders[5].guid,
+ folders[4].guid,
+ folders[3].guid,
+ folders[1].guid,
+ ];
+
+ // Take an old one and put it at the front.
+ await openPopupAndSelectFolder(folders[2].guid);
+
+ await assertRecentFolders(expectedFolders,
+ "Should have correctly re-ordered the list");
+});
--- a/toolkit/components/places/Database.cpp
+++ b/toolkit/components/places/Database.cpp
@@ -1,16 +1,17 @@
/* 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/. */
#include "mozilla/ArrayUtils.h"
#include "mozilla/Attributes.h"
#include "mozilla/DebugOnly.h"
#include "mozilla/ScopeExit.h"
+#include "mozilla/JSONWriter.h"
#include "Database.h"
#include "nsIAnnotationService.h"
#include "nsIInterfaceRequestorUtils.h"
#include "nsIFile.h"
#include "nsIWritablePropertyBag2.h"
@@ -106,16 +107,21 @@
// Livemarks annotations.
#define LMANNO_FEEDURI "livemark/feedURI"
#define LMANNO_SITEURI "livemark/siteURI"
// This is no longer used & obsolete except for during migration.
// Note: it may still be found in older places databases.
#define MOBILE_ROOT_ANNO "mobile/bookmarksRoot"
+// This annotation is no longer used & is obsolete, but here for migration.
+#define LAST_USED_ANNO NS_LITERAL_CSTRING("bookmarkPropertiesDialog/folderLastUsed")
+// This is key in the meta table that the LAST_USED_ANNO is migrated to.
+#define LAST_USED_FOLDERS_META_KEY NS_LITERAL_CSTRING("places/bookmarks/edit/lastusedfolder")
+
// We use a fixed title for the mobile root to avoid marking the database as
// corrupt if we can't look up the localized title in the string bundle. Sync
// sets the title to the localized version when it creates the left pane query.
#define MOBILE_ROOT_TITLE "mobile"
using namespace mozilla;
namespace mozilla {
@@ -1295,17 +1301,22 @@ Database::InitSchema(bool* aDatabaseMigr
NS_ENSURE_SUCCESS(rv, rv);
}
if (currentSchemaVersion < 50) {
rv = MigrateV50Up();
NS_ENSURE_SUCCESS(rv, rv);
}
- // Firefox 62 uses schema version 50.
+ if (currentSchemaVersion < 51) {
+ rv = MigrateV51Up();
+ NS_ENSURE_SUCCESS(rv, rv);
+ }
+
+ // Firefox 62 uses schema version 51.
// Schema Upgrades must add migration code here.
// >>> IMPORTANT! <<<
// NEVER MIX UP SYNC AND ASYNC EXECUTION IN MIGRATORS, YOU MAY LOCK THE
// CONNECTION AND CAUSE FURTHER STEPS TO FAIL.
// In case, set a bool and do the async work in the ScopeExit guard just
// before the migration steps.
}
@@ -2554,16 +2565,100 @@ Database::MigrateV50Up() {
rv = syncStmt->Execute();
if (NS_FAILED(rv)) return rv;
}
return NS_OK;
}
+struct StringWriteFunc : public JSONWriteFunc
+{
+ nsCString& mCString;
+ explicit StringWriteFunc(nsCString& aCString) : mCString(aCString)
+ {
+ }
+ void Write(const char* aStr) override { mCString.Append(aStr); }
+};
+
+nsresult
+Database::MigrateV51Up()
+{
+ nsCOMPtr<mozIStorageStatement> stmt;
+ nsresult rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "SELECT b.guid FROM moz_anno_attributes n "
+ "JOIN moz_items_annos a ON n.id = a.anno_attribute_id "
+ "JOIN moz_bookmarks b ON a.item_id = b.id "
+ "WHERE n.name = :anno_name ORDER BY a.content DESC"
+ ), getter_AddRefs(stmt));
+ if (NS_FAILED(rv)) {
+ MOZ_ASSERT(false, "Should succeed unless item annotations table has been removed");
+ return NS_OK;
+ };
+
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"),
+ LAST_USED_ANNO);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ nsAutoCString json;
+ JSONWriter jw{ MakeUnique<StringWriteFunc>(json) };
+ jw.StartArrayProperty(nullptr, JSONWriter::SingleLineStyle);
+
+ bool hasAtLeastOne = false;
+ bool hasMore = false;
+ uint32_t length;
+ while (NS_SUCCEEDED(stmt->ExecuteStep(&hasMore)) && hasMore) {
+ hasAtLeastOne = true;
+ jw.StringElement(stmt->AsSharedUTF8String(0, &length));
+ }
+ jw.EndArray();
+
+ // If we don't have any, just abort early and save the extra work.
+ if (!hasAtLeastOne) {
+ return NS_OK;
+ }
+
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "INSERT OR REPLACE INTO moz_meta "
+ "VALUES (:key, :value) "
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("key"),
+ LAST_USED_FOLDERS_META_KEY);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("value"), json);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ // Clean up the now redundant annotations.
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_items_annos WHERE anno_attribute_id = "
+ "(SELECT id FROM moz_anno_attributes WHERE name = :anno_name) "
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), LAST_USED_ANNO);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ rv = mMainConn->CreateStatement(NS_LITERAL_CSTRING(
+ "DELETE FROM moz_anno_attributes WHERE name = :anno_name "
+ ), getter_AddRefs(stmt));
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->BindUTF8StringByName(NS_LITERAL_CSTRING("anno_name"), LAST_USED_ANNO);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = stmt->Execute();
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+
nsresult
Database::ConvertOldStyleQuery(nsCString& aURL)
{
AutoTArray<QueryKeyValuePair, 8> tokens;
nsresult rv = TokenizeQueryString(aURL, &tokens);
NS_ENSURE_SUCCESS(rv, rv);
AutoTArray<QueryKeyValuePair, 8> newTokens;
--- a/toolkit/components/places/Database.h
+++ b/toolkit/components/places/Database.h
@@ -14,17 +14,17 @@
#include "mozilla/storage/StatementCache.h"
#include "mozilla/Attributes.h"
#include "nsIEventTarget.h"
#include "Shutdown.h"
#include "nsCategoryCache.h"
// This is the schema version. Update it at any schema change and add a
// corresponding migrateVxx method below.
-#define DATABASE_SCHEMA_VERSION 50
+#define DATABASE_SCHEMA_VERSION 51
// Fired after Places inited.
#define TOPIC_PLACES_INIT_COMPLETE "places-init-complete"
// This topic is received when the profile is about to be lost. Places does
// initial shutdown work and notifies TOPIC_PLACES_SHUTDOWN to all listeners.
// Any shutdown work that requires the Places APIs should happen here.
#define TOPIC_PROFILE_CHANGE_TEARDOWN "profile-change-teardown"
// Fired when Places is shutting down. Any code should stop accessing Places
@@ -332,16 +332,17 @@ protected:
nsresult MigrateV43Up();
nsresult MigrateV44Up();
nsresult MigrateV45Up();
nsresult MigrateV46Up();
nsresult MigrateV47Up();
nsresult MigrateV48Up();
nsresult MigrateV49Up();
nsresult MigrateV50Up();
+ nsresult MigrateV51Up();
void MigrateV48Frecencies();
nsresult UpdateBookmarkRootTitles();
friend class ConnectionShutdownBlocker;
int64_t CreateMobileRoot();
--- a/toolkit/components/places/tests/head_common.js
+++ b/toolkit/components/places/tests/head_common.js
@@ -1,16 +1,13 @@
/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-const CURRENT_SCHEMA_VERSION = 50;
-const FIRST_UPGRADABLE_SCHEMA_VERSION = 30;
-
const NS_APP_USER_PROFILE_50_DIR = "ProfD";
// Shortcuts to transitions type.
const TRANSITION_LINK = Ci.nsINavHistoryService.TRANSITION_LINK;
const TRANSITION_TYPED = Ci.nsINavHistoryService.TRANSITION_TYPED;
const TRANSITION_BOOKMARK = Ci.nsINavHistoryService.TRANSITION_BOOKMARK;
const TRANSITION_EMBED = Ci.nsINavHistoryService.TRANSITION_EMBED;
const TRANSITION_FRAMED_LINK = Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK;
--- a/toolkit/components/places/tests/migration/head_migration.js
+++ b/toolkit/components/places/tests/migration/head_migration.js
@@ -9,8 +9,40 @@ ChromeUtils.import("resource://gre/modul
{
/* import-globals-from ../head_common.js */
let commonFile = do_get_file("../head_common.js", false);
let uri = Services.io.newFileURI(commonFile);
Services.scriptloader.loadSubScript(uri.spec, this);
}
// Put any other stuff relative to this test folder below.
+
+const CURRENT_SCHEMA_VERSION = 51;
+const FIRST_UPGRADABLE_SCHEMA_VERSION = 30;
+
+async function assertAnnotationsRemoved(db, expectedAnnos) {
+ for (let anno of expectedAnnos) {
+ let rows = await db.execute(`
+ SELECT id FROM moz_anno_attributes
+ WHERE name = :anno
+ `, {anno});
+
+ Assert.equal(rows.length, 0, `${anno} should not exist in the database`);
+ }
+}
+
+async function assertNoOrphanAnnotations(db) {
+ let rows = await db.execute(`
+ SELECT item_id FROM moz_items_annos
+ WHERE item_id NOT IN (SELECT id from moz_bookmarks)
+ `);
+
+ Assert.equal(rows.length, 0,
+ `Should have no orphan annotations.`);
+
+ rows = await db.execute(`
+ SELECT id FROM moz_anno_attributes
+ WHERE id NOT IN (SELECT id from moz_items_annos)
+ `);
+
+ Assert.equal(rows.length, 0,
+ `Should have no orphan annotation attributes.`);
+}
--- a/toolkit/components/places/tests/migration/test_current_from_v43.js
+++ b/toolkit/components/places/tests/migration/test_current_from_v43.js
@@ -111,24 +111,17 @@ add_task(async function test_tombstones_
Assert.equal(rows.length, EXPECTED_REMOVED_BOOKMARK_GUIDS.length,
"Should have removed all the expected bookmarks.");
});
add_task(async function test_annotations_removed() {
let db = await PlacesUtils.promiseDBConnection();
- for (let anno of EXPECTED_REMOVED_ANNOTATIONS) {
- let rows = await db.execute(`
- SELECT id FROM moz_anno_attributes
- WHERE name = :anno
- `, {anno});
-
- Assert.equal(rows.length, 0, `${anno} should not exist in the database`);
- }
+ await assertAnnotationsRemoved(db, EXPECTED_REMOVED_ANNOTATIONS);
});
add_task(async function test_check_history_entries() {
let db = await PlacesUtils.promiseDBConnection();
for (let entry of EXPECTED_REMOVED_PLACES_ENTRIES) {
let rows = await db.execute(`
SELECT id FROM moz_places
@@ -163,31 +156,17 @@ add_task(async function test_check_keywo
Assert.equal(rows.length, 0,
`Should have removed the expected keyword: ${keyword}.`);
}
});
add_task(async function test_no_orphan_annotations() {
let db = await PlacesUtils.promiseDBConnection();
- let rows = await db.execute(`
- SELECT item_id FROM moz_items_annos
- WHERE item_id NOT IN (SELECT id from moz_bookmarks)
- `);
-
- Assert.equal(rows.length, 0,
- `Should have no orphan annotations.`);
-
- rows = await db.execute(`
- SELECT id FROM moz_anno_attributes
- WHERE id NOT IN (SELECT id from moz_items_annos)
- `);
-
- Assert.equal(rows.length, 0,
- `Should have no orphan annotation attributes.`);
+ await assertNoOrphanAnnotations(db);
});
add_task(async function test_no_orphan_keywords() {
let db = await PlacesUtils.promiseDBConnection();
let rows = await db.execute(`
SELECT place_id FROM moz_keywords
WHERE place_id NOT IN (SELECT id from moz_places)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/places/tests/migration/test_current_from_v50.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BASE_GUID = "null".padEnd(11, "_");
+const LAST_USED_ANNO = "bookmarkPropertiesDialog/folderLastUsed";
+const LAST_USED_META_DATA = "places/bookmarks/edit/lastusedfolder";
+
+let expectedGuids = [];
+
+add_task(async function setup() {
+ await setupPlacesDatabase("places_v43.sqlite");
+
+ // Setup database contents to be migrated.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, DB_FILENAME);
+ let db = await Sqlite.openConnection({ path });
+ // We can reuse the same guid, it doesn't matter for this test.
+ await db.execute(`INSERT INTO moz_anno_attributes (name)
+ VALUES (:last_used_anno)`, { last_used_anno: LAST_USED_ANNO });
+
+ for (let i = 0; i < 3; i++) {
+ let guid = `${BASE_GUID}${i}`;
+ await db.execute(`INSERT INTO moz_bookmarks (guid, type)
+ VALUES (:guid, :type)
+ `, { guid, type: PlacesUtils.bookmarks.TYPE_FOLDER });
+ await db.execute(`INSERT INTO moz_items_annos (item_id, anno_attribute_id, content)
+ VALUES ((SELECT id FROM moz_bookmarks WHERE guid = :guid),
+ (SELECT id FROM moz_anno_attributes WHERE name = :last_used_anno),
+ :content)`, {
+ guid,
+ content: new Date(1517318477569) - (3 - i) * 60 * 60 * 1000,
+ last_used_anno: LAST_USED_ANNO,
+ });
+ expectedGuids.unshift(guid);
+ }
+ await db.close();
+});
+
+add_task(async function database_is_valid() {
+ // Accessing the database for the first time triggers migration.
+ Assert.equal(PlacesUtils.history.databaseStatus,
+ PlacesUtils.history.DATABASE_STATUS_UPGRADED);
+
+ let db = await PlacesUtils.promiseDBConnection();
+ Assert.equal((await db.getSchemaVersion()), CURRENT_SCHEMA_VERSION);
+});
+
+add_task(async function test_folders_migrated() {
+ let metaData = await PlacesUtils.metadata.get(LAST_USED_META_DATA);
+
+ Assert.deepEqual(JSON.parse(metaData), expectedGuids);
+});
+
+add_task(async function test_annotations_removed() {
+ let db = await PlacesUtils.promiseDBConnection();
+
+ await assertAnnotationsRemoved(db, [LAST_USED_ANNO]);
+});
+
+add_task(async function test_no_orphan_annotations() {
+ let db = await PlacesUtils.promiseDBConnection();
+
+ await assertNoOrphanAnnotations(db);
+});
--- a/toolkit/components/places/tests/migration/xpcshell.ini
+++ b/toolkit/components/places/tests/migration/xpcshell.ini
@@ -22,8 +22,9 @@ support-files =
[test_current_from_v38.js]
[test_current_from_v41.js]
[test_current_from_v42.js]
[test_current_from_v43.js]
[test_current_from_v45.js]
[test_current_from_v46.js]
[test_current_from_v47.js]
[test_current_from_v48.js]
+[test_current_from_v50.js]