From a0ce7b325621bdec43683b4107330018ae4201c2 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Tue, 1 Sep 2026 21:59:20 -0400 Subject: [PATCH] Show recently used collections in the Add to Collection menu (#6036) Collections are now tracked as they're selected, added to, or dropped on, and the five most recent usable targets are listed above the full collection hierarchy by full path. --- chrome/content/zotero/collectionTree.jsx | 4 ++ .../content/zotero/xpcom/data/collections.js | 61 ++++++++++++++++++ chrome/content/zotero/zoteroPane.js | 44 ++++++++++++- test/tests/collectionsTest.js | 58 +++++++++++++++++ test/tests/zoteroPaneTest.js | 62 +++++++++++++++++++ 5 files changed, 228 insertions(+), 1 deletion(-) diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index eabe1f7e7c..8bb8471baf 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -2314,6 +2314,10 @@ var CollectionTree = class CollectionTree extends LibraryTree { var targetLibraryID = targetTreeRow.ref.libraryID; var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false; + if (targetCollectionID) { + Zotero.Collections.addToRecent(targetTreeRow.ref); + } + if (dataType == 'zotero/collection') { let droppedCollections = await Zotero.Collections.getAsync(data); if (droppedCollections.some(c => c.id == targetCollectionID)) { diff --git a/chrome/content/zotero/xpcom/data/collections.js b/chrome/content/zotero/xpcom/data/collections.js index 0a4cecd197..d1af865b47 100644 --- a/chrome/content/zotero/xpcom/data/collections.js +++ b/chrome/content/zotero/xpcom/data/collections.js @@ -160,6 +160,67 @@ Zotero.Collections = function () { } + /** + * The number of collections tracked in the recently used list for each library + */ + this.MAX_RECENT = 20; + + /** + * The number of collections tracked in the recently used list across all libraries + */ + this.MAX_RECENT_TOTAL = 100; + + + // Collection ids, most recent first, for the current session only + var _recent = []; + + + /** + * Get the collections the user has most recently viewed or added items to, most recent first + * + * @param {Integer} [libraryID] - Limit to collections in a given library + * @return {Zotero.Collection[]} + */ + this.getRecent = function (libraryID) { + var collections = []; + for (let id of _recent) { + // A collection can be deleted or trashed after it's added to the list + let collection = this.get(id); + if (!collection || collection.deleted) { + continue; + } + if (libraryID === undefined || collection.libraryID == libraryID) { + collections.push(collection); + } + } + return collections; + }; + + + /** + * Move a collection to the front of the recently used list + * + * @param {Zotero.Collection} collection + */ + this.addToRecent = function (collection) { + _recent = _recent.filter(id => id != collection.id); + _recent.unshift(collection.id); + // Drop collections that have been deleted, along with everything from a library that + // has been removed, and trim each library separately, so that heavy use of one doesn't + // push another's collections off the end of the list + var counts = new Map(); + _recent = _recent.filter((id) => { + let collection = this.get(id); + if (!collection) { + return false; + } + let count = (counts.get(collection.libraryID) || 0) + 1; + counts.set(collection.libraryID, count); + return count <= this.MAX_RECENT; + }).slice(0, this.MAX_RECENT_TOTAL); + }; + + this._loadChildCollections = async function (libraryID, ids, idSQL) { var sql = "SELECT C1.collectionID, C2.collectionID AS childCollectionID " + "FROM collections C1 LEFT JOIN collections C2 ON (C1.collectionID=C2.parentCollectionID) " diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index aba6e151b7..2c6a16e44c 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -62,6 +62,8 @@ var ZoteroPane = new function () { || ev.getModifierState("Control") || ev.getModifierState("OS"); const TAB_NUMBER_CODE_RE = /^(?:Numpad|Digit)([0-9])$/; + + const RECENT_COLLECTIONS_SHOWN = 5; var self = this, _loaded = false, _madeVisible = false, @@ -1893,6 +1895,11 @@ var ZoteroPane = new function () { await this.itemsView.changeCollectionTreeRows(collectionTreeRows); Zotero.Prefs.set('lastViewedFolder', collectionTreeRows[0].id); + // The focused row is the one just clicked, whereas collectionTreeRows is in tree order + let focusedRow = this.collectionsView.selectedTreeRow; + if (focusedRow && focusedRow.isCollection()) { + Zotero.Collections.addToRecent(focusedRow.ref); + } }); @@ -4849,6 +4856,19 @@ var ZoteroPane = new function () { } }; + + function _getCollectionPathLabel(collection) { + var names = [collection.name]; + var parentID = collection.parentID; + while (parentID) { + let parent = Zotero.Collections.get(parentID); + names.unshift(parent.name); + parentID = parent.parentID; + } + return names.join(' \u203A '); + } + + this.buildAddItemToCollectionMenu = function (event, items = this.getSelectedItems()) { if (event.target !== event.currentTarget) return; let popup = event.target; @@ -4873,6 +4893,27 @@ var ZoteroPane = new function () { throw new Error('All items must be the same library'); } + let containsItems = collection => items.every(item => collection.hasItem(item)); + + // Recently used collections above the full list, skipping any that already contain + // all the items so that every slot is a usable target + let recent = Zotero.Collections.getRecent(libraryID) + .filter(collection => !containsItems(collection)) + .slice(0, RECENT_COLLECTIONS_SHOWN); + if (recent.length) { + let menuitems = recent.map((collection) => { + let menuitem = document.createXULElement('menuitem'); + // Full path, since collections in different parts of the tree can share a name + menuitem.setAttribute('label', _getCollectionPathLabel(collection)); + menuitem.setAttribute('image', collection.treeViewImage); + menuitem.classList.add('menuitem-iconic'); + menuitem.addEventListener('command', + () => this.addItemsToCollection(items, collection)); + return menuitem; + }); + popup.append(...menuitems, document.createXULElement('menuseparator')); + } + let collections = Zotero.Collections.getByLibrary(libraryID); for (let col of collections) { let menuItem = Zotero.Utilities.Internal.createMenuForTarget( @@ -4885,7 +4926,7 @@ var ZoteroPane = new function () { event.stopPropagation(); } }, - collection => items.every(item => collection.hasItem(item)) + containsItems ); popup.append(menuItem); } @@ -4918,6 +4959,7 @@ var ZoteroPane = new function () { ); await collection.addItems(ids); }); + Zotero.Collections.addToRecent(collection); }; diff --git a/test/tests/collectionsTest.js b/test/tests/collectionsTest.js index c93d8493ed..ac74ffa0c8 100644 --- a/test/tests/collectionsTest.js +++ b/test/tests/collectionsTest.js @@ -101,4 +101,62 @@ describe("Zotero.Collections", function () { assert.notInstanceOf(collection, Zotero.Feed); }); }); + + describe("#getRecent()", function () { + it("should list collections most recently added first", async function () { + var col1 = await createDataObject('collection'); + var col2 = await createDataObject('collection'); + + Zotero.Collections.addToRecent(col1); + Zotero.Collections.addToRecent(col2); + assert.sameOrderedMembers(Zotero.Collections.getRecent().slice(0, 2), [col2, col1]); + + // Adding a collection again moves it to the front without duplicating it + Zotero.Collections.addToRecent(col1); + assert.sameOrderedMembers(Zotero.Collections.getRecent().slice(0, 2), [col1, col2]); + }); + + it("should drop the oldest collection past the maximum for each library", async function () { + var group = await createGroup(); + var groupCollection = await createDataObject('collection', { libraryID: group.libraryID }); + Zotero.Collections.addToRecent(groupCollection); + + var collections = []; + for (let i = 0; i <= Zotero.Collections.MAX_RECENT; i++) { + let collection = await createDataObject('collection'); + collections.push(collection); + Zotero.Collections.addToRecent(collection); + } + + var recent = Zotero.Collections.getRecent(Zotero.Libraries.userLibraryID); + assert.lengthOf(recent, Zotero.Collections.MAX_RECENT); + assert.notInclude(recent, collections[0]); + assert.include(recent, collections[1]); + // Another library's collections aren't pushed out along with them + assert.include(Zotero.Collections.getRecent(group.libraryID), groupCollection); + }); + + it("should skip trashed collections and collections in other libraries", async function () { + var group = await createGroup(); + var groupCollection = await createDataObject('collection', { libraryID: group.libraryID }); + var trashed = await createDataObject('collection'); + var collection = await createDataObject('collection'); + + Zotero.Collections.addToRecent(groupCollection); + Zotero.Collections.addToRecent(trashed); + Zotero.Collections.addToRecent(collection); + + trashed.deleted = true; + await trashed.saveTx(); + + var recent = Zotero.Collections.getRecent(); + assert.include(recent, collection); + assert.include(recent, groupCollection); + assert.notInclude(recent, trashed); + + var userRecent = Zotero.Collections.getRecent(Zotero.Libraries.userLibraryID); + assert.include(userRecent, collection); + assert.notInclude(userRecent, groupCollection); + }); + }); }) diff --git a/test/tests/zoteroPaneTest.js b/test/tests/zoteroPaneTest.js index 193a342350..1b5a379f05 100644 --- a/test/tests/zoteroPaneTest.js +++ b/test/tests/zoteroPaneTest.js @@ -1136,6 +1136,68 @@ describe("ZoteroPane", function () { }); }); + describe("#buildAddItemToCollectionMenu()", function () { + var popup; + + before(function () { + popup = doc.getElementById('zotero-add-to-collection-popup'); + }); + + beforeEach(async function () { + // Leave the tree on the library root, so that a collection created later in a test + // can't be recorded by the reselection that follows adding a row + await selectLibrary(win); + }); + + after(async function () { + // Don't leave a multiple selection behind for later tests + await selectLibrary(win); + }); + + it("should record the selected collection as recently used", async function () { + var collection = await createDataObject('collection'); + await zp.collectionsView.selectByID("C" + collection.id); + await waitForItemsLoad(win); + + assert.equal(Zotero.Collections.getRecent()[0], collection); + }); + + it("should record the collection added to a multiple selection", async function () { + var c1 = await createDataObject('collection', { name: 'AAA recent' }); + var c2 = await createDataObject('collection', { name: 'ZZZ recent' }); + var cv = zp.collectionsView; + await cv.selectByID("C" + c1.id); + await waitForItemsLoad(win); + cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id)); + await zp.onCollectionSelected(); + await zp.itemsView.waitForLoad(); + + assert.equal(Zotero.Collections.getRecent()[0], c2); + }); + + it("should offer recently used collections above the full list", async function () { + var parent = await createDataObject('collection', { name: 'Parent' }); + var child = await createDataObject('collection', { name: 'Child', parentID: parent.id }); + var item = await createDataObject('item', { collections: [parent.id] }); + + Zotero.Collections.addToRecent(parent); + Zotero.Collections.addToRecent(child); + + zp.buildAddItemToCollectionMenu({ target: popup, currentTarget: popup }, [item]); + + // Recent collections follow New Collection and a separator, by full path + var nodes = [...popup.children]; + var recent = []; + for (let node of nodes.slice(nodes.findIndex(n => n.tagName == 'menuseparator') + 1)) { + if (node.tagName == 'menuseparator') break; + recent.push(node.getAttribute('label')); + } + assert.include(recent, 'Parent \u203A Child'); + // The parent already contains the item, so it isn't offered + assert.notInclude(recent, 'Parent'); + }); + }); + describe("#buildItemContextMenu()", function () { it("shouldn't show export or bib options for multiple standalone file attachments without notes", async function () { var item1 = await importFileAttachment('test.png');