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.
This commit is contained in:
Dan Stillman 2026-09-01 21:59:20 -04:00 • committed by GitHub
parent 4a8a88dd41
commit a0ce7b3256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 228 additions and 1 deletions

View file

@ -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)) {

View file

@ -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) "

View file

@ -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);
};

View file

@ -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);
});
});
})

View file

@ -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');