Support multiple-collection selection

Allow selecting multiple collections, saved searches, or library roots in the
collection tree -- within a library or across libraries -- and show the union
of their items. The selection is threaded through the pane as an array
(getCollectionTreeRows(), changeCollectionTreeRows(), etc.); the item pane, tag
selector, reports, and export operate on all selected rows.

Adding items (new items and notes, drag-and-drop, the attachment dialog,
import, Add by Identifier) targets every selected collection. Only rows that can
share an items view may be combined: collections, saved searches, and library
roots mix freely, and multiple Recently Read rows can be combined across
libraries, but other special views (Trash, Duplicates, etc.) and rows from
different visibility groups can't be shown together, so a selection mixing them
keeps only the focused row. In-window advanced search runs across all selected
collections.

Advanced search value autocomplete is now scoped to the searched library, fixing
a long-standing TODO where suggestions were drawn from all libraries regardless
of the search scope; for a cross-library selection it spans the selected
libraries.

For a cross-library selection, the tag selector shows the union of tags
(colored tags only when a single library is in scope, since colors are
per-library), and deleting a tag spans all selected libraries while
rename/color/split are disabled. If advanced search is open, the
collection and saved-search conditions are omitted, since each is scoped
to a single library.
This commit is contained in:
Dan Stillman 2026-06-13 19:20:44 -04:00
parent 4d38970ef0
commit d7770a9d11
36 changed files with 1893 additions and 597 deletions

View file

@ -50,6 +50,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
dragAndDrop: false,
filterLibraryIDs: false,
hideSources: [],
multiSelect: false,
onContextMenu: noop,
};
@ -59,13 +60,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
dragAndDrop: PropTypes.bool,
filterLibraryIDs: PropTypes.array,
hideSources: PropTypes.array,
multiSelect: PropTypes.bool,
onContextMenu: PropTypes.func,
};
constructor(props) {
super(props);
this.itemTreeView = null;
this.itemToSelect = null;
this.type = 'collection';
this.name = "CollectionTree";
@ -140,6 +141,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
componentDidMount() {
super.componentDidMount();
this.selection.select(0);
this.makeVisible();
if (this.props.dragAndDrop) {
@ -306,6 +309,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
// Classes
div.className = "row";
div.classList.toggle('selected', selection.isSelected(index));
div.classList.toggle('first-selected', selection.isFirstRowOfSelectionBlock(index));
div.classList.toggle('last-selected', selection.isLastRowOfSelectionBlock(index));
div.classList.toggle('highlighted', this._highlightedRows.has(treeRow.id));
div.classList.toggle('drop', this._dropRow == index);
div.classList.toggle('flashing', this._flashingRow == index);
@ -476,6 +481,8 @@ var CollectionTree = class CollectionTree extends LibraryTree {
onToggleOpenState: this.toggleOpenState,
getRowString: this.getRowString.bind(this),
multiSelect: this.props.multiSelect,
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
onKeyDown: this.handleKeyDown,
@ -1305,17 +1312,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
* @returns {Promise<void>}
*/
async deleteSelection(deleteItems) {
var treeRow = this.getRow(this.selection.focused);
if (treeRow.isFeed()) {
await treeRow.ref.eraseTx();
return;
let treeRows = this.getSelectedRows();
// Feeds must be erased outside a transaction since Feed.erase()
// starts its own transaction internally
let feeds = treeRows.filter(r => r.isFeed());
let others = treeRows.filter(r => !r.isFeed());
for (let row of feeds) {
await row.ref.eraseTx();
}
treeRow.ref.deleted = true;
if (treeRow.isCollection()) {
await treeRow.ref.saveTx({ deleteItems });
return;
if (others.length) {
await Zotero.DB.executeTransaction(async () => {
for (let row of others) {
row.ref.deleted = true;
if (row.isCollection()) {
await row.ref.save({ deleteItems });
}
else {
await row.ref.save();
}
}
});
}
await treeRow.ref.saveTx();
}
unregister() {
@ -1371,20 +1388,35 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
getSelectedCollection(asID) {
var collection = this.getRow(this.selection.focused);
if (collection && collection.isCollection()) {
return asID ? collection.ref.id : collection.ref;
Zotero.debug("CollectionTree#getSelectedCollection() is deprecated -- use getSelectedCollections()");
return this.getSelectedCollections(asID)[0] || false;
}
getSelectedCollections(asID) {
var collections = [];
for (let index of this.selection.selected) {
let row = this.getRow(index);
if (row && row.isCollection()) {
collections.push(asID ? row.ref.id : row.ref);
}
}
return collections;
}
getSelectedSearch(asID) {
if (this.getRow(this.selection.focused)) {
var search = this.getRow(this.selection.focused);
if (search && search.isSearch()) {
return asID ? search.ref.id : search.ref;
Zotero.debug("CollectionTree#getSelectedSearch() is deprecated -- use getSelectedSearches()");
return this.getSelectedSearches(asID)[0] || false;
}
getSelectedSearches(asID) {
var searches = [];
for (let index of this.selection.selected) {
let row = this.getRow(index);
if (row && row.isSearch()) {
searches.push(asID ? row.ref.id : row.ref);
}
}
return false;
return searches;
}
getSelectedGroup(asID) {
@ -1448,18 +1480,27 @@ var CollectionTree = class CollectionTree extends LibraryTree {
////////////////////////////////////////////////////////////////////////////////
onDragStart(event, index) {
const treeRow = this.getRow(index);
super.onDragStart(event, index);
// See note in #setDropEffect()
if (Zotero.isWin || Zotero.isLinux) {
event.dataTransfer.effectAllowed = 'copyMove';
}
if (!treeRow.isCollection() && !treeRow.isSearch()) {
let rows = this.getSelectedRows();
// Ignore drag if all selected rows aren't the same type
if (!rows.every(o => o.type == rows[0].type)) {
return;
}
let type = treeRow.isCollection() ? "zotero/collection" : "zotero/search";
event.dataTransfer.setData(type, treeRow.ref.id);
Zotero.debug(`Dragging ${type} ` + treeRow.id);
// Ignore if not all collections or searches
if (!rows.every(o => o.isCollection() || o.isSearch())) {
return;
}
let type = rows[0].isCollection() ? "zotero/collection" : "zotero/search";
let ids = rows.map(o => o.ref.id);
event.dataTransfer.setDragImage(this._dragImageContainer, 0, 0);
event.dataTransfer.setData(type, ids);
Zotero.debug(`Dragging ${type} ` + (ids.length > 1 ? '[' + ids.join(', ') + ']' : ids[0]));
}
onDragOver(event, index) {
@ -1603,7 +1644,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
onDragEnd = () => {
onDragEnd = (event, index) => {
super.onDragEnd(event, index);
let dropRow = this._dropRow;
this._dropRow = null;
this.tree.invalidateRow(dropRow);
@ -1775,19 +1818,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false;
}
let draggedCollectionID = data[0];
let draggedCollection = Zotero.Collections.get(draggedCollectionID);
for (let id of data) {
let draggedCollection = Zotero.Collections.get(id);
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (draggedCollectionID == treeRow.ref.id) {
return false;
}
// Dragging within same library
if (treeRow.ref.libraryID == draggedCollection.libraryID) {
// Collections cannot be dropped on themselves
if (id == treeRow.ref.id) {
return false;
}
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
// Nor in their children
if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) {
return false;
}
}
}
@ -2214,21 +2258,31 @@ var CollectionTree = class CollectionTree extends LibraryTree {
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
if (dataType == 'zotero/collection') {
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
let droppedCollections = await Zotero.Collections.getAsync(data);
if (droppedCollections.some(c => c.id == targetCollectionID)) {
throw new Error("Can't drop onto source row");
}
// Collection drag between libraries
if (targetLibraryID != droppedCollection.libraryID) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
if (targetLibraryID != droppedCollections[0].libraryID) {
for (let droppedCollection of droppedCollections) {
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
}
// Collection drag within a library
else {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.saveTx();
await Zotero.DB.executeTransaction(async () => {
for (let droppedCollection of droppedCollections) {
droppedCollection.parentID = targetCollectionID;
await droppedCollection.save();
}
});
}
}
else if (dataType == 'zotero/item') {

View file

@ -90,7 +90,15 @@ const STUB_COLLECTION_TREE_ROW = {
class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
constructor(itemTree) {
super(itemTree);
this.collectionTreeRow = null;
this.collectionTreeRows = [];
}
/**
* The first selected collection tree row, for behaviors where the first row
* determines view-wide semantics (view type, trash mode, etc.)
*/
get collectionTreeRow() {
return this.collectionTreeRows[0] || null;
}
/**
@ -102,34 +110,44 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
}
/**
* Set a new collectionTreeRow and refresh items.
* Set new collectionTreeRows and refresh items.
* This handles the data/model logic; UI orchestration stays in ItemTree.
* @param {Object} collectionTreeRow - The collection tree row to set
* @param {Object[]} collectionTreeRows - The collection tree rows to set
* @returns {Promise<void>}
*/
async setCollectionTreeRow(collectionTreeRow) {
async setCollectionTreeRows(collectionTreeRows) {
// Normalize stub objects to include default CollectionTreeRow methods
if (collectionTreeRow.constructor.name == "Object") {
collectionTreeRow = Object.assign({}, STUB_COLLECTION_TREE_ROW, collectionTreeRow);
}
// No-op if same collection
if (this.collectionTreeRow && this.collectionTreeRow.id === collectionTreeRow.id) {
collectionTreeRows = collectionTreeRows.map((row) => {
if (row.constructor.name == "Object") {
row = Object.assign({}, STUB_COLLECTION_TREE_ROW, row);
}
return row;
});
// No-op if same set of collections
if (this.collectionTreeRows.length
&& Zotero.Utilities.arrayEquals(
this.collectionTreeRows.map(row => row.id).sort(),
collectionTreeRows.map(row => row.id).sort()
)) {
return;
}
if (!collectionTreeRows.slice(1).every(row => row.visibilityGroup == collectionTreeRows[0].visibilityGroup)) {
throw new Error("Selected collection tree rows belong to different visibility groups");
}
let resetColumns = false;
if (this.collectionTreeRow?.type != collectionTreeRow.type) {
if (this.collectionTreeRow?.type != collectionTreeRows[0].type) {
resetColumns = true;
}
this.collectionTreeRow = collectionTreeRow;
this.collectionTreeRows = collectionTreeRows;
// Set ID based on visibilityGroup
const visibilityGroup = collectionTreeRow.visibilityGroup || 'default';
const visibilityGroup = collectionTreeRows[0].visibilityGroup || 'default';
let treeID = "item-tree-" + this.itemTree.props.id + "-" + visibilityGroup;
// Needs to be called after this.collectionTreeRow is set so that this.itemTree.visibilityGroup is correct
// Needs to be called after this.collectionTreeRows is set so that this.itemTree.visibilityGroup is correct
let idChanged = await this.itemTree.setId(treeID);
this._includeTrashed = collectionTreeRow.isTrash();
// Emit loading state - only setCollectionTreeRow shows loading UI
this._includeTrashed = collectionTreeRows[0].isTrash();
// Emit loading state - only setCollectionTreeRows shows loading UI
await this.runListeners('update', null, { loading: true });
await this.itemTree._ensureSortContextReady();
resetColumns && await this.itemTree._resetColumns();
@ -144,22 +162,26 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
* @returns {Promise<void>}
*/
async setFilter(type, data) {
let changed;
switch (type) {
case 'search':
changed = this.collectionTreeRow.setSearch(data);
break;
case 'citation-search':
changed = this.collectionTreeRow.setSearch(data, 'fields');
break;
case 'advanced-search':
changed = this.collectionTreeRow.setAdvancedSearch(data);
break;
case 'tags':
changed = this.collectionTreeRow.setTags(data);
break;
default:
throw ('Invalid filter type in setFilter');
let changed = false;
for (let collectionTreeRow of this.collectionTreeRows) {
let rowChanged;
switch (type) {
case 'search':
rowChanged = collectionTreeRow.setSearch(data);
break;
case 'citation-search':
rowChanged = collectionTreeRow.setSearch(data, 'fields');
break;
case 'advanced-search':
rowChanged = collectionTreeRow.setAdvancedSearch(data);
break;
case 'tags':
rowChanged = collectionTreeRow.setTags(data);
break;
default:
throw ('Invalid filter type in setFilter');
}
changed = changed || rowChanged;
}
if (changed) {
await this.refresh({ restoreSelection: true });
@ -180,9 +202,15 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
this.itemTree._refreshPromise = deferred.promise;
try {
this.collectionTreeRow.clearCache();
// Get the full set of items we want to show
let newSearchItems = await this.collectionTreeRow.getItems();
this.collectionTreeRows.forEach(row => row.clearCache());
// Get the full set of items we want to show, merged across all selected rows
let newSearchItemSet = new Set();
for (let arr of await Promise.all(this.collectionTreeRows.map(row => row.getItems()))) {
for (let item of arr) {
newSearchItemSet.add(item);
}
}
let newSearchItems = [...newSearchItemSet];
if (this.collectionTreeRow.isTrash()) {
// When in trash, also fetch trashed collections and searched
// So that they are displayed among deleted items
@ -403,6 +431,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
const cachedSelection = this.itemTree._cachedSelection;
const collectionTreeRow = this.collectionTreeRow;
const collectionTreeRows = this.collectionTreeRows;
var madeChanges = false;
var refresh = false;
@ -428,15 +457,15 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
}
var visibleSubcollections = Zotero.Prefs.get('recursiveCollections')
? collectionTreeRow.ref.getDescendents(false, 'collection')
? collectionTreeRows.map(row => row.ref.getDescendents(false, 'collection')).flat()
: [];
var splitIDs = [];
for (let id of ids) {
var split = id.split('-');
// Include if an item in this collection or a visible subcollection
if (split[0] == collectionTreeRow.ref.id
|| visibleSubcollections.some(c => split[0] == c.id)) {
splitIDs.push(split[1]);
let [collectionID, itemID] = id.split('-');
// Include if an item in one of the selected collections or a visible subcollection
if (collectionTreeRows.some(row => row.ref.id == collectionID)
|| visibleSubcollections.some(c => collectionID == c.id)) {
splitIDs.push(itemID);
}
}
ids = splitIDs;
@ -498,7 +527,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
return;
}
if ((action == 'remove' && !collectionTreeRow.isLibrary(true))
if ((action == 'remove' && !collectionTreeRows.some(row => row.isLibrary(true)))
|| action == 'delete' || action == 'trash'
|| (action == 'removeDuplicatesMaster' && collectionTreeRow.isDuplicates())) {
// Since a remove involves shifting of rows, we have to do it in order,
@ -507,7 +536,8 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
let push = action == 'delete' || action == 'trash' || action == 'removeDuplicatesMaster';
for (var i = 0, len = ids.length; i < len; i++) {
if (!push) {
push = !collectionTreeRow.ref.hasItem(ids[i]);
let id = ids[i];
push = !collectionTreeRows.some(row => row.ref.hasItem(id));
}
// Row might already be gone (e.g. if this is a child and
// 'modify' was sent to parent)
@ -533,7 +563,8 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
madeChanges = true;
}
}
else if (collectionTreeRow.isSearchMode() && ['item', 'collection', 'search'].includes(type) && ['add', 'modify'].includes(action)) {
else if ((collectionTreeRows.length > 1 || collectionTreeRow.isSearchMode())
&& ['item', 'collection', 'search'].includes(type) && ['add', 'modify'].includes(action)) {
// If search mode, just re-run search
if (action == 'add' && this.hasQuickSearch()) {
// For item adds, clear the quick search, unless all the new items have
@ -551,7 +582,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
if (search) {
search.searchTextbox.value = '';
}
this.collectionTreeRow.setSearch('');
this.collectionTreeRows.forEach(row => row.setSearch(''));
}
}
this.itemTree.invalidateRowCache(ids);
@ -621,11 +652,14 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
// containing the item, the item has to be added
else if (item.isTopLevelItem()) {
// Root view
let add = collectionTreeRow.isLibrary(true)
&& collectionTreeRow.ref.libraryID == item.libraryID;
let add = collectionTreeRows.some(
row => row.isLibrary(true) && row.ref.libraryID == item.libraryID
);
// Collection containing item
if (!add && collectionTreeRow.isCollection()) {
add = item.inCollection(collectionTreeRow.ref.id);
if (!add) {
add = collectionTreeRows.some(
row => row.isCollection() && item.inCollection(row.ref.id)
);
}
if (add) {
// Most likely, the note or attachment's parent was removed.
@ -655,10 +689,9 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
else if (type == 'item' && action == 'add') {
if (!items) items = Zotero.Items.get(ids);
for (let item of items) {
// if the item belongs in this collection
if (((collectionTreeRow.isLibrary(true)
&& collectionTreeRow.ref.libraryID == item.libraryID)
|| (collectionTreeRow.isCollection() && item.inCollection(collectionTreeRow.ref.id)))
// if the item belongs in one of the selected views
if ((collectionTreeRows.some(row => row.isLibrary(true) && row.ref.libraryID == item.libraryID)
|| collectionTreeRows.some(row => row.isCollection() && item.inCollection(row.ref.id)))
// if we haven't already added it to our hash map
&& !this._rowMap[item.id]
// Regular item or standalone note/attachment
@ -721,7 +754,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
// If we refreshed, we have to clear the cache
if (!refresh) {
this.collectionTreeRow.clearCache();
this.collectionTreeRows.forEach(row => row.clearCache());
}
var singleSelect = false;
@ -853,6 +886,8 @@ class CollectionViewItemTree extends ItemTree {
get collectionTreeRow() { return this.rowProvider.collectionTreeRow; }
get collectionTreeRows() { return this.rowProvider.collectionTreeRows; }
get visibilityGroup() {
return this.collectionTreeRow?.visibilityGroup ?? 'default';
}
@ -878,18 +913,24 @@ class CollectionViewItemTree extends ItemTree {
}
async changeCollectionTreeRow(collectionTreeRow) {
return this.changeCollectionTreeRows(collectionTreeRow ? [collectionTreeRow] : []);
}
async changeCollectionTreeRows(collectionTreeRows) {
if (this._locked) return;
if (!collectionTreeRow) {
if (!collectionTreeRows || !collectionTreeRows.length) {
this.tree = null;
this._treebox = null;
return this.clearItemsPaneMessage();
}
Zotero.debug(`CollectionViewItemTree.changeCollectionTreeRow(): ${collectionTreeRow.id}`);
Zotero.debug(`CollectionViewItemTree.changeCollectionTreeRows(): [${collectionTreeRows.map(row => row.id).join(', ')}]`);
if (collectionTreeRow.view) {
collectionTreeRow.view.itemTreeView = this;
for (let collectionTreeRow of collectionTreeRows) {
if (collectionTreeRow.view) {
collectionTreeRow.view.itemTreeView = this;
}
}
await this.rowProvider.setCollectionTreeRow(collectionTreeRow);
await this.rowProvider.setCollectionTreeRows(collectionTreeRows);
return this.waitForLoad();
}
@ -1167,14 +1208,22 @@ class CollectionViewItemTree extends ItemTree {
var collectionTreeRow = this.collectionTreeRow;
var rowItem = null;
if (row != -1 && orient == 0) {
var rowItem = this.getRow(row).ref; // the item we are dragging over
rowItem = this.getRow(row).ref; // the item we are dragging over
// Cannot drop anything on attachments/notes
if (!rowItem.isRegularItem()) {
return false;
}
}
// When the selection spans multiple libraries, only allow drops directly onto a
// specific item, which identifies the target library. Drops into blank space or
// between rows are ambiguous across libraries, so disallow them.
if (!rowItem && new Set(this.collectionTreeRows.map(r => r.ref.libraryID)).size > 1) {
return false;
}
if (dataType == 'zotero/item') {
let items = Zotero.Items.get(data);
@ -1193,8 +1242,9 @@ class CollectionViewItemTree extends ItemTree {
return false;
}
// Disallow cross-library child drag
if (item.libraryID != collectionTreeRow.ref.libraryID) {
// Disallow cross-library child drag (the target item's library, which
// may differ from the focused row's in a cross-library selection)
if (item.libraryID != rowItem.libraryID) {
return false;
}
@ -1208,7 +1258,7 @@ class CollectionViewItemTree extends ItemTree {
}
// In library, allow children to be dragged out of parent
else if (collectionTreeRow.isLibrary(true) || collectionTreeRow.isCollection()) {
else if (this.collectionTreeRows.some(r => r.isLibrary(true) || r.isCollection())) {
let targetRow = row != -1 ? this.getRow(row) : null;
for (let item of items) {
// Don't allow drag if any top-level items
@ -1267,8 +1317,11 @@ class CollectionViewItemTree extends ItemTree {
return false;
}
}
// Don't allow drop into searches or publications
else if (collectionTreeRow.isSearch() || collectionTreeRow.isPublications()) {
// Into blank space, require a row that can receive items -- a collection or a
// library root. A saved search or My Publications can't, and checking all
// selected rows keeps this order-independent for a mixed collection + search
// selection.
else if (!this.collectionTreeRows.some(r => r.isCollection() || r.isLibrary(true))) {
return false;
}
@ -1343,8 +1396,27 @@ class CollectionViewItemTree extends ItemTree {
// Dropped outside of a row
else {
// Remove from parent and make top-level
if (collectionTreeRow.isLibrary(true)) {
let collectionRows = this.collectionTreeRows.filter(r => r.isCollection());
// Add to all selected collections
if (collectionRows.length) {
await Zotero.DB.executeTransaction(async function () {
for (let i = 0; i < items.length; i++) {
let item = items[i];
var source = item.isRegularItem() ? false : item.parentItemID;
// Child item -- make top-level
if (source) {
item.parentID = false;
}
for (let collectionRow of collectionRows) {
item.addToCollection(collectionRow.ref.id);
}
await item.save();
toMove.push(item.id);
}
});
}
// Only library roots selected -- remove from parent and make top-level
else if (collectionTreeRow.isLibrary(true)) {
await Zotero.DB.executeTransaction(async function () {
for (let i = 0; i < items.length; i++) {
let item = items[i];
@ -1355,31 +1427,13 @@ class CollectionViewItemTree extends ItemTree {
}
});
}
// Add to collection
else {
await Zotero.DB.executeTransaction(async function () {
for (let i = 0; i < items.length; i++) {
let item = items[i];
var source = item.isRegularItem() ? false : item.parentItemID;
// Top-level item
if (source) {
item.parentID = false;
item.addToCollection(collectionTreeRow.ref.id);
await item.save();
}
else {
item.addToCollection(collectionTreeRow.ref.id);
await item.save();
}
toMove.push(item.id);
}
});
}
}
}
else if (dataType == 'application/x-moz-file') {
// Disallow drop into read-only libraries
if (!collectionTreeRow.editable) {
// Disallow drop into read-only libraries. For a drop directly on an item,
// that's the item's library (which can differ in a cross-library selection).
let dropLibraryID = orient == 0 ? this.getRow(row).ref.libraryID : collectionTreeRow.ref.libraryID;
if (!Zotero.Libraries.get(dropLibraryID).editable) {
window.ZoteroPane.displayCannotEditLibraryMessage();
return;
}
@ -1402,14 +1456,23 @@ class CollectionViewItemTree extends ItemTree {
var targetLibraryID = collectionTreeRow.ref.libraryID;
var parentItemID = false;
var parentCollectionID = false;
var parentCollectionIDs = false;
if (orient == 0) {
// Dropped directly on an item: attach to it, in its library (which can
// differ from the focused row's in a cross-library selection)
let treerow = this.getRow(row);
parentItemID = treerow.ref.id;
targetLibraryID = treerow.ref.libraryID;
}
else if (collectionTreeRow.isCollection()) {
var parentCollectionID = collectionTreeRow.ref.id;
else {
// Add to all selected collections. Adding to a collection already implies
// the item is in that collection's library, so when a library root is also
// selected the collection(s) are the right (and sufficient) target.
let collectionRows = this.collectionTreeRows.filter(r => r.isCollection());
if (collectionRows.length) {
parentCollectionIDs = collectionRows.map(r => r.ref.id);
}
}
let addedItems = [];
@ -1470,7 +1533,7 @@ class CollectionViewItemTree extends ItemTree {
file,
title: delaySetAutoAttachmentTitle ? '' : undefined,
parentItemID,
collections: parentCollectionID ? [parentCollectionID] : undefined,
collections: parentCollectionIDs || undefined,
saveOptions: {
notifierQueue
}
@ -1488,7 +1551,7 @@ class CollectionViewItemTree extends ItemTree {
fileBaseName,
libraryID: targetLibraryID,
parentItemID,
collections: parentCollectionID ? [parentCollectionID] : undefined,
collections: parentCollectionIDs || undefined,
saveOptions: {
notifierQueue
}
@ -1621,6 +1684,12 @@ class CollectionViewItemTree extends ItemTree {
// Colored tag handling (ZoteroPane-specific)
if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey && COLORED_TAGS_RE.test(event.code)) {
// Colored-tag number shortcuts map positions to a single library's colored
// tags, which aren't shown in the tag selector across a multi-library
// selection, so disable them there
if (new Set(this.collectionTreeRows.map(r => r.ref?.libraryID)).size > 1) {
return true;
}
let libraryID = this.collectionTreeRow?.ref?.libraryID;
if (!libraryID) {
return true;
@ -1720,6 +1789,7 @@ class CollectionViewItemTree extends ItemTree {
let selectedItemIDs = selectedItems.map(o => o.id);
let collectionTreeRow = this.collectionTreeRow;
let collectionTreeRows = this.collectionTreeRows;
// If all selected items are annotations, for now erase them skipping trash
if (selectedItems.length && selectedItems.every(item => item.isAnnotation())) {
@ -1774,8 +1844,8 @@ class CollectionViewItemTree extends ItemTree {
}
});
}
else if (collectionTreeRow.isLibrary(true)
|| collectionTreeRow.isSearch()
else if (collectionTreeRows.some(row => row.isLibrary(true))
|| collectionTreeRows.some(row => row.isSearch())
|| collectionTreeRow.isUnfiled()
|| collectionTreeRow.isRecentlyRead()
|| collectionTreeRow.isRetracted()
@ -1783,10 +1853,12 @@ class CollectionViewItemTree extends ItemTree {
|| force) {
await Zotero.Items.trashTx(selectedItemIDs);
}
else if (collectionTreeRow.isCollection()) {
let collectionIDs = [collectionTreeRow.ref.id];
else if (collectionTreeRows.every(row => row.isCollection())) {
let collectionIDs = collectionTreeRows.map(row => row.ref.id);
if (Zotero.Prefs.get('recursiveCollections')) {
collectionIDs.push(...collectionTreeRow.ref.getDescendents(false, 'collection').map(c => c.id));
for (let row of collectionTreeRows) {
collectionIDs.push(...row.ref.getDescendents(false, 'collection').map(c => c.id));
}
}
await Zotero.DB.executeTransaction(async () => {

View file

@ -61,6 +61,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
this.searchBoxRef = React.createRef();
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
// Library IDs of the selected collection tree rows, in collections-list order.
// Usually one, but a cross-library selection scopes the tag selector to several.
this.libraryIDs = [];
// Not stored in state to avoid an unnecessary refresh. Instead, when a tag is selected, we
// trigger the selection handler, which updates the visible items, which triggers
// onItemViewChanged(), which triggers a refresh with the new tags.
@ -101,10 +104,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
componentDidUpdate(_prevProps, _prevState) {
Zotero.debug("Tag selector updated");
// If we changed collections, scroll to top
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewID = this.collectionTreeRow.id;
// If we changed the set of collections, scroll to top
if (this.collectionTreeRows) {
let treeViewIDs = this.collectionTreeRows.map(row => row.id).sort();
if (!Zotero.Utilities.arrayEquals(treeViewIDs, this.prevTreeViewIDs)) {
this.tagListRef.current.scrollToTop();
this.prevTreeViewIDs = treeViewIDs;
}
}
}
@ -115,7 +121,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
/**
* Safely fetch tags from the current collection tree row, returning [] on search error.
* Safely fetch tags from the selected collection tree rows, returning [] on search error.
* CollectionTreeRow.getTags() calls getSearchResults() under the hood, which throws
* Zotero.CollectionTreeRow.SearchError if the underlying search query fails (e.g., a
* saved search with invalid conditions). The tag selector should degrade gracefully in
@ -124,7 +130,12 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
*/
async _safeGetTags(...args) {
try {
return await this.collectionTreeRow.getTags(...args);
let tags = (await Promise.all(
this.collectionTreeRows.map(row => row.getTags(...args))
)).flat();
// Multiple rows (collections, or collections across libraries) can return
// the same tag, so dedupe by name
return this._dedupeTags(tags);
}
catch (e) {
if (e instanceof Zotero.CollectionTreeRow.SearchError) {
@ -135,24 +146,78 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
}
}
/**
* Whether more than one library is currently in scope (cross-library selection)
*/
get multiLibrary() {
return this.libraryIDs.length > 1;
}
_dedupeTags(tags) {
let seen = new Set();
let result = [];
for (let tag of tags) {
if (!seen.has(tag.tag)) {
seen.add(tag.tag);
result.push(tag);
}
}
return result;
}
/**
* Get all tags across the selected libraries (for "Display All Tags"), deduped by name
*
* @param {Number[]} [tagIDs] - Limit to the given tag IDs
*/
async _getAllTagsInLibraries(tagIDs) {
let results = await Promise.all(
this.libraryIDs.map(libraryID => Zotero.Tags.getAllWithin({ libraryID, tagIDs }))
);
return this._dedupeTags(results.flat());
}
_getSelectedLibraryIDs(collectionTreeRows, fallbackLibraryID) {
let ids = [];
for (let row of collectionTreeRows || []) {
let id = row.ref && row.ref.libraryID;
if (id !== undefined && id !== null && !ids.includes(id)) {
ids.push(id);
}
}
if (!ids.length && fallbackLibraryID) {
ids.push(fallbackLibraryID);
}
return ids;
}
/**
* Colored tags are a per-library synced setting, so a unified cross-library list
* can't coherently show them (the same color can mean different tags in different
* libraries). Only show colored tags when a single library is in scope. The items
* list still shows each item's own library's swatches via Item.getItemsListTags().
*/
_getScopeColors() {
if (this.libraryIDs.length !== 1) {
return new Map();
}
return Zotero.Tags.getColors(this.libraryIDs[0]);
}
// Update trigger #1 (triggered by ZoteroPane)
async onItemViewChanged({ collectionTreeRow, libraryID }) {
async onItemViewChanged({ collectionTreeRows, libraryID }) {
Zotero.debug('Updating tag selector from current view');
var prevLibraryID = this.libraryID;
this.collectionTreeRow = collectionTreeRow;
this.libraryID = libraryID;
var prevLibraryIDs = this.libraryIDs;
this.collectionTreeRows = collectionTreeRows;
this.libraryIDs = this._getSelectedLibraryIDs(collectionTreeRows, libraryID);
this.libraryID = this.libraryIDs[0];
var newState = {
loaded: true
};
if (prevLibraryID != libraryID) {
if (libraryID) {
newState.tagColors = Zotero.Tags.getColors(libraryID);
}
else {
newState.tagColors = new Map();
}
if (!Zotero.Utilities.arrayEquals(prevLibraryIDs, this.libraryIDs)) {
newState.tagColors = this._getScopeColors();
}
var { tags, scope } = await this.getTagsAndScope();
newState.tags = tags;
@ -166,14 +231,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
Zotero.debug("Updating tag selector after tag color change");
this.setState({
tagColors: Zotero.Tags.getColors(this.libraryID)
tagColors: this._getScopeColors()
});
}
return;
}
// Ignore anything other than deletes in duplicates view
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
if (this.collectionTreeRows?.[0]?.isDuplicates()) {
switch (event) {
case 'delete':
case 'trash':
@ -215,9 +280,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
for (let [type, tagIDs] of tagsByType) {
changedTagsInScope.push(...await this._safeGetTags([type], tagIDs));
if (this.displayAllTags) {
changedTagsInView.push(
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
);
changedTagsInView.push(...await this._getAllTagsInLibraries(tagIDs));
}
}
if (!this.displayAllTags) {
@ -340,7 +403,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
// The scope is all visible tags, not all tags in the library
var scope = new Set(tags.map(t => t.tag));
if (this.displayAllTags) {
tags = await Zotero.Tags.getAll(this.libraryID);
tags = await this._getAllTagsInLibraries();
}
// If tags haven't changed, return previous array without sorting again
@ -584,6 +647,14 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
for (let i = 0; i < tagContextMenu.childNodes.length; i++) {
tagContextMenu.childNodes[i].disabled = this.state.viewOnly;
}
// In a cross-library selection, disable per-library actions that would only
// affect one of the selected libraries. Deleting still works -- it removes the
// tag from all selected libraries (see openDeletePrompt())
if (this.multiLibrary) {
for (let id of ['assign-color-tag', 'rename-tag', 'split-tag']) {
document.getElementById(id).disabled = true;
}
}
ev.preventDefault();
tagContextMenu.openPopupAtScreen(
@ -804,12 +875,16 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
var tagID = Zotero.Tags.getID(this.contextTag.name);
if (tagID) {
await Zotero.Tags.removeFromLibrary(this.libraryID, tagID);
}
// If only a tag color setting, remove that
else {
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
// Delete from every selected library. removeFromLibrary() only clears the color
// for tags that have items in the library, so also clear any remaining color-only
// setting (e.g. a tag that's a real tag in one library but only colored in another)
for (let libraryID of this.libraryIDs) {
if (tagID) {
await Zotero.Tags.removeFromLibrary(libraryID, tagID);
}
if (Zotero.Tags.getColors(libraryID).has(this.contextTag.name)) {
await Zotero.Tags.setColor(libraryID, this.contextTag.name, false);
}
}
}

View file

@ -140,7 +140,11 @@
// editable library or group root (but not a feed) and not within a collection.
// Revisit when/if we support nested condition sets in the UI.
_canSaveInCurrentRow() {
let collectionTreeRow = ZoteroPane.getCollectionTreeRow();
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
if (collectionTreeRows.length != 1) {
return false;
}
let collectionTreeRow = collectionTreeRows[0];
return collectionTreeRow.isLibrary(true)
&& !collectionTreeRow.isFeed()
&& collectionTreeRow.editable;
@ -153,6 +157,23 @@
if (libraryID) {
this._search.libraryID = libraryID;
}
// Set the libraries the search applies to: a temporary search spans all
// selected libraries, a saved search just its own. Used to scope value
// autocomplete and, when more than one library is involved, to drop the
// Collection/Saved Search condition, which can only resolve within one library.
if (this.type === 'temporary') {
let libraryIDs = [];
for (let row of ZoteroPane.getCollectionTreeRows()) {
let id = row.ref && row.ref.libraryID;
if (id !== undefined && id !== null && !libraryIDs.includes(id)) {
libraryIDs.push(id);
}
}
this._searchElem.scopeLibraryIDs = libraryIDs;
}
else {
this._searchElem.scopeLibraryIDs = [this._search.libraryID];
}
this._searchElem.search = this._search;
this._saveButton.disabled = this.type === 'temporary' && !this._canSaveInCurrentRow();
}
@ -197,13 +218,13 @@
return;
}
let collectionTreeRow = ZoteroPane.getCollectionTreeRow();
let collectionTreeRows = ZoteroPane.getCollectionTreeRows();
if (!this._canSaveInCurrentRow()) {
throw new Error('Can only save in an editable library root');
}
this._ensureSearch();
let libraryID = collectionTreeRow.ref.libraryID;
let libraryID = collectionTreeRows[0].ref.libraryID;
let searches = await Zotero.Searches.getAll(libraryID);
let prefix = Zotero.getString('pane.collections.untitled');
let defaultName = Zotero.Utilities.Internal.getNextName(

View file

@ -129,7 +129,7 @@
}
get _renderDependencies() {
return [...super._renderDependencies, this.collectionTreeRow?.id];
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
}
init() {
@ -488,7 +488,7 @@
}
if (this.item instanceof Zotero.FeedItem) {
let row = ZoteroPane.getCollectionTreeRow();
let row = ZoteroPane.getCollectionTreeRows()[0];
if (row && row.isFeeds()) {
fieldNames.unshift("feed");
}

View file

@ -96,12 +96,12 @@
this._tabID = tabID;
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
}
get supportsPinning() {
@ -238,7 +238,7 @@
box.tabID = this.tabID;
box.tabType = this.tabType;
box.item = item;
box.collectionTreeRow = this.collectionTreeRow;
box.collectionTreeRows = this.collectionTreeRows;
// Discard hidden panes
if (box.hidden && box.discard) {
box.discard();

View file

@ -74,12 +74,12 @@
this._data = data;
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(val) {
this._collectionTreeRow = val;
set collectionTreeRows(val) {
this._collectionTreeRows = val;
}
get itemsView() {
@ -150,7 +150,7 @@
notify(action, type) {
if (type == 'item' && action == 'modify') {
if (this.collectionTreeRow && this.collectionTreeRow.isFeedsOrFeed()) {
if (this.collectionTreeRows?.[0]?.isFeedsOrFeed()) {
this.updateReadLabel();
}
}
@ -191,7 +191,7 @@
this._itemDetails.tabID = "zotero-pane";
this._itemDetails.tabType = "library";
this._itemDetails.item = item;
this._itemDetails.collectionTreeRow = this.collectionTreeRow;
this._itemDetails.collectionTreeRows = this.collectionTreeRows;
this._itemDetails.render();
@ -229,7 +229,7 @@
let count = this.data.length;
// Display duplicates merge interface in item pane
if (this.collectionTreeRow.isDuplicates()) {
if (this.collectionTreeRows[0].isDuplicates()) {
if (!this.editable) {
if (count) {
msg = Zotero.getString('pane.item.duplicates.writeAccessRequired');
@ -259,7 +259,7 @@
if (count) {
let key;
// In the trash, we have to check the object type
if (this.collectionTreeRow.isTrash()) {
if (this.collectionTreeRows[0].isTrash()) {
if (this.data.every(x => x instanceof Zotero.Collection)) {
key = 'item-pane-message-collections-selected';
}
@ -280,7 +280,7 @@
}
else {
let count = this.itemsView.rowCount;
if (this.collectionTreeRow.isTrash()
if (this.collectionTreeRows[0].isTrash()
&& this.itemsView._rows?.some(
x => x.ref instanceof Zotero.Collection || x.ref instanceof Zotero.Search
)) {
@ -315,7 +315,7 @@
}
// My Publications buttons
var isPublications = this.collectionTreeRow.isPublications();
var isPublications = this.collectionTreeRows[0].isPublications();
// Show in My Publications view if selected items are all notes or non-linked-file attachments
var showMyPublicationsButtons = isPublications
&& this.data.every((item) => {
@ -331,13 +331,13 @@
// Trash button
let nonDeletedItemsSelected = this.data.some(item => !item.deleted);
if (this.collectionTreeRow.isTrash() && !nonDeletedItemsSelected) {
if (this.collectionTreeRows[0].isTrash() && !nonDeletedItemsSelected) {
container.renderCustomHead(this.renderTrashHead.bind(this));
return;
}
// Feed buttons
if (this.collectionTreeRow.isFeedsOrFeed()) {
if (this.collectionTreeRows[0].isFeedsOrFeed()) {
container.renderCustomHead(this.renderFeedHead.bind(this));
this.updateReadLabel();
return;
@ -413,7 +413,7 @@
renderAnnotationsHead(data) {
let { doc, append } = data;
let button = doc.createXULElement("button");
button.disabled = !this.collectionTreeRow.editable;
button.disabled = !this.collectionTreeRows.every(o => o.editable);
button.id = 'zotero-item-pane-note-from-annotations';
if (Zotero.Items.getTopLevel(this.data).length == 1) {
button.label = Zotero.getString('pane.items.menu.addNoteFromAnnotations');

View file

@ -59,12 +59,12 @@ export class ItemPaneSectionElementBase extends XULElementBase {
this.setAttribute('tabType', tabType);
}
get collectionTreeRow() {
return this._collectionTreeRow;
get collectionTreeRows() {
return this._collectionTreeRows;
}
set collectionTreeRow(collectionTreeRow) {
this._collectionTreeRow = collectionTreeRow;
set collectionTreeRows(collectionTreeRows) {
this._collectionTreeRows = collectionTreeRows;
}
_syncRenderPending = false;

View file

@ -66,7 +66,7 @@ import { getCSSIcon } from 'components/icons';
}
get _renderDependencies() {
return [...super._renderDependencies, this.collectionTreeRow?.id];
return [...super._renderDependencies, this.collectionTreeRows?.map(o => o.id).join(',')];
}
init() {
@ -155,7 +155,7 @@ import { getCSSIcon } from 'components/icons';
}
let isCurrent = this.tabType === 'library'
&& this.collectionTreeRow?.id == obj.treeViewID;
&& this.collectionTreeRows.map(o => o.id).includes(obj.treeViewID);
box.classList.toggle('current', isCurrent);
// Disable clicky if this is a context row or we're already in the library/collection it points to

View file

@ -432,9 +432,9 @@
case 'collection':
{
let rows = [];
var libraryID = this.parent.search.libraryID;
// Add collections
let cols = Zotero.Collections.getByLibrary(libraryID, true);
for (let col of cols) {
@ -452,7 +452,7 @@
image: Zotero.Collection.prototype.treeViewImage
});
}
// Add saved searches
let searches = Zotero.Searches.getByLibrary(libraryID);
for (let search of searches) {
@ -513,7 +513,9 @@
}
// Update field drop-down if applicable
this.querySelector('#valuefield').update(conditionName, this.mode);
this.querySelector('#valuefield').update(
conditionName, this.mode, this.parent && this.parent.scopeLibraryIDs
);
}
}
}
@ -594,7 +596,17 @@
this.parent = parent;
this.conditionID = condition.id;
var menu = this.querySelector('#conditionsmenu');
// Collection and saved search conditions resolve within a single library, so
// remove the Collection condition (which also covers saved searches) when the
// selection spans multiple libraries
if (this.parent.scopeLibraryIDs && this.parent.scopeLibraryIDs.length > 1) {
let collectionItem = menu.querySelector('menuitem[value="collection"]');
if (collectionItem) {
collectionItem.remove();
}
}
if (this.parent.search) {
this.dontupdate = true; //so that the search doesn't get updated while we are creating controls.
var prefix = '';
@ -863,7 +875,7 @@
throw new Error('Invalid search textbox popup');
}
update(condition, mode) {
update(condition, mode, scopeLibraryIDs) {
var textbox = this.querySelector('#search-textbox');
var button = this.querySelector('#textbox-button');
@ -912,11 +924,15 @@
default:
textbox.setAttribute('disableautocomplete', 'false');
// TODO: Provide current libraryID
var autocompleteParams = {
fieldName: condition
};
// Scope suggestions to the selected libraries (the same set the
// collection condition menu uses). Empty/unset falls back to all libraries.
if (scopeLibraryIDs && scopeLibraryIDs.length) {
autocompleteParams.libraryIDs = scopeLibraryIDs;
}
switch (condition) {
case 'creator':
case 'author':

View file

@ -131,9 +131,10 @@ Zotero_File_Exporter.prototype.save = async function () {
return;
}
if(this.collection) {
if (this.collection) {
translation.setCollection(this.collection);
} else if(this.items) {
}
else if (this.items) {
translation.setItems(this.items);
} else if(this.libraryID === undefined) {
throw new Error('No export configured');
@ -201,21 +202,27 @@ var Zotero_File_Interface = new function () {
/*
* exports a collection or saved search
*/
function exportCollection() {
async function exportCollection() {
var exporter = new Zotero_File_Exporter();
var collection = ZoteroPane_Local.getSelectedCollection();
if(collection) {
exporter.name = collection.getName();
exporter.collection = collection;
} else {
var collections = ZoteroPane_Local.getSelectedCollections();
if (collections.length == 1) {
exporter.name = collections[0].getName();
exporter.collection = collections[0];
}
else if (collections.length > 1) {
exporter.name = collections.map(c => c.getName()).join(', ');
exporter.items = await ZoteroPane.getUnfilteredItems();
if (!exporter.items.length) throw ("No items to save");
}
else {
// find sorted items
exporter.items = ZoteroPane_Local.getSortedItems();
if(!exporter.items) throw ("No items to save");
if (!exporter.items) throw ("No items to save");
// find name
var search = ZoteroPane_Local.getSelectedSavedSearch();
if(search) {
if (search) {
exporter.name = search.name;
}
}
@ -581,7 +588,7 @@ var Zotero_File_Interface = new function () {
}
var libraryID = Zotero.Libraries.userLibraryID;
var importCollection = null;
var importCollections = [];
try {
let zp = Zotero.getActiveZoteroPane();
libraryID = zp.getSelectedLibraryID();
@ -589,7 +596,7 @@ var Zotero_File_Interface = new function () {
await zp.collectionsView.selectLibrary(libraryID);
}
else if (!createNewCollection) {
importCollection = zp.getSelectedCollection();
importCollections = zp.getSelectedCollections();
}
}
catch (e) {
@ -614,10 +621,11 @@ var Zotero_File_Interface = new function () {
else {
collectionName = defaultNewCollectionPrefix + " " + (new Date()).toLocaleString();
}
importCollection = new Zotero.Collection;
let importCollection = new Zotero.Collection;
importCollection.libraryID = libraryID;
importCollection.name = collectionName;
await importCollection.saveTx();
importCollections = [importCollection];
}
translation.setTranslator(translators[0]);
@ -650,7 +658,7 @@ var Zotero_File_Interface = new function () {
try {
await translation.translate({
libraryID,
collections: importCollection ? [importCollection.id] : null,
collections: importCollections.length ? importCollections.map(c => c.id) : null,
linkFiles,
saveOptions: {
notifierQueue
@ -726,13 +734,13 @@ var Zotero_File_Interface = new function () {
* Creates a bibliography from a collection or saved search
*/
this.bibliographyFromCollection = async function () {
var items = ZoteroPane.getSortedItems();
var items = await ZoteroPane.getUnfilteredItems();
// Find collection name
var name = false;
var collection = ZoteroPane.getSelectedCollection();
if (collection) {
name = collection.name;
var collections = ZoteroPane.getSelectedCollections();
if (collections.length) {
name = collections.map(c => c.name).join(', ');
}
else {
let search = ZoteroPane.getSelectedSavedSearch();

View file

@ -454,7 +454,7 @@ class Layout {
else {
// Make sure the collectionTreeRow is defined to
// avoid errors thrown when filter is set on first load
while (!this.itemsView.collectionTreeRow) {
while (!this.itemsView.collectionTreeRows) {
await Zotero.Promise.delay(10);
}
await this.refreshItemsList();
@ -784,7 +784,8 @@ class LibraryLayout extends Layout {
hideSources: ['duplicates', 'trash', 'feeds'],
initialFolder: Zotero.Prefs.get("integration.citationDialogCollectionLastSelected"),
onActivate: () => {},
filterLibraryIDs: io.filterLibraryIDs
filterLibraryIDs: io.filterLibraryIDs,
multiSelect: true
});
// Add aria-description with instructions on what this collection tree is for
// Voiceover announces the description placed on the actual tree when focus enters it
@ -877,10 +878,18 @@ class LibraryLayout extends Layout {
}
async _onCollectionSelection() {
var collectionTreeRow = this.collectionsView.getRow(this.collectionsView.selection.focused);
if (!this.collectionsView.selection.count) return;
// Collection not changed
if (this.itemsView && this.itemsView.collectionTreeRow && this.itemsView.collectionTreeRow.id == collectionTreeRow.id) {
// Show the union of all selected collections (cross-library selections are
// grouped by library in the items view, as in List mode)
let selectedRows = [...this.collectionsView.selection.selected]
.sort((a, b) => a - b)
.map(index => this.collectionsView.getRow(index));
// Collection selection not changed
if (this.itemsView
&& Zotero.Utilities.arrayEquals(
selectedRows.map(row => row.id).sort(),
this.itemsView.collectionTreeRows.map(row => row.id).sort()
)) {
return;
}
// _onCollectionSelection will be called during initiation. It can take a while
@ -888,41 +897,45 @@ class LibraryLayout extends Layout {
if (currentLayout?.type !== "library") return;
this.itemsView.setItemsPaneMessage(Zotero.getString('pane.items.loading'));
// Load library data if necessary
var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
// Load item data for each selected library if necessary
for (let libraryID of new Set(selectedRows.map(row => row.ref.libraryID))) {
let library = Zotero.Libraries.get(libraryID);
if (!library.getDataLoaded('item')) {
Zotero.debug("Waiting for items to load for library " + library.libraryID);
await library.waitForDataLoad('item');
}
}
await this.itemsView.changeCollectionTreeRow({
// Restrict each collection's items to those relevant to the current citation mode
let filterItemsForMode = async (items) => {
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
if (DIALOG_STATE.isAddingNote()) {
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
if (regularItems.length) {
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
}
// when citing notes, only keep notes or note parents
items = items.filter(item => item.isNote() || item.getNotes().length);
}
// when adding annotations, only keep annotations, their attachments, and their top-level items
if (DIALOG_STATE.isAddingAnnotations()) {
return SearchHandler.keepItemsWithAnnotations(items);
}
return items;
};
await this.itemsView.changeCollectionTreeRows(selectedRows.map(collectionTreeRow => ({
id: collectionTreeRow.id,
getItems: async () => {
let items = await collectionTreeRow.getItems();
// In add-note mode, note parent checks call item.getNotes(), which requires childItems
if (DIALOG_STATE.isAddingNote()) {
let regularItems = items.filter(item => SearchHandler.isItemWithNotes(item));
if (regularItems.length) {
await Zotero.Items.loadDataTypes(regularItems, ['childItems']);
}
// when citing notes, only keep notes or note parents
items = items.filter(item => item.isNote() || item.getNotes().length);
}
// when adding annotations, only keep annotations, their attachments, and their top-level items
if (DIALOG_STATE.isAddingAnnotations()) {
return SearchHandler.keepItemsWithAnnotations(items);
}
return items;
},
getItems: async () => filterItemsForMode(await collectionTreeRow.getItems()),
isSearch: () => true,
isSearchMode: () => true,
setSearch: (searchText, mode) => collectionTreeRow.setSearch(searchText, mode),
clearCache: () => collectionTreeRow.clearCache(),
ref: collectionTreeRow.ref
});
})));
await this.itemsView.setFilter('citation-search', SearchHandler.searchValue);
this.itemsView.clearItemsPaneMessage();
}

View file

@ -1497,12 +1497,14 @@ var ItemTree = class ItemTree extends LibraryTree {
let hasQuickSearch = !!this.collectionTreeRow.searchText;
let hasTagFilters = this.collectionTreeRow.tags?.size > 0;
if (hasQuickSearch || hasTagFilters) {
// Clear all searches set on the collection tree row directly on
// collectionTreeRow (vs using ZoteroPane functions) to avoid
// Clear all searches set on the collection tree rows directly on
// the rows (vs using ZoteroPane functions) to avoid
// refreshing the itemTree multiple times at the same time, which can lead
// to tag selector not showing all tags after quickSearch is cleared
this.collectionTreeRow.setTags(new Set());
this.collectionTreeRow.setSearch('');
for (let collectionTreeRow of this.collectionTreeRows) {
collectionTreeRow.setTags(new Set());
collectionTreeRow.setSearch('');
}
// Clear quickSearch text field and tag selection without
// rerunning search
if (window.ZoteroPane.tagSelector) {

View file

@ -56,6 +56,22 @@ var LibraryTree = class LibraryTree extends React.Component {
return this._waitForEvent('select');
}
componentDidMount() {
// Create an element where we can create drag images to be displayed next to the cursor while dragging
// since for multiple item drags we need to display all the elements
let elem = this._dragImageContainer = document.createElement("div");
elem.style.width = "100%";
elem.style.height = "2000px";
elem.style.position = "absolute";
elem.style.top = "-10000px";
elem.className = "drag-image-container";
this.domEl.appendChild(elem);
}
componentWillUnmount() {
this.domEl.removeChild(this._dragImageContainer);
}
componentDidCatch(error, info) {
// Async operations might attempt to update the react components
// after window close in tests, which will cause unnecessary crashing
@ -118,6 +134,28 @@ var LibraryTree = class LibraryTree extends React.Component {
return this._rowMap[id];
}
/**
* Get selected tree rows
*/
getSelectedRows() {
var indexes = this.selection ? Array.from(this.selection.selected) : [];
indexes = indexes.filter(index => index < this._rows.length);
try {
return indexes.map(index => this.getRow(index));
}
catch (e) {
Zotero.debug(indexes);
throw e;
}
}
/**
* Get selected objects, including collections and searches in the trash in item trees
*/
getSelectedObjects() {
return this.getSelectedRows().map(row => row.ref);
}
/**
* Add a tree row to the main array, update the row count, tell the treebox that the row
* count changed, and update the row map
@ -253,6 +291,27 @@ var LibraryTree = class LibraryTree extends React.Component {
}
event.dataTransfer.dropEffect = effect;
}
/**
* Start a drag using HTML 5 Drag and Drop
*/
onDragStart(event, index) {
// Propagate selection before we set the drag image if dragging not one of the selected rows
if (!this.selection.isSelected(index)) {
this.selection.select(index);
}
// Set drag image
const dragElems = this.domEl.querySelectorAll('.selected');
for (let elem of dragElems) {
elem = elem.cloneNode(true);
elem.style.position = "initial";
this._dragImageContainer.appendChild(elem);
}
}
onDragEnd(_event, _index) {
this._dragImageContainer.innerHTML = "";
}
};
Zotero.Utilities.Internal.makeClassEventDispatcher(LibraryTree);

View file

@ -73,8 +73,8 @@ var Zotero_Lookup = new function () {
else {
try {
libraryID = ZoteroPane.getSelectedLibraryID();
let collection = ZoteroPane.getSelectedCollection();
collections = collection ? [collection.id] : false;
let selectedCollections = ZoteroPane.getSelectedCollections();
collections = selectedCollections.length ? selectedCollections.map(c => c.id) : false;
}
catch (e) {
/** TODO: handle this **/

View file

@ -41,7 +41,7 @@ async function onLoad() {
}
let itemID = parseInt(io.itemID);
let collectionID = parseInt(io.collectionID);
let collectionIDs = io.collectionIDs.split(",").filter(x => x).map(x => parseInt(x));
let parentItemKey = io.parentItemKey;
let ref;
@ -65,8 +65,9 @@ async function onLoad() {
ref = Zotero.Items.getByLibraryAndKey(parentItemKey);
noteEditor.parentItem = ref;
}
else if (collectionID && collectionID != 'undefined') {
noteEditor.collection = Zotero.Collections.get(collectionID);
else if (collectionIDs.length) {
// DEBUG: Was 'collection', but seemingly unused?
noteEditor.collections = Zotero.Collections.get(collectionIDs);
}
noteEditor.refresh();
}

View file

@ -25,52 +25,69 @@
var Zotero_Report_Interface = new function() {
/*
* Load a report for the currently selected collection
/**
* Load a report for the selected collections/searches
*/
this.loadCollectionReport = function () {
var libraryID = ZoteroPane_Local.getSelectedLibraryID();
var collections = ZoteroPane_Local.getSelectedCollections();
var items = collections.length
? _getCollectionItems(collections)
: ZoteroPane_Local.getSortedItems();
if (!items.length) {
throw new Error('No items in selected collections');
}
var sortColumn = ZoteroPane_Local.getSortField();
var queryString = '?sort=' + sortColumn
+ '&direction=' + (ZoteroPane.getSortDirection() == 1 ? 'asc' : 'desc');
var url = 'zotero://report/';
var source = ZoteroPane_Local.getSelectedCollection();
if (!source) {
source = ZoteroPane_Local.getSelectedSavedSearch();
}
if (!source) {
throw new Error('No collection currently selected');
}
url += Zotero.API.getLibraryPrefix(source.libraryID) + '/';
if (source instanceof Zotero.Collection) {
url += 'collections/' + source.key;
}
else {
url += 'searches/' + source.key;
}
url += '/items' + queryString;
var url = 'zotero://report/'
+ Zotero.API.getLibraryPrefix(libraryID) + '/'
+ 'items?sort=' + sortColumn
+ '&direction=' + (ZoteroPane.getSortDirection() == 1 ? 'asc' : 'desc')
+ '&itemKey=' + items.map(item => item.key).join(',');
Zotero.openInViewer(url, { allowJavaScript: false });
}
/*
/**
* Load a report for the currently selected items
*/
this.loadItemReport = function () {
var libraryID = ZoteroPane_Local.getSelectedLibraryID();
var items = ZoteroPane_Local.getSelectedItems();
if (!items || !items.length) {
throw new Error('No items currently selected');
}
var url = 'zotero://report/' + Zotero.API.getLibraryPrefix(libraryID) + '/items'
+ '?itemKey=' + items.map(item => item.key).join(',');
var libraryID = items[0].libraryID;
var url = 'zotero://report/'
+ Zotero.API.getLibraryPrefix(libraryID) + '/'
+ 'items?itemKey=' + items.map(item => item.key).join(',');
Zotero.openInViewer(url, { allowJavaScript: false });
}
/**
* Get all items from the given collections, respecting recursiveCollections
*/
function _getCollectionItems(collections) {
var items = new Set();
var recursive = Zotero.Prefs.get('recursiveCollections');
for (let collection of collections) {
for (let item of collection.getChildItems()) {
items.add(item);
}
if (recursive) {
for (let desc of collection.getDescendents(false, 'collection')) {
let col = Zotero.Collections.get(desc.id);
if (col) {
for (let item of col.getChildItems()) {
items.add(item);
}
}
}
}
}
return [...items];
}
}

View file

@ -154,7 +154,7 @@ var onCollectionSelected = async function () {
await library.waitForDataLoad('item');
}
await itemsView.changeCollectionTreeRow(collectionTreeRow);
await itemsView.changeCollectionTreeRows([collectionTreeRow]);
itemsView.clearItemsPaneMessage();
};

View file

@ -324,7 +324,12 @@ Zotero.CollectionTreeRow.prototype.getTrashedCollections = async function () {
};
Zotero.CollectionTreeRow.prototype.getItems = async function () {
/**
* @param {Object} [options]
* @param {Boolean} [options.unfiltered=false] - If true, ignore quicksearch, tag, and
* advanced search filters
*/
Zotero.CollectionTreeRow.prototype.getItems = async function (options = {}) {
switch (this.type) {
// Fake results if this is a shared library
case 'share':
@ -334,7 +339,7 @@ Zotero.CollectionTreeRow.prototype.getItems = async function () {
return this.ref.getItems();
}
var ids = await this.getSearchResults();
var ids = await this.getSearchResults(false, { unfiltered: options.unfiltered });
// Filter out items that exist in the items table (where search results come from) but that haven't
// yet been registered. This helps prevent unloaded-data crashes when switching collections while
@ -353,7 +358,22 @@ Zotero.CollectionTreeRow.prototype.getItems = async function () {
return Zotero.Items.getAsync(ids);
};
Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTable) {
/**
* @param {Boolean} [asTempTable=false]
* @param {Object} [options]
* @param {Boolean} [options.unfiltered=false] - If true, ignore quicksearch, tag, and
* advanced search filters and bypass the cache
*/
Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTable, options = {}) {
if (options.unfiltered) {
let s = await this.getSearchObject({ unfiltered: true });
let ids = await s.search();
if (asTempTable) {
return Zotero.Search.idsToTempTable(ids);
}
return ids;
}
if (!this._cachedResults) {
let s = await this.getSearchObject();
try {
@ -378,9 +398,13 @@ Zotero.CollectionTreeRow.prototype.getSearchResults = async function (asTempTabl
* Returns the search object for the currently display
*
* This accounts for the collection, saved search, quicksearch, tags, etc.
*
* @param {Object} [options]
* @param {Boolean} [options.unfiltered=false] - If true, ignore quicksearch, tag, and
* advanced search filters and bypass the cache
*/
Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
if (this._cachedSearch) {
Zotero.CollectionTreeRow.prototype.getSearchObject = async function (options = {}) {
if (!options.unfiltered && this._cachedSearch) {
return this._cachedSearch;
}
@ -403,18 +427,20 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
}
else if (this.isDuplicates()) {
s = await this.ref.getSearchObject();
let tmpTable;
for (let id in s.conditions) {
let c = s.conditions[id];
if (c.condition == 'tempTable') {
tmpTable = c.value;
break;
if (!options.unfiltered) {
let tmpTable;
for (let id in s.conditions) {
let c = s.conditions[id];
if (c.condition == 'tempTable') {
tmpTable = c.value;
break;
}
}
// Called by ItemTreeView::unregister()
this.onUnload = async function () {
await Zotero.DB.queryAsync(`DROP TABLE IF EXISTS ${tmpTable}`, false, { noCache: true });
};
}
// Called by ItemTreeView::unregister()
this.onUnload = async function () {
await Zotero.DB.queryAsync(`DROP TABLE IF EXISTS ${tmpTable}`, false, { noCache: true });
};
}
else {
s = new Zotero.Search();
@ -464,21 +490,23 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
}
s2.setScope(s, includeScopeChildren);
// Add Quick Search unless advanced search is enabled
if (this.searchText && !this.advancedSearch) {
let cond = 'quicksearch-'
+ (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode'));
s2.addCondition(cond, 'contains', this.searchText);
}
if (!options.unfiltered) {
// Add Quick Search unless advanced search is enabled
if (this.searchText && !this.advancedSearch) {
let cond = 'quicksearch-'
+ (this.searchMode || Zotero.Prefs.get('search.quicksearch-mode'));
s2.addCondition(cond, 'contains', this.searchText);
}
if (this.tags){
for (let tag of this.tags) {
s2.addCondition('tag', 'is', tag);
if (this.tags) {
for (let tag of this.tags) {
s2.addCondition('tag', 'is', tag);
}
}
}
let s3;
if (this.advancedSearch) {
if (!options.unfiltered && this.advancedSearch) {
if (this.advancedSearch.libraryID === null) {
// A library-less search (Feeds pseudo-library) can't be clone()d
s3 = new Zotero.Search();
@ -506,7 +534,9 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = async function () {
s3 = s2;
}
this._cachedSearch = s3;
if (!options.unfiltered) {
this._cachedSearch = s3;
}
return s3;
};

View file

@ -43,7 +43,7 @@ Zotero.Notes = new function () {
* @returns {Promise<Zotero.EditorInstance | null>} Instance of Zotero.EditorInstance for the note.
* If the note tab is opened in background (unloaded), returns null.
*/
this.open = async function (itemID, location, { title, tabIndex, tabID, openInBackground, openInWindow, allowDuplicate, preventJumpback, parentItemKey } = {}) {
this.open = async function (itemID, location, { title, tabIndex, tabID, openInBackground, openInWindow, allowDuplicate, preventJumpback, parentItemKey, collections } = {}) {
let { libraryID } = Zotero.Items.getLibraryAndKeyFromID(itemID);
let library = Zotero.Libraries.get(libraryID);
let win = Zotero.getMainWindow();
@ -123,7 +123,8 @@ Zotero.Notes = new function () {
name = 'zotero-note-' + itemID;
}
let io = { itemID, parentItemKey, location, _initPromise: Zotero.Promise.defer() };
let collectionIDs = collections ? collections.map(c => c.id).join(',') : '';
let io = { itemID, parentItemKey, collectionIDs, location, _initPromise: Zotero.Promise.defer() };
Services.ww.openWindow(
win,
'chrome://zotero/content/note.xhtml',

View file

@ -35,8 +35,13 @@ Zotero.Server.Connector = {
if (zp && zp.collectionsView) {
if (allowReadOnly || zp.collectionsView.editable && allowFilesReadOnly || zp.collectionsView.filesEditable) {
// The Connector saves to a single target, so derive both the library and the
// collection from the focused row. A multiple-collection selection in the pane
// isn't expressible here yet, and getSelectedCollection() could otherwise return
// a collection from a different library than the focused row.
let treeRow = zp.collectionsView.selectedTreeRow;
library = Zotero.Libraries.get(zp.getSelectedLibraryID());
collection = zp.getSelectedCollection();
collection = treeRow && treeRow.isCollection() ? treeRow.ref : null;
editable = zp.collectionsView.editable;
}
// If not editable, switch to My Library if it exists and is editable

View file

@ -1086,11 +1086,11 @@ Zotero.Translate.ItemGetter.prototype = {
setCollection: function (collection, getChildCollections) {
// get items in this collection
var items = new Set(collection.getChildItems());
if (getChildCollections) {
// Get child collections
this._collectionsLeft = Zotero.Collections.getByParent(collection.id);
// Get items in all descendant collections
let descendantCollections = Zotero.Collections.getByParent(collection.id, true);
for (let collection of descendantCollections) {
@ -1098,7 +1098,7 @@ Zotero.Translate.ItemGetter.prototype = {
childItems.forEach(item => items.add(item));
}
}
this._itemsLeft = Array.from(items.values());
this._itemsLeft.sort((a, b) => a.id - b.id);
this.numItems = this._itemsLeft.length;

View file

@ -58,7 +58,15 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
throw new Error("Invalid JSON passed to autocomplete");
}
var [fieldName, , subField] = searchParams.fieldName.split("-");
// Library scope: accept a single libraryID (e.g. the item pane and tags box) or an
// array of libraryIDs (advanced search across a multi-library selection). An empty
// array means no library filter (suggest values from all libraries).
var libraryIDs = searchParams.libraryIDs
|| (searchParams.libraryID ? [searchParams.libraryID] : []);
// Bare '?' placeholder list, e.g. "?, ?, ?", for sites that push params sequentially
var libraryPlaceholders = libraryIDs.map(() => '?').join(', ');
var resultsCallback;
switch (fieldName) {
@ -68,10 +76,10 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
case 'tag':
var sql = "SELECT DISTINCT name AS val, NULL AS id FROM tags WHERE name LIKE ? ESCAPE '\\'";
var sqlParams = [Zotero.DB.escapeSQLExpression(searchString) + '%'];
if (searchParams.libraryID) {
if (libraryIDs.length) {
sql += " AND tagID IN (SELECT tagID FROM itemTags JOIN items USING (itemID) "
+ "WHERE libraryID=?)";
sqlParams.push(searchParams.libraryID);
+ `WHERE libraryID IN (${libraryPlaceholders}))`;
sqlParams.push(...libraryIDs);
}
if (searchParams.itemID) {
sql += " AND name NOT IN (SELECT name FROM tags WHERE tagID IN ("
@ -93,10 +101,10 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
var sql = "SELECT DISTINCT CASE fieldMode WHEN 1 THEN lastName "
+ "WHEN 0 THEN firstName || ' ' || lastName END AS val, NULL AS id "
+ "FROM creators ";
if (fieldName != 'creator' || searchParams.libraryID) {
if (fieldName != 'creator' || libraryIDs.length) {
sql += "JOIN itemCreators USING (creatorID) ";
}
if (searchParams.libraryID) {
if (libraryIDs.length) {
sql += "JOIN items USING (itemID) ";
}
sql += "WHERE CASE fieldMode "
@ -108,9 +116,9 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
sql += "AND creatorTypeID=? ";
sqlParams.push(Zotero.CreatorTypes.getID(fieldName));
}
if (searchParams.libraryID) {
sql += ` AND libraryID=? `;
sqlParams.push(searchParams.libraryID);
if (libraryIDs.length) {
sql += ` AND libraryID IN (${libraryPlaceholders}) `;
sqlParams.push(...libraryIDs);
}
sql += "ORDER BY val";
}
@ -137,7 +145,7 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
}
var fromSQL = " FROM creators "
if (searchParams.libraryID) {
if (libraryIDs.length) {
fromSQL += "JOIN itemCreators USING (creatorID) JOIN items USING (itemID) ";
}
fromSQL += "WHERE " + subField + " LIKE ?1 AND fieldMode=?2";
@ -155,11 +163,11 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
}
fromSQL += ")";
}
if (searchParams.libraryID) {
fromSQL += ` AND libraryID=?${sqlParams.length + 1}`;
sqlParams.push(searchParams.libraryID);
if (libraryIDs.length) {
fromSQL += ` AND libraryID IN (${libraryIDs.map((_, i) => '?' + (sqlParams.length + 1 + i)).join(', ')})`;
sqlParams.push(...libraryIDs);
}
sql += fromSQL;
// If double-field mode, include matches for just this field
@ -179,9 +187,9 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
var sql = "SELECT DISTINCT DATE(" + fieldName + ", 'localtime') AS val, NULL AS id "
+ "FROM items WHERE " + fieldName + " LIKE ? ";
var sqlParams = [searchString + '%'];
if (searchParams.libraryID) {
sql += "AND libraryID=? ";
sqlParams.push(searchParams.libraryID);
if (libraryIDs.length) {
sql += `AND libraryID IN (${libraryPlaceholders}) `;
sqlParams.push(...libraryIDs);
}
sql += "ORDER BY " + fieldName;
@ -191,14 +199,14 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
var fieldID = Zotero.ItemFields.getID('accessDate');
var sql = "SELECT DISTINCT DATE(value, 'localtime') AS val, NULL AS id FROM itemData ";
if (searchParams.libraryID) {
if (libraryIDs.length) {
sql += "JOIN items USING (itemID) ";
}
sql += "WHERE fieldID=? AND value LIKE ? ";
var sqlParams = [fieldID, searchString + '%'];
if (searchParams.libraryID) {
sql += "AND libraryID=? ";
sqlParams.push(searchParams.libraryID);
if (libraryIDs.length) {
sql += `AND libraryID IN (${libraryPlaceholders}) `;
sqlParams.push(...libraryIDs);
}
sql += "ORDER BY value";
@ -218,7 +226,7 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
var valueField = fieldName == 'date' ? 'SUBSTR(value, 12, 100)' : 'value';
var sql = "SELECT DISTINCT " + valueField + " AS val, NULL AS id FROM itemData ";
if (searchParams.libraryID) {
if (libraryIDs.length) {
sql += "JOIN items USING (itemID) ";
}
sql += "JOIN itemDataValues USING (valueID) "
@ -230,10 +238,10 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
+ "NATURAL JOIN itemDataValues WHERE fieldID=?1 AND itemID=?3) ";
sqlParams.push(searchParams.itemID);
}
// Limit to specific library
if (searchParams.libraryID) {
sql += `AND libraryID=?${sqlParams.length + 1} `;
sqlParams.push(searchParams.libraryID);
// Limit to specific libraries
if (libraryIDs.length) {
sql += `AND libraryID IN (${libraryIDs.map((_, i) => '?' + (sqlParams.length + 1 + i)).join(', ')}) `;
sqlParams.push(...libraryIDs);
}
sql += "ORDER BY value";
}

File diff suppressed because it is too large Load diff

View file

@ -1030,13 +1030,13 @@
<!-- Tag Selector -->
<menupopup id="tag-menu">
<menuitem label="&zotero.tagSelector.assignColor;"
<menuitem id="assign-color-tag" label="&zotero.tagSelector.assignColor;"
oncommand="ZoteroPane.tagSelector.openColorPickerWindow(); event.stopPropagation();"/>
<menuitem label="&zotero.tagSelector.renameTag;"
<menuitem id="rename-tag" label="&zotero.tagSelector.renameTag;"
oncommand="ZoteroPane.tagSelector.openRenamePrompt(); event.stopPropagation();"/>
<menuitem id="split-tag" label="&zotero.tagSelector.splitTag;"
oncommand="ZoteroPane.tagSelector.openTagSplitterWindow(event)"/>
<menuitem label="&zotero.tagSelector.deleteTag;"
<menuitem id="delete-tag" label="&zotero.tagSelector.deleteTag;"
oncommand="ZoteroPane.tagSelector.openDeletePrompt(); event.stopPropagation();"/>
</menupopup>
<menupopup id="tag-selector-view-settings-menu"
@ -1048,9 +1048,14 @@
var libraryID = ZoteroPane.tagSelector.libraryID;
var library = Zotero.Libraries.get(libraryID);
var enabled = false;
if (library.editable) {
if ((await Zotero.Tags.getAutomaticInLibrary(libraryID)).length) {
enabled = true;
// 'Delete Automatic Tags in This Library' is per-library, so disable it
// when the selection spans multiple libraries (nested ifs to avoid '&amp;&amp;'
// in this XML attribute)
if (!ZoteroPane.tagSelector.multiLibrary) {
if (library.editable) {
if ((await Zotero.Tags.getAutomaticInLibrary(libraryID)).length) {
enabled = true;
}
}
}
document.getElementById('delete-automatic-tags').disabled = !enabled;

View file

@ -338,6 +338,8 @@ import-online-relink-only =
import-online-relink-kb = { general-more-information }
import-online-connection-error = { -app-name } could not connect to { $targetApp }. Please check your internet connection and try again.
tab-title-multiple-collections = Multiple
items-table-cell-notes =
.aria-label = { $count ->
[one] { $count } Note

View file

@ -99,6 +99,20 @@ $universal-icons: (
}
}
}
&.selected {
border-radius: 0;
&.first-selected {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&.last-selected {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
}
}
.row.flashing:not(.drop) {

View file

@ -135,6 +135,44 @@ describe("Advanced Search", function () {
await saved.eraseTx();
});
it("should scope results to multiple selected collections", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
var inFirst = await createDataObject('item', { title: "foo bar", collections: [collection1.id] });
var inSecond = await createDataObject('item', { title: "foo baz", collections: [collection2.id] });
var noMatch = await createDataObject('item', { title: "qux", collections: [collection2.id] });
var notInCollections = await createDataObject('item', { title: "foo qux" });
var cv = zp.collectionsView;
await cv.selectByID("C" + collection1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
await zp.toggleAdvancedSearchState('open');
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'contains', 'foo');
deck.pane.search = s;
var iv = zp.itemsView;
await deck.pane.submit();
await iv.waitForLoad();
// Matching items from both collections, but not the matching item
// outside the selected collections
assert.equal(iv.rowCount, 2);
assert.isNumber(iv.getRowIndexByID(inFirst.id));
assert.isNumber(iv.getRowIndexByID(inSecond.id));
await zp.setAdvancedSearchState('closed');
await selectLibrary(win);
await Zotero.Items.erase([inFirst.id, inSecond.id, noMatch.id, notInCollections.id]);
await Zotero.Collections.erase([collection1.id, collection2.id]);
});
it("should search across feeds in Feeds view", async function () {
let feed = await createFeed();
let feedItem = await createDataObject('feedItem', { libraryID: feed.libraryID, setTitle: true }, { skipSelect: true });
@ -158,9 +196,59 @@ describe("Advanced Search", function () {
await zp.setAdvancedSearchState('closed');
await selectLibrary(win);
await feed.eraseTx();
});
it("should scope value autocomplete to the given libraries", async function () {
// Run the 'zotero' autocomplete provider directly with given params
function autocomplete(searchString, params) {
return new Promise((resolve) => {
let search = Cc["@mozilla.org/autocomplete/search;1?name=zotero"]
.createInstance(Ci.nsIAutoCompleteSearch);
let listener = {
onSearchResult(_search, result) {
// Ignore intermediate (ongoing) updates
if (result.searchResult == Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING) {
return;
}
let values = [];
for (let i = 0; i < result.matchCount; i++) {
values.push(result.getValueAt(i));
}
resolve(values);
}
};
search.startSearch(searchString, JSON.stringify(params), null, listener);
});
}
var group = await getGroup();
var groupLibraryID = group.libraryID;
// Autocomplete matches a value prefix, so both values start with the token
var token = Zotero.Utilities.randomString() + ' ';
var userPublisher = token + 'User';
var groupPublisher = token + 'Group';
var userItem = await createDataObject('item', { itemType: 'book' });
userItem.setField('publisher', userPublisher);
await userItem.saveTx();
var groupItem = await createDataObject('item', { itemType: 'book', libraryID: groupLibraryID });
groupItem.setField('publisher', groupPublisher);
await groupItem.saveTx();
// Scoped to the user library: only its value
var userOnly = await autocomplete(token, { fieldName: 'publisher', libraryIDs: [Zotero.Libraries.userLibraryID] });
assert.deepEqual(userOnly, [userPublisher]);
// Scoped to both libraries: both values
var both = await autocomplete(token, {
fieldName: 'publisher',
libraryIDs: [Zotero.Libraries.userLibraryID, groupLibraryID]
});
assert.includeMembers(both, [userPublisher, groupPublisher]);
await Zotero.Items.erase([userItem.id, groupItem.id]);
});
it("should save a search in an editable group library root but not a collection", async function () {
var group = await getGroup();
@ -463,10 +551,36 @@ describe("Advanced Search", function () {
assert.include(values, "S" + search2.key);
await selectLibrary(win);
await Zotero.Collections.erase([collection1.id, collection2.id]);
await Zotero.Searches.erase([search1.id, search2.id]);
});
it("shouldn't appear in a cross-library scope", async function () {
var group = await getGroup();
var groupLibraryID = group.libraryID;
// Simulate the cross-library scope that advancedSearchPane.refresh() sets
// from a multi-library collection selection
searchBox.scopeLibraryIDs = [Zotero.Libraries.userLibraryID, groupLibraryID];
try {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
// Collection condition (which also covers saved searches) shouldn't be offered
for (let i = 0; i < conditionsMenu.itemCount; i++) {
assert.notEqual(conditionsMenu.getItemAtIndex(i).value, 'collection');
}
}
finally {
searchBox.scopeLibraryIDs = null;
}
});
});
describe("Saved Search", function () {

View file

@ -482,6 +482,33 @@ describe("Citation Dialog", function () {
let rowNode = dialog.document.getElementById(rowID);
assert.isTrue(rowNode.classList.contains("highlighted"));
});
it("should show the union of items from multiple selected collections", async function () {
let collectionOne = await createDataObject('collection');
let collectionTwo = await createDataObject('collection');
let itemOne = await createDataObject('item', { collections: [collectionOne.id] });
let itemTwo = await createDataObject('item', { collections: [collectionTwo.id] });
await IOManager.toggleDialogMode("library");
let cv = dialog.libraryLayout.collectionsView;
let itemsView = dialog.libraryLayout.itemsView;
// Select both collections
await cv.selectByID("C" + collectionOne.id);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collectionTwo.id));
await dialog.libraryLayout._onCollectionSelection();
await itemsView.waitForLoad();
assert.isNumber(itemsView.getRowIndexByID(itemOne.id),
"Item from first selected collection should be shown");
assert.isNumber(itemsView.getRowIndexByID(itemTwo.id),
"Item from second selected collection should be shown");
// Restore the default single-row selection so later tests that rely on the
// library root being selected aren't affected by the leftover multi-selection
await cv.selectByID("L" + Zotero.Libraries.userLibraryID);
await itemsView.waitForLoad();
});
});
describe("Search", function () {

View file

@ -673,7 +673,6 @@ describe("Zotero.CollectionTree", function () {
it("should switch to library root if item isn't in collection", async function () {
var item = await createDataObject('item');
var collection = await createDataObject('collection');
Zotero.debug(zp.itemsView._rows);
await cv.selectItem(item.id);
await waitForItemsLoad(win);
assert.equal(cv.selection.focused, 0);
@ -731,7 +730,7 @@ describe("Zotero.CollectionTree", function () {
}
Zotero.DragDrop.currentDragSource = objectType == "item"
? zp.itemsView.collectionTreeRow
? zp.itemsView.collectionTreeRows[0]
: null;
if (!promise) {
@ -763,7 +762,7 @@ describe("Zotero.CollectionTree", function () {
var row = cv.getRowIndexByID(targetRowID);
Zotero.DragDrop.currentDragSource = objectType == "item"
? zp.itemsView.collectionTreeRow
? zp.itemsView.collectionTreeRows[0]
: null;
var dt = {
dropEffect: 'copy',

View file

@ -1962,6 +1962,120 @@ describe("CollectionViewItemTree", function () {
);
});
it("should add a dragged file to all selected collections", async function () {
var collection1 = await createDataObject('collection');
var collection2 = await createDataObject('collection');
// Select both collections
await cv.selectByID("C" + collection1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
itemsView = zp.itemsView;
var file = getTestDataDirectory();
file.append('test.png');
var idsPromise = waitForItemEvent('add');
drop(0, -1, {
dropEffect: 'copy',
effectAllowed: 'copy',
types: ['application/x-moz-file'],
mozItemCount: 1,
mozGetDataAt: function (type, i) {
if (type == 'application/x-moz-file' && i == 0) {
return file;
}
}
})
var ids = await idsPromise;
var item = Zotero.Items.get(ids[0]);
assert.isTrue(item.inCollection(collection1.id));
assert.isTrue(item.inCollection(collection2.id));
});
it("should allow a file drop onto a specific item but not blank space for a cross-library selection", async function () {
var group = await createGroup();
var c1 = await createDataObject('collection');
var c2 = await createDataObject('collection', { libraryID: group.libraryID });
var userItem = await createDataObject('item', { collections: [c1.id] });
var groupItem = await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
await cv.expandLibrary(group.libraryID);
await cv.selectByID("C" + c1.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + c2.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
itemsView = zp.itemsView;
var file = getTestDataDirectory();
file.append('test.png');
var fileDataTransfer = {
dropEffect: 'copy',
effectAllowed: 'copy',
types: ['application/x-moz-file'],
mozItemCount: 1,
mozGetDataAt: function (type, i) {
if (type == 'application/x-moz-file' && i == 0) {
return file;
}
}
};
// Directly onto a specific item (which identifies the target library): allowed
assert.isTrue(itemsView.canDropCheck(itemsView.getRowIndexByID(userItem.id), 0, fileDataTransfer));
// Into blank space (ambiguous across libraries): rejected
assert.isFalse(itemsView.canDropCheck(-1, -1, fileDataTransfer));
// Dropping onto the group item attaches the file in the group library
var idsPromise = waitForItemEvent('add');
await drop(itemsView.getRowIndexByID(groupItem.id), 0, fileDataTransfer);
var ids = await idsPromise;
var attachment = Zotero.Items.get(ids[0]);
assert.equal(attachment.libraryID, group.libraryID);
assert.equal(attachment.parentItemID, groupItem.id);
await selectLibrary(win);
await group.eraseTx();
});
it("should allow a blank-space file drop for a mixed collection and saved-search selection regardless of order", async function () {
var collection = await createDataObject('collection');
var search = await createDataObject('search');
var file = getTestDataDirectory();
file.append('test.png');
var fileDataTransfer = {
dropEffect: 'copy',
effectAllowed: 'copy',
types: ['application/x-moz-file'],
mozItemCount: 1,
mozGetDataAt: function (type, i) {
if (type == 'application/x-moz-file' && i == 0) {
return file;
}
}
};
// Select the saved search first (so it's focused), then add the collection
await cv.selectByID("S" + search.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
itemsView = zp.itemsView;
// Allowed even though the focused row is a search, since a selected
// collection can receive the file
assert.isTrue(itemsView.canDropCheck(-1, -1, fileDataTransfer));
await selectLibrary(win);
});
it("should create a stored top-level attachment when a file URI is dragged", async function () {
var promise = itemsView.waitForSelect();
var pdfFile = getTestDataDirectory();
@ -2449,16 +2563,16 @@ describe("CollectionViewItemTree", function () {
});
});
describe("#setCollectionTreeRow()", function () {
it("should no-op when setting the same row", async function () {
describe("#setCollectionTreeRows()", function () {
it("should no-op when setting the same rows", async function () {
let rowProvider = itemsView.rowProvider;
let currentRow = rowProvider.collectionTreeRow;
assert.ok(currentRow);
let refreshSpy = sinon.spy(rowProvider, 'refresh');
try {
await rowProvider.setCollectionTreeRow(currentRow);
await rowProvider.setCollectionTreeRows([currentRow]);
assert.equal(refreshSpy.callCount, 0);
}
finally {

View file

@ -768,6 +768,37 @@ describe("Item pane", function () {
rowIDs = [...section.querySelectorAll(".row")].map(node => node.dataset.id);
assert.deepEqual(rowIDs, [`L${item.libraryID}`, `C${collectionParent.id}`, `C${collectionChild.id}`]);
});
it("should mark every selected collection as current for a multiple-collection selection", async function () {
// Select both collections the item belongs to. Select the (sub)collection
// first so its row is revealed, then toggle the parent into the selection.
let cv = ZoteroPane.collectionsView;
await cv.selectByID("C" + collectionChild.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collectionParent.id));
await ZoteroPane.onCollectionSelected();
await ZoteroPane.itemsView.waitForLoad();
// Select within the current (multi-collection) view rather than
// ZoteroPane.selectItem(), which would navigate and drop the selection
await ZoteroPane.itemsView.selectItem(item.id);
// itemSelected() pushes the current selection into the item pane; force a
// render so the section reflects the multi-collection selection (item-pane
// render is skipped when the selected item itself hasn't changed)
await ZoteroPane.itemSelected();
await ZoteroPane.itemPane.render();
section = ZoteroPane.itemPane._itemDetails.getPane("libraries-collections");
let parentBox = section.querySelector(`.row[data-id="C${collectionParent.id}"] .box`);
let childBox = section.querySelector(`.row[data-id="C${collectionChild.id}"] .box`);
let libraryBox = section.querySelector(`.row[data-id="L${item.libraryID}"] .box`);
// Both selected collections are bolded (.current); the unselected library row is not
assert.isTrue(parentBox.classList.contains('current'));
assert.isTrue(childBox.classList.contains('current'));
assert.isFalse(libraryBox.classList.contains('current'));
await selectLibrary(win);
});
});
describe("Attachments pane", function () {

View file

@ -944,6 +944,7 @@ describe("Plugin API", function () {
...defaultContextKeys,
"items",
"collectionTreeRow",
"collectionTreeRows",
]
},
"main/library/collection": {
@ -954,6 +955,7 @@ describe("Plugin API", function () {
contextKeys: [
...defaultContextKeys,
"collectionTreeRow",
"collectionTreeRows",
]
},
"main/library/addAttachment": {

View file

@ -246,7 +246,32 @@ describe("Connector Server", function () {
var req = await reqPromise;
assert.equal(req.status, 201);
});
it("should target only the focused row for a cross-library multiple-collection selection", async function () {
// A collection in My Library plus a group library root, with the group focused.
// The Connector saves to a single target, so it should use the focused row (the
// group root) and not the collection from the other library.
var group = await createGroup();
var collection = await createDataObject('collection');
var cv = win.ZoteroPane.collectionsView;
await select(win, collection);
var groupRow = cv.getRowIndexByID(group.treeViewID);
cv.selection.toggleSelect(groupRow);
await waitForItemsLoad(win);
// Sanity check: both rows selected, with the group focused
assert.equal(cv.selection.focused, groupRow);
assert.sameMembers(
win.ZoteroPane.getCollectionTreeRows().map(r => r.ref.libraryID),
[Zotero.Libraries.userLibraryID, group.libraryID]
);
var target = Zotero.Server.Connector.getSaveTarget();
assert.equal(target.library.libraryID, group.libraryID);
assert.isNull(target.collection);
});
it("should use the provided proxy to deproxify item url", async function () {
await selectLibrary(win, Zotero.Libraries.userLibraryID);
await waitForItemsLoad(win);

View file

@ -48,7 +48,7 @@ describe("Tag Selector", function () {
tagSelector.selectedTags = new Set();
tagSelector.handleSearch('');
tagSelector.onItemViewChanged({
collectionTreeRow: win.ZoteroPane.getCollectionTreeRow(),
collectionTreeRows: win.ZoteroPane.getCollectionTreeRows(),
libraryID
});
await waitForTagSelector(win);
@ -783,6 +783,128 @@ describe("Tag Selector", function () {
});
});
describe("Cross-library selection", function () {
var group, groupLibraryID;
before(async function () {
group = await createGroup();
groupLibraryID = group.libraryID;
await collectionsView.expandLibrary(groupLibraryID);
});
after(async function () {
await group.eraseTx();
});
async function selectCrossLibrary(userCollection, groupCollection) {
let cv = collectionsView;
await cv.selectByID("C" + userCollection.id);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID("C" + groupCollection.id));
await win.ZoteroPane.onCollectionSelected();
await win.ZoteroPane.itemsView.waitForLoad();
tagSelector.onItemViewChanged({
collectionTreeRows: win.ZoteroPane.getCollectionTreeRows(),
libraryID: win.ZoteroPane.getSelectedLibraryID()
});
await waitForTagSelector(win);
}
it("should show tags from all selected libraries", async function () {
let userTag = 'U ' + Zotero.Utilities.randomString();
let groupTag = 'G ' + Zotero.Utilities.randomString();
let userCollection = await createDataObject('collection');
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
let userItem = createUnsavedDataObject('item', { collections: [userCollection.id] });
userItem.setTags([userTag]);
let groupItem = createUnsavedDataObject(
'item', { libraryID: groupLibraryID, collections: [groupCollection.id] }
);
groupItem.setTags([groupTag]);
await Zotero.DB.executeTransaction(async function () {
await userItem.save();
await groupItem.save();
});
await selectCrossLibrary(userCollection, groupCollection);
assert.isTrue(tagSelector.multiLibrary);
let tags = getRegularTags();
assert.includeMembers(tags, [userTag, groupTag]);
});
it("shouldn't show colored tags in the selector across libraries", async function () {
let coloredTag = 'C ' + Zotero.Utilities.randomString();
let userCollection = await createDataObject('collection');
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
// Color the tag in the user library and apply it to an item there
await Zotero.Tags.setColor(libraryID, coloredTag, '#990000', 0);
let userItem = createUnsavedDataObject('item', { collections: [userCollection.id] });
userItem.setTags([coloredTag]);
let groupItem = createUnsavedDataObject(
'item', { libraryID: groupLibraryID, collections: [groupCollection.id] }
);
groupItem.setTags(['G ' + Zotero.Utilities.randomString()]);
await Zotero.DB.executeTransaction(async function () {
await userItem.save();
await groupItem.save();
});
await selectCrossLibrary(userCollection, groupCollection);
// The tag is still listed, just not as a colored tag
assert.include(getRegularTags(), coloredTag);
assert.notInclude(getColoredTags(), coloredTag);
await Zotero.Tags.setColor(libraryID, coloredTag, false);
});
it("should disable rename, color, and split but allow delete", async function () {
let userCollection = await createDataObject('collection');
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
await selectCrossLibrary(userCollection, groupCollection);
let menu = doc.getElementById('tag-menu');
let mockEvent = { preventDefault() {}, screenX: 0, screenY: 0 };
try {
tagSelector.handleTagContext({ name: 'whatever' }, mockEvent);
assert.isTrue(doc.getElementById('assign-color-tag').disabled);
assert.isTrue(doc.getElementById('rename-tag').disabled);
assert.isTrue(doc.getElementById('split-tag').disabled);
assert.isFalse(doc.getElementById('delete-tag').disabled);
}
finally {
menu.hidePopup();
}
});
it("should delete a tag from all selected libraries", async function () {
let sharedTag = 'shared ' + Zotero.Utilities.randomString();
let userCollection = await createDataObject('collection');
let groupCollection = await createDataObject('collection', { libraryID: groupLibraryID });
let userItem = createUnsavedDataObject('item', { collections: [userCollection.id] });
userItem.setTags([sharedTag]);
let groupItem = createUnsavedDataObject(
'item', { libraryID: groupLibraryID, collections: [groupCollection.id] }
);
groupItem.setTags([sharedTag]);
await Zotero.DB.executeTransaction(async function () {
await userItem.save();
await groupItem.save();
});
await selectCrossLibrary(userCollection, groupCollection);
tagSelector.contextTag = { name: sharedTag };
let dialogPromise = waitForDialog();
await tagSelector.openDeletePrompt();
await dialogPromise;
assert.isFalse(userItem.hasTag(sharedTag), "Tag removed from user library item");
assert.isFalse(groupItem.hasTag(sharedTag), "Tag removed from group library item");
});
});
describe("Search error handling", function () {
it("should degrade gracefully when getTags() throws SearchError", async function () {
// _safeGetTags wraps collectionTreeRow.getTags(), which calls getSearchResults().

View file

@ -1040,6 +1040,55 @@ describe("ZoteroPane", function () {
});
});
describe("#buildCollectionContextMenu()", function () {
async function selectCollectionRows(ids) {
let cv = zp.collectionsView;
await cv.selectByID(ids[0]);
await waitForItemsLoad(win);
for (let id of ids.slice(1)) {
cv.selection.toggleSelect(cv.getRowIndexByID(id));
}
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
}
afterEach(async function () {
await selectLibrary(win);
});
it("should hide New Subcollection and Rename for a multiple-collection selection", async function () {
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection');
// A single collection shows both single-target actions
await selectCollectionRows(["C" + c1.id]);
await zp.buildCollectionContextMenu();
assert.isFalse(win.document.getElementById('newSubcollection').hidden);
assert.isFalse(win.document.getElementById('editSelectedCollection').hidden);
// Two collections hides them
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
await zp.buildCollectionContextMenu();
assert.isTrue(win.document.getElementById('newSubcollection').hidden);
assert.isTrue(win.document.getElementById('editSelectedCollection').hidden);
});
it("should disable the report for a cross-library collection selection", async function () {
let group = await createGroup();
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
await createDataObject('item', { collections: [c1.id] });
await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
await zp.collectionsView.expandLibrary(group.libraryID);
await selectCollectionRows(["C" + c1.id, "C" + c2.id]);
await zp.buildCollectionContextMenu();
assert.isTrue(win.document.getElementById('loadReport').disabled);
await group.eraseTx();
});
});
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');
@ -1052,6 +1101,42 @@ describe("ZoteroPane", function () {
assert.isTrue(menu.querySelector('.zotero-menuitem-export').hidden);
assert.isTrue(menu.querySelector('.zotero-menuitem-create-bibliography').hidden);
});
it("should disable the report when selected items span libraries", async function () {
let group = await createGroup();
let c1 = await createDataObject('collection');
let c2 = await createDataObject('collection', { libraryID: group.libraryID });
let item1 = await createDataObject('item', { collections: [c1.id] });
let item2 = await createDataObject('item', { libraryID: group.libraryID, collections: [c2.id] });
// Cross-library multiple-collection selection -> grouped items view
await zp.collectionsView.expandLibrary(group.libraryID);
let 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();
let menu = win.document.getElementById('zotero-itemmenu');
let report = menu.querySelector('.zotero-menuitem-create-report');
// Select within the current (grouped) view rather than ZoteroPane.selectItems(),
// which would navigate the collection tree and drop the multi-selection
// Items from a single library: report enabled
await zp.itemsView.selectItems([item1.id]);
await zp.buildItemContextMenu();
assert.isFalse(report.disabled);
// Items spanning libraries: report disabled
await zp.itemsView.selectItems([item1.id, item2.id]);
await zp.buildItemContextMenu();
assert.isTrue(report.disabled);
await selectLibrary(win);
await group.eraseTx();
});
it("should show “Export Note…” for standalone file attachment with note", async function () {
var item1 = await importFileAttachment('test.png');
@ -1970,4 +2055,221 @@ describe("ZoteroPane", function () {
assert.includeMembers(topLevelCollections, [collectionChild]);
});
});
describe("Multi-Collection Selection", function () {
afterEach(async function () {
Zotero.Prefs.clear('recursiveCollections');
await selectLibrary(win);
});
async function selectMultipleCollections(collections) {
let cv = zp.collectionsView;
// Select the first collection
await cv.selectByID("C" + collections[0].id);
await waitForItemsLoad(win);
// Toggle-select additional collections
for (let i = 1; i < collections.length; i++) {
let row = cv.getRowIndexByID("C" + collections[i].id);
cv.selection.toggleSelect(row);
}
// Wait for the items view to reload with the combined set. The
// selection-triggered call is queued ahead of this one in Zotero.serial,
// and this one no-ops once the view matches the selection.
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
}
describe("Uncombinable selections", function () {
it("should keep only the focused row when Recently Read and a collection are selected together", async function () {
let collection = await createDataObject('collection');
let cv = zp.collectionsView;
// Show and select Recently Read
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', true, true);
await waitForItemsLoad(win);
// Toggle-select the collection, forming an uncombinable two-row selection
cv.selection.toggleSelect(cv.getRowIndexByID("C" + collection.id));
await zp.onCollectionSelected();
// The selection should have been reduced to the focused (collection) row
assert.equal(cv.selection.count, 1);
let rows = zp.getCollectionTreeRows();
assert.lengthOf(rows, 1);
assert.isTrue(rows[0].isCollection());
await zp.setVirtual(Zotero.Libraries.userLibraryID, 'recentlyRead', false);
});
});
describe("Combinable special views", function () {
it("should combine Recently Read across libraries without reducing the selection", async function () {
let userLibraryID = Zotero.Libraries.userLibraryID;
let groupLibraryID = (await createGroup()).libraryID;
let cv = zp.collectionsView;
// Seed a recently-read item in each library
let userItem = await createDataObject('item', { libraryID: userLibraryID });
let userAttachment = await importPDFAttachment(userItem);
userAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
await userAttachment.saveTx();
let groupItem = await createDataObject('item', { libraryID: groupLibraryID });
let groupAttachment = await importPDFAttachment(groupItem);
groupAttachment.attachmentLastRead = Math.round(Date.now() / 1000);
await groupAttachment.saveTx();
// Show Recently Read in both libraries, then select both rows
await zp.setVirtual(userLibraryID, 'recentlyRead', true, false);
await zp.setVirtual(groupLibraryID, 'recentlyRead', true, false);
await cv.selectByID('Y' + userLibraryID);
await waitForItemsLoad(win);
cv.selection.toggleSelect(cv.getRowIndexByID('Y' + groupLibraryID));
await zp.onCollectionSelected();
await zp.itemsView.waitForLoad();
// Both Recently Read rows should remain selected
assert.equal(cv.selection.count, 2);
let rows = zp.getCollectionTreeRows();
assert.lengthOf(rows, 2);
assert.isTrue(rows.every(r => r.isRecentlyRead()));
// The items list should show the recently-read item from each library
assert.isNumber(zp.itemsView.getRowIndexByID(userItem.id));
assert.isNumber(zp.itemsView.getRowIndexByID(groupItem.id));
await zp.setVirtual(userLibraryID, 'recentlyRead', false);
await zp.setVirtual(groupLibraryID, 'recentlyRead', false);
Zotero.Items._lastReadCutoffs.clear();
});
});
describe("Items display", function () {
it("should show items from multiple selected collections", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
let item1 = await createDataObject('item', { collections: [collection1.id] });
let item2 = await createDataObject('item', { collections: [collection2.id] });
await selectMultipleCollections([collection1, collection2]);
let itemsView = zp.itemsView;
assert.ok(itemsView.getRowIndexByID(item1.id) !== false, "Item from first collection should be shown");
assert.ok(itemsView.getRowIndexByID(item2.id) !== false, "Item from second collection should be shown");
});
it("should show items from subcollections with recursiveCollections", async function () {
Zotero.Prefs.set('recursiveCollections', true);
let collection1 = await createDataObject('collection');
let subCollection1 = await createDataObject('collection', { parentID: collection1.id });
let collection2 = await createDataObject('collection');
let subCollection2 = await createDataObject('collection', { parentID: collection2.id });
let item1 = await createDataObject('item', { collections: [subCollection1.id] });
let item2 = await createDataObject('item', { collections: [subCollection2.id] });
let item3 = await createDataObject('item', { collections: [collection1.id] });
await selectMultipleCollections([collection1, collection2]);
let itemsView = zp.itemsView;
assert.ok(itemsView.getRowIndexByID(item1.id) !== false,
"Item from subcollection of first collection should be shown");
assert.ok(itemsView.getRowIndexByID(item2.id) !== false,
"Item from subcollection of second collection should be shown");
assert.ok(itemsView.getRowIndexByID(item3.id) !== false,
"Item directly in first collection should be shown");
});
it("should not duplicate items that exist in both selected collections", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
let item = await createDataObject('item', { collections: [collection1.id, collection2.id] });
await selectMultipleCollections([collection1, collection2]);
let itemsView = zp.itemsView;
// Count how many times the item appears
let count = 0;
for (let i = 0; i < itemsView.rowCount; i++) {
if (itemsView.getRow(i).ref.id === item.id) {
count++;
}
}
assert.equal(count, 1, "Item in both collections should appear only once");
});
});
describe("#newItem()", function () {
it("should add a new item to all selected collections", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
await selectMultipleCollections([collection1, collection2]);
let item = await zp.newItem(Zotero.ItemTypes.getID('book'), {}, null, true);
assert.isTrue(item.inCollection(collection1.id),
"New item should be in first selected collection");
assert.isTrue(item.inCollection(collection2.id),
"New item should be in second selected collection");
});
});
describe("#duplicateSelectedItem()", function () {
it("should add duplicate to the subset of selected collections the original belongs to", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
let collection3 = await createDataObject('collection');
// Item is in collections 1 and 3, not 2
let item = await createDataObject('item', { collections: [collection1.id, collection3.id] });
// Select collection1, which contains the item
await select(win, collection1);
await zp.itemsView.selectItem(item.id);
// Temporarily override getCollectionTreeRows to simulate multi-collection selection
let cv = zp.collectionsView;
let origGetRows = zp.getCollectionTreeRows;
let row1 = cv.getRowIndexByID("C" + collection1.id);
let row2 = cv.getRowIndexByID("C" + collection2.id);
let row3 = cv.getRowIndexByID("C" + collection3.id);
zp.getCollectionTreeRows = function () {
return [row1, row2, row3].map(index => cv.getRow(index));
};
try {
let duplicate = await zp.duplicateSelectedItem();
// Duplicate should be in collections 1 and 3 (selected + original is there)
// but not in collection 2 (selected but original isn't there)
assert.isTrue(duplicate.inCollection(collection1.id),
"Duplicate should be in collection 1 (selected and original exists)");
assert.isFalse(duplicate.inCollection(collection2.id),
"Duplicate should not be in collection 2 (selected but original doesn't exist)");
assert.isTrue(duplicate.inCollection(collection3.id),
"Duplicate should be in collection 3 (selected and original exists)");
}
finally {
zp.getCollectionTreeRows = origGetRows;
}
});
});
describe("#deleteSelectedCollection()", function () {
it("should move all selected collections to trash", async function () {
let collection1 = await createDataObject('collection');
let collection2 = await createDataObject('collection');
await selectMultipleCollections([collection1, collection2]);
let promise = waitForDialog();
await zp.deleteSelectedCollection();
await promise;
assert.isTrue(collection1.deleted,
"First collection should be in trash");
assert.isTrue(collection2.deleted,
"Second collection should be in trash");
});
});
});
})