From d7770a9d11894c5c01e4148bd22fa6148b48f66c Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Sat, 13 Jun 2026 19:20:44 -0400 Subject: [PATCH] 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. --- chrome/content/zotero/collectionTree.jsx | 146 +++-- .../content/zotero/collectionViewItemTree.jsx | 268 +++++--- .../containers/tagSelectorContainer.jsx | 137 +++- .../zotero/elements/advancedSearchPane.js | 29 +- chrome/content/zotero/elements/itemBox.js | 4 +- chrome/content/zotero/elements/itemDetails.js | 10 +- chrome/content/zotero/elements/itemPane.js | 26 +- .../elements/itemPaneSectionElementBase.mjs | 8 +- .../elements/librariesCollectionsBox.js | 4 +- .../content/zotero/elements/zoteroSearch.js | 32 +- chrome/content/zotero/fileInterface.js | 44 +- .../zotero/integration/citationDialog.js | 77 ++- chrome/content/zotero/itemTree.jsx | 10 +- chrome/content/zotero/libraryTree.js | 59 ++ chrome/content/zotero/lookup.js | 4 +- chrome/content/zotero/note.js | 7 +- chrome/content/zotero/reportInterface.js | 77 ++- chrome/content/zotero/selectItemsDialog.js | 2 +- .../content/zotero/xpcom/collectionTreeRow.js | 82 ++- chrome/content/zotero/xpcom/data/notes.js | 5 +- .../zotero/xpcom/server/server_connector.js | 7 +- .../xpcom/translation/translate_item.js | 6 +- chrome/content/zotero/zotero-autocomplete.mjs | 60 +- chrome/content/zotero/zoteroPane.js | 593 +++++++++++------- chrome/content/zotero/zoteroPane.xhtml | 17 +- chrome/locale/en-US/zotero/zotero.ftl | 2 + scss/components/_collection-tree.scss | 14 + test/tests/advancedSearchTest.js | 118 +++- test/tests/citationDialogTest.js | 27 + test/tests/collectionTreeTest.js | 5 +- test/tests/collectionViewItemTreeTest.js | 124 +++- test/tests/itemPaneTest.js | 31 + test/tests/pluginAPITest.js | 2 + test/tests/server_connectorTest.js | 27 +- test/tests/tagSelectorTest.js | 124 +++- test/tests/zoteroPaneTest.js | 302 +++++++++ 36 files changed, 1893 insertions(+), 597 deletions(-) diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 90e2f1cad6..efff2a8a9b 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -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} */ 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') { diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index 231dc9f387..a960bc430e 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -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} */ - 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} */ 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 () => { diff --git a/chrome/content/zotero/containers/tagSelectorContainer.jsx b/chrome/content/zotero/containers/tagSelectorContainer.jsx index 45afdc0736..5f948bb209 100644 --- a/chrome/content/zotero/containers/tagSelectorContainer.jsx +++ b/chrome/content/zotero/containers/tagSelectorContainer.jsx @@ -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); + } } } diff --git a/chrome/content/zotero/elements/advancedSearchPane.js b/chrome/content/zotero/elements/advancedSearchPane.js index f6b96bc337..2c46e6af92 100644 --- a/chrome/content/zotero/elements/advancedSearchPane.js +++ b/chrome/content/zotero/elements/advancedSearchPane.js @@ -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( diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js index 6acae0df39..55ab64e4ce 100644 --- a/chrome/content/zotero/elements/itemBox.js +++ b/chrome/content/zotero/elements/itemBox.js @@ -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"); } diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js index c441d403da..afc4adf3c2 100644 --- a/chrome/content/zotero/elements/itemDetails.js +++ b/chrome/content/zotero/elements/itemDetails.js @@ -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(); diff --git a/chrome/content/zotero/elements/itemPane.js b/chrome/content/zotero/elements/itemPane.js index bd4306fb46..348ca3c6d9 100644 --- a/chrome/content/zotero/elements/itemPane.js +++ b/chrome/content/zotero/elements/itemPane.js @@ -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'); diff --git a/chrome/content/zotero/elements/itemPaneSectionElementBase.mjs b/chrome/content/zotero/elements/itemPaneSectionElementBase.mjs index 661a62c5d7..efd8025dab 100644 --- a/chrome/content/zotero/elements/itemPaneSectionElementBase.mjs +++ b/chrome/content/zotero/elements/itemPaneSectionElementBase.mjs @@ -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; diff --git a/chrome/content/zotero/elements/librariesCollectionsBox.js b/chrome/content/zotero/elements/librariesCollectionsBox.js index 5eb173b07e..1c287fc1ac 100644 --- a/chrome/content/zotero/elements/librariesCollectionsBox.js +++ b/chrome/content/zotero/elements/librariesCollectionsBox.js @@ -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 diff --git a/chrome/content/zotero/elements/zoteroSearch.js b/chrome/content/zotero/elements/zoteroSearch.js index 773ccbdc47..311da98e6a 100644 --- a/chrome/content/zotero/elements/zoteroSearch.js +++ b/chrome/content/zotero/elements/zoteroSearch.js @@ -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': diff --git a/chrome/content/zotero/fileInterface.js b/chrome/content/zotero/fileInterface.js index 7c0c9cdc5f..4069fd2eaa 100644 --- a/chrome/content/zotero/fileInterface.js +++ b/chrome/content/zotero/fileInterface.js @@ -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(); diff --git a/chrome/content/zotero/integration/citationDialog.js b/chrome/content/zotero/integration/citationDialog.js index f0100c65fc..144b703438 100644 --- a/chrome/content/zotero/integration/citationDialog.js +++ b/chrome/content/zotero/integration/citationDialog.js @@ -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(); } diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index fe213796bb..8300e68ea7 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -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) { diff --git a/chrome/content/zotero/libraryTree.js b/chrome/content/zotero/libraryTree.js index f9499270a1..a3a7783335 100644 --- a/chrome/content/zotero/libraryTree.js +++ b/chrome/content/zotero/libraryTree.js @@ -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); diff --git a/chrome/content/zotero/lookup.js b/chrome/content/zotero/lookup.js index 1a890b990a..599bd02353 100644 --- a/chrome/content/zotero/lookup.js +++ b/chrome/content/zotero/lookup.js @@ -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 **/ diff --git a/chrome/content/zotero/note.js b/chrome/content/zotero/note.js index 8abdd7ea89..0b6c101c1b 100644 --- a/chrome/content/zotero/note.js +++ b/chrome/content/zotero/note.js @@ -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(); } diff --git a/chrome/content/zotero/reportInterface.js b/chrome/content/zotero/reportInterface.js index 1131c911f1..df387696fb 100644 --- a/chrome/content/zotero/reportInterface.js +++ b/chrome/content/zotero/reportInterface.js @@ -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]; + } } diff --git a/chrome/content/zotero/selectItemsDialog.js b/chrome/content/zotero/selectItemsDialog.js index d69e3a122a..875cd60bbf 100644 --- a/chrome/content/zotero/selectItemsDialog.js +++ b/chrome/content/zotero/selectItemsDialog.js @@ -154,7 +154,7 @@ var onCollectionSelected = async function () { await library.waitForDataLoad('item'); } - await itemsView.changeCollectionTreeRow(collectionTreeRow); + await itemsView.changeCollectionTreeRows([collectionTreeRow]); itemsView.clearItemsPaneMessage(); }; diff --git a/chrome/content/zotero/xpcom/collectionTreeRow.js b/chrome/content/zotero/xpcom/collectionTreeRow.js index 86d3cb656f..b7595b2fbb 100644 --- a/chrome/content/zotero/xpcom/collectionTreeRow.js +++ b/chrome/content/zotero/xpcom/collectionTreeRow.js @@ -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; }; diff --git a/chrome/content/zotero/xpcom/data/notes.js b/chrome/content/zotero/xpcom/data/notes.js index 409fa4cf64..738b6f923d 100644 --- a/chrome/content/zotero/xpcom/data/notes.js +++ b/chrome/content/zotero/xpcom/data/notes.js @@ -43,7 +43,7 @@ Zotero.Notes = new function () { * @returns {Promise} 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', diff --git a/chrome/content/zotero/xpcom/server/server_connector.js b/chrome/content/zotero/xpcom/server/server_connector.js index b33452bd8d..19783ecb0e 100644 --- a/chrome/content/zotero/xpcom/server/server_connector.js +++ b/chrome/content/zotero/xpcom/server/server_connector.js @@ -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 diff --git a/chrome/content/zotero/xpcom/translation/translate_item.js b/chrome/content/zotero/xpcom/translation/translate_item.js index 4fb2c4f675..f63f5c1b82 100644 --- a/chrome/content/zotero/xpcom/translation/translate_item.js +++ b/chrome/content/zotero/xpcom/translation/translate_item.js @@ -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; diff --git a/chrome/content/zotero/zotero-autocomplete.mjs b/chrome/content/zotero/zotero-autocomplete.mjs index 3b8a5ca553..28e7456756 100644 --- a/chrome/content/zotero/zotero-autocomplete.mjs +++ b/chrome/content/zotero/zotero-autocomplete.mjs @@ -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"; } diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 80bec8ccb7..d3c3b9ed2d 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -48,7 +48,6 @@ var ZoteroPane = new function () { this.captureKeyDown = captureKeyDown; this.handleKeyUp = handleKeyUp; this.handleKeyPress = handleKeyPress; - this.getSelectedCollection = getSelectedCollection; this.getSelectedSavedSearch = getSelectedSavedSearch; this.getSortField = getSortField; this.getSortDirection = getSortDirection; @@ -1369,21 +1368,22 @@ var ZoteroPane = new function () { } break; case 'toggleAllRead': - var collectionTreeRow = this.getCollectionTreeRow(); - if (collectionTreeRow.isFeed()) { + var collectionTreeRows = this.getCollectionTreeRows(); + if (collectionTreeRows[0].isFeed()) { this.markFeedRead(); } break; - case 'toggleRead': + case 'toggleRead': { // Toggle read/unread - let row = this.getCollectionTreeRow(); - if (!row || !row.isFeedsOrFeed()) return; + let rows = this.getCollectionTreeRows(); + if (!rows.some(row => row.isFeedsOrFeed())) return; this.toggleSelectedItemsRead(); if (itemReadTimeout) { clearTimeout(itemReadTimeout); itemReadTimeout = null; } break; + } // Handled by s in standalone.js, pointing to s in zoteroPane.xul, // which are enabled or disabled by this.updateQuickCopyCommands(), called by @@ -1411,6 +1411,13 @@ var ZoteroPane = new function () { * _data_ is an optional object with field:value for itemData */ this.newItem = async function (typeID, data, row, manual) { + // Shouldn't be reachable with multiple libraries selected (toolbar is disabled), + // but just in case + let rows = this.getCollectionTreeRows(); + if (new Set(rows.map(r => r.ref.libraryID)).size > 1) { + return; + } + if ((row === undefined || row === null) && this.getCollectionTreeRow()) { row = this.collectionsView.selection.focused; @@ -1432,6 +1439,8 @@ var ZoteroPane = new function () { var collectionTreeRow = null; } + let selectedCollectionTreeRows = rows.filter(r => r.isCollection()); + let itemID; await Zotero.DB.executeTransaction(async function () { var item = new Zotero.Item(typeID); @@ -1441,8 +1450,8 @@ var ZoteroPane = new function () { } itemID = await item.save(); - if (collectionTreeRow && collectionTreeRow.isCollection()) { - await collectionTreeRow.ref.addItem(itemID); + for (let r of selectedCollectionTreeRows) { + await r.ref.addItem(itemID); } }); @@ -1615,7 +1624,8 @@ var ZoteroPane = new function () { ZoteroPane.collectionsView = await CollectionTree.init(collectionsTree, { onSelectionChange: prevSelection => ZoteroPane.onCollectionSelected(prevSelection), onContextMenu: (...args) => ZoteroPane.onCollectionsContextMenuOpen(...args), - dragAndDrop: true + dragAndDrop: true, + multiSelect: true }); collectionsTree.firstChild.addEventListener("focus", ZoteroPane.collectionsView.recordCollectionTreeFocus); } @@ -1696,8 +1706,8 @@ var ZoteroPane = new function () { this.tagSelectorShown = function () { - var collectionTreeRow = this.getCollectionTreeRow(); - if (!collectionTreeRow) return; + var collectionTreeRows = this.getCollectionTreeRows(); + if (!collectionTreeRows.length) return; var tagSelector = document.getElementById('zotero-tag-selector-container'); return !tagSelector.hasAttribute('collapsed') || tagSelector.getAttribute('collapsed') == 'false'; @@ -1710,36 +1720,71 @@ var ZoteroPane = new function () { * Passed to the items tree to trigger on changes */ this.setTagScope = async function () { - var collectionTreeRow = self.getCollectionTreeRow(); + var collectionTreeRows = this.getCollectionTreeRows(); if (self.tagSelectorShown()) { if (!ZoteroPane.tagSelector) { await this.initTagSelector(); } - if (collectionTreeRow.editable) { + if (collectionTreeRows.every(o => o.editable)) { ZoteroPane_Local.tagSelector.setMode('edit'); } else { ZoteroPane_Local.tagSelector.setMode('view'); } ZoteroPane_Local.tagSelector.onItemViewChanged({ - libraryID: collectionTreeRow.ref && collectionTreeRow.ref.libraryID, - collectionTreeRow + libraryID: collectionTreeRows[0].ref && collectionTreeRows[0].ref.libraryID, + collectionTreeRows }); } }; this.onCollectionSelected = Zotero.serial(async function () { - var collectionTreeRow = this.getCollectionTreeRow(); - if (!collectionTreeRow) { + var collectionTreeRows = this.getCollectionTreeRows(); + if (!collectionTreeRows.length) { Zotero.debug('ZoteroPane.onCollectionSelected: No selected collection found'); return; } - - if (this.itemsView && this.itemsView.collectionTreeRow && this.itemsView.collectionTreeRow.id == collectionTreeRow.id) { + + // Only certain combinations of rows can be shown together in one items view. + // Collections, saved searches, and library roots can be mixed freely, within or + // across libraries. Recently Read can be combined only with other Recently Read + // rows (across libraries). All other special views (Trash, Duplicates, etc.) can't + // be shown alongside anything else. The visibility-group check enforces both the + // Recently Read restriction and the feed/non-feed split: Recently Read and feeds + // are each their own group, so pairing one with a collection (or with each other) + // spans two groups, which can't share an items view. When the selected rows can't + // be shown together, drop everything except the focused row and show just that. + // Selections spanning multiple libraries are shown grouped by library in the + // items list. + if (collectionTreeRows.length > 1) { + let combinable = collectionTreeRows.every( + row => row.isCollection() || row.isSearch() || row.isLibrary(true) + || row.isFeeds() || row.isRecentlyRead() + ); + let mixesVisibilityGroups = new Set( + collectionTreeRows.map(row => row.visibilityGroup) + ).size > 1; + if (!combinable || mixesVisibilityGroups) { + Zotero.debug("ZoteroPane.onCollectionSelected: Selected rows can't be shown " + + "together -- keeping only the focused row"); + // Drop all but the focused row. Don't await selectByID() here: it awaits + // waitForSelect(), whose 'select' event can't fire until this handler + // returns, so it would deadlock. Reducing the selection directly + // re-triggers onCollectionSelected() with the single row. + this.collectionsView.selection.select(this.collectionsView.selection.focused); + return; + } + } + + if (this.itemsView?.collectionTreeRows + && Zotero.Utilities.arrayEquals( + collectionTreeRows.map(r => r.id).sort(), + this.itemsView.collectionTreeRows.map(r => r.id).sort() + )) { Zotero.debug("ZoteroPane.onCollectionSelected: Collection selection hasn't changed"); // Update enabled actions, in case editability has changed - this._updateEnabledActionsForRow(collectionTreeRow); + this._updateEnabledActionsForCollectionTreeRows(collectionTreeRows); return; } @@ -1783,7 +1828,10 @@ var ZoteroPane = new function () { } // Rename tab - Zotero_Tabs.rename('zotero-pane', collectionTreeRow.getName()); + let tabName = collectionTreeRows.length == 1 + ? collectionTreeRows[0].getName() + : Zotero.getString('tab-title-multiple-collections'); + Zotero_Tabs.rename('zotero-pane', tabName); // Clear quick search and tag selector when switching views document.getElementById('zotero-tb-search').onCollectionSelected(); @@ -1791,46 +1839,48 @@ var ZoteroPane = new function () { ZoteroPane.tagSelector.clearTagSelection(); } - collectionTreeRow.setSearch(''); - if (ZoteroPane.tagSelector) { - collectionTreeRow.setTags(ZoteroPane.tagSelector.getTagSelection()); - } + this._refreshAdvancedSearchPane(collectionTreeRows); + this._updateEnabledActionsForCollectionTreeRows(collectionTreeRows); - this._refreshAdvancedSearchPane(collectionTreeRow); - this._updateEnabledActionsForRow(collectionTreeRow); + for (let collectionTreeRow of collectionTreeRows) { + collectionTreeRow.setSearch(''); + if (ZoteroPane.tagSelector) { + collectionTreeRow.setTags(ZoteroPane.tagSelector.getTagSelection()); + } - // If item data not yet loaded for library, load it now. - // Other data types are loaded at startup - if (collectionTreeRow.isFeeds()) { - var feedsToLoad = Zotero.Feeds.getAll().filter(feed => !feed.getDataLoaded('item')); - if (feedsToLoad.length) { - Zotero.debug("Waiting for items to load for feeds " + feedsToLoad.map(feed => feed.libraryID)); - ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading')); - for (let feed of feedsToLoad) { - await feed.waitForDataLoad('item'); + // If item data not yet loaded for library, load it now. + // Other data types are loaded at startup + if (collectionTreeRow.isFeeds()) { + var feedsToLoad = Zotero.Feeds.getAll().filter(feed => !feed.getDataLoaded('item')); + if (feedsToLoad.length) { + Zotero.debug("Waiting for items to load for feeds " + feedsToLoad.map(feed => feed.libraryID)); + ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading')); + for (let feed of feedsToLoad) { + await feed.waitForDataLoad('item'); + } + } + } + else { + var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID); + if (!library.getDataLoaded('item')) { + Zotero.debug("Waiting for items to load for library " + library.libraryID); + ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading')); + await library.waitForDataLoad('item'); } } } - else { - var library = Zotero.Libraries.get(collectionTreeRow.ref.libraryID); - if (!library.getDataLoaded('item')) { - Zotero.debug("Waiting for items to load for library " + library.libraryID); - ZoteroPane_Local.setItemsPaneMessage(Zotero.getString('pane.items.loading')); - await library.waitForDataLoad('item'); - } - } - await this.itemsView.changeCollectionTreeRow(collectionTreeRow); + await this.itemsView.changeCollectionTreeRows(collectionTreeRows); - Zotero.Prefs.set('lastViewedFolder', collectionTreeRow.id); + Zotero.Prefs.set('lastViewedFolder', collectionTreeRows[0].id); }); /** - * @param {Zotero.CollectionTreeRow} [collectionTreeRow] - During collection selection, - * the newly selected row, which isn't in the items view yet + * @param {Zotero.CollectionTreeRow[]} [collectionTreeRows] - During collection selection, + * the newly selected rows, which aren't in the items view yet */ - this._refreshAdvancedSearchPane = function (collectionTreeRow) { + this._refreshAdvancedSearchPane = function (collectionTreeRows) { let deck = document.getElementById('zotero-advanced-search-pane-deck'); deck.pane.refresh(); @@ -1838,12 +1888,14 @@ var ZoteroPane = new function () { let search = deck.state === 'closed' || deck.selectedSearchType !== 'temporary' || !deck.pane.active ? null : deck.pane.search; - if (collectionTreeRow) { - collectionTreeRow.setAdvancedSearch(search); + if (collectionTreeRows) { + for (let collectionTreeRow of collectionTreeRows) { + collectionTreeRow.setAdvancedSearch(search); + } return undefined; } - // Apply via the items view, whose row can be a different object from the - // collection tree's current row + // Apply via the items view, whose rows can be different objects from the + // collection tree's current rows return this.itemsView.setFilter('advanced-search', search); }; @@ -1948,7 +2000,7 @@ var ZoteroPane = new function () { /** * Enable or disable toolbar icons, menu options, and commands as necessary */ - this._updateEnabledActionsForRow = function (collectionTreeRow) { + this._updateEnabledActionsForCollectionTreeRows = function (collectionTreeRows) { const disableIfNoEdit = [ "menu_newItem", "cmd_zotero_addByIdentifier", @@ -1972,6 +2024,11 @@ var ZoteroPane = new function () { "zotero-tb-attachment-add", "zotero-tb-note-add", ]; + // Disable item/collection creation when selected rows span multiple libraries + let multipleLibraries = new Set( + collectionTreeRows.map(o => (o.isFeeds() ? -1 : o.ref.libraryID)) + ).size > 1; + for (let i = 0; i < disableIfNoEdit.length; i++) { let command = disableIfNoEdit[i]; let el = document.getElementById(command); @@ -1979,8 +2036,8 @@ var ZoteroPane = new function () { // If a trash is selected, new collection depends on the // editability of the library - if (collectionTreeRow.isTrash() && command == 'cmd_zotero_newCollection') { - var overrideEditable = Zotero.Libraries.get(collectionTreeRow.ref.libraryID).editable; + if (collectionTreeRows[0].isTrash() && command == 'cmd_zotero_newCollection') { + var overrideEditable = Zotero.Libraries.get(collectionTreeRows[0].ref.libraryID).editable; } else { var overrideEditable = false; @@ -1988,11 +2045,12 @@ var ZoteroPane = new function () { // Don't allow normal buttons in My Publications, because things need to // be dragged and go through the wizard - let forceDisable = collectionTreeRow.isPublications() + let forceDisable = collectionTreeRows[0].isPublications() && command != 'cmd_zotero_newCollection' && command != 'zotero-tb-note-add'; - if ((collectionTreeRow.editable || overrideEditable) && !forceDisable) { + if ((collectionTreeRows.every(o => o.editable) || overrideEditable) + && !forceDisable && !multipleLibraries) { if(el.hasAttribute("disabled")) el.removeAttribute("disabled"); } else { el.setAttribute("disabled", "true"); @@ -2002,11 +2060,26 @@ var ZoteroPane = new function () { this.getCollectionTreeRow = function () { + Zotero.debug("ZoteroPane.getCollectionTreeRow() is deprecated -- use ZoteroPane.getCollectionTreeRows()"); return this.collectionsView && this.collectionsView.selection.count && this.collectionsView.getRow(this.collectionsView.selection.focused); } + /** + * return {CollectionTreeRow[]} + */ + this.getCollectionTreeRows = function () { + if (!this.collectionsView) { + return []; + } + // selection.selected is in click order -- return rows in collections-list order + return [...this.collectionsView.selection.selected] + .sort((a, b) => a - b) + .map(index => this.collectionsView.getRow(index)); + } + + /** * @return {Promise} - Promise that resolves to true if an item was selected, * or false if not (used for tests, though there could possibly @@ -2018,9 +2091,9 @@ var ZoteroPane = new function () { Zotero.debug("Items view not available in itemSelected", 2); return false; } - let collectionTreeRow = this.getCollectionTreeRow(); + let collectionTreeRows = this.getCollectionTreeRows(); // I don't think this happens in normal usage, but it can happen during tests - if (!collectionTreeRow) { + if (!collectionTreeRows.length) { return false; } @@ -2029,7 +2102,7 @@ var ZoteroPane = new function () { // Display buttons at top of item pane depending on context. This needs to run even if the // selection hasn't changed, because the selected items might have been modified. this.itemPane.data = selectedItems; - this.itemPane.collectionTreeRow = collectionTreeRow; + this.itemPane.collectionTreeRows = collectionTreeRows; this.itemPane.itemsView = this.itemsView; this.itemPane.editable = this.collectionsView.editable; this.itemPane.updateItemPaneButtons(selectedItems); @@ -2199,9 +2272,15 @@ var ZoteroPane = new function () { await Zotero.DB.executeTransaction(async function () { newItem = item.clone(); - // If in a collection, add new item to it - if (self.getCollectionTreeRow().isCollection() && newItem.isTopLevelItem()) { - newItem.setCollections([self.getCollectionTreeRow().ref.id]); + // If in collections, add new item to all selected collections + // that the original item belongs to + if (newItem.isTopLevelItem()) { + let collectionIDs = self.getCollectionTreeRows() + .filter(r => r.isCollection() && item.inCollection(r.ref.id)) + .map(r => r.ref.id); + if (collectionIDs.length) { + newItem.setCollections(collectionIDs); + } } await newItem.save(); if (item.isNote() && Zotero.Libraries.get(newItem.libraryID).filesEditable) { @@ -2301,8 +2380,8 @@ var ZoteroPane = new function () { * @return {Boolean} */ this.canDeleteSelectedItems = function () { - let collectionTreeRow = this.getCollectionTreeRow(); - if (collectionTreeRow.isTrash()) { + let collectionTreeRows = this.getCollectionTreeRows(); + if (collectionTreeRows[0].isTrash()) { for (let index of this.itemsView.selection.selected) { while (index != -1 && !this.itemsView.getRow(index).ref.deleted) { index = this.itemsView.getParentIndex(index); @@ -2312,7 +2391,7 @@ var ZoteroPane = new function () { } } } - else if (collectionTreeRow.isShare()) { + else if (collectionTreeRows[0].isShare()) { return false; } // If multiple items are selected and only some are annotations, disallow delete unless we @@ -2320,7 +2399,7 @@ var ZoteroPane = new function () { let selected = this.itemsView.getSelectedItems(); if (!selected.every(item => item.isAnnotation()) && selected.some(item => item.isAnnotation())) { - return collectionTreeRow.isTrash(); + return collectionTreeRows[0].isTrash(); } return true; }; @@ -2342,9 +2421,9 @@ var ZoteroPane = new function () { if (!this.itemsView || !this.itemsView.selection.count) { return; } - var collectionTreeRow = this.getCollectionTreeRow(); + var collectionTreeRows = this.getCollectionTreeRows(); - if (!collectionTreeRow.isTrash() && !collectionTreeRow.isBucket() && !this.canEdit()) { + if (!collectionTreeRows[0].isTrash() && !collectionTreeRows[0].isBucket() && !this.canEdit()) { this.displayCannotEditLibraryMessage(); return; } @@ -2376,7 +2455,7 @@ var ZoteroPane = new function () { if (this.itemsView.getSelectedItems().every(item => item.isAnnotation())) { prompt = toDelete; } - else if (collectionTreeRow.isPublications()) { + else if (collectionTreeRows[0].isPublications()) { let toRemoveFromPublications = { title: Zotero.getString('pane.items.removeFromPublications.title'), text: Zotero.getString( @@ -2385,18 +2464,18 @@ var ZoteroPane = new function () { }; prompt = force ? toTrash : toRemoveFromPublications; } - else if (collectionTreeRow.isRecentlyRead()) { + else if (collectionTreeRows[0].isRecentlyRead()) { prompt = force ? toTrash : toRemove; } - else if (collectionTreeRow.isLibrary(true) - || collectionTreeRow.isSearch() - || collectionTreeRow.isUnfiled() - || collectionTreeRow.isRetracted() - || collectionTreeRow.isDuplicates()) { + else if (collectionTreeRows[0].isLibrary(true) + || collectionTreeRows[0].isSearch() + || collectionTreeRows[0].isUnfiled() + || collectionTreeRows[0].isRetracted() + || collectionTreeRows[0].isDuplicates()) { // In library, don't prompt if meta key was pressed prompt = (force && !fromMenu) ? false : toTrash; } - else if (collectionTreeRow.isCollection()) { + else if (collectionTreeRows[0].isCollection()) { if (force) { prompt = toTrash; } @@ -2410,9 +2489,14 @@ var ZoteroPane = new function () { // descendant collections (even if also in the selected collection), // prompt to remove from all if (Zotero.Prefs.get('recursiveCollections')) { - let descendants = collectionTreeRow.ref.getDescendents(false, 'collection'); - let inSubcollection = descendants - .some(({ id }) => this.itemsView.getSelectedItems() + // Removal recurses into the descendants of every selected collection + // (see CollectionViewItemTree), so check all of them when deciding + // whether to show the recursive-removal prompt + let descendantIDs = collectionTreeRows + .filter(row => row.isCollection()) + .flatMap(row => row.ref.getDescendents(false, 'collection').map(({ id }) => id)); + let inSubcollection = descendantIDs + .some(id => this.itemsView.getSelectedItems() .some(item => item.inCollection(id))); if (inSubcollection) { var prompt = { @@ -2431,7 +2515,7 @@ var ZoteroPane = new function () { } } } - else if (collectionTreeRow.isTrash() || collectionTreeRow.isBucket()) { + else if (collectionTreeRows[0].isTrash() || collectionTreeRows[0].isBucket()) { prompt = toDelete; } @@ -2480,84 +2564,84 @@ var ZoteroPane = new function () { this.deleteSelectedCollection = function (deleteItems) { - var collectionTreeRow = this.getCollectionTreeRow(); + var collectionTreeRows = this.getCollectionTreeRows(); + if (!collectionTreeRows.length) { + return; + } - // Don't allow deleting libraries or My Publications - if (collectionTreeRow.isLibrary(true) && !collectionTreeRow.isFeed()) { + // Don't allow deleting libraries + if (collectionTreeRows.some(o => o.isLibrary(true)) && !collectionTreeRows.every(o => o.isFeed())) { return; } // Remove virtual duplicates collection - if (collectionTreeRow.isDuplicates()) { - this.setVirtual(collectionTreeRow.ref.libraryID, 'duplicates', false); + if (collectionTreeRows[0].isDuplicates()) { + this.setVirtual(collectionTreeRows[0].ref.libraryID, 'duplicates', false); return; } // Remove virtual unfiled collection - else if (collectionTreeRow.isUnfiled()) { - this.setVirtual(collectionTreeRow.ref.libraryID, 'unfiled', false); + else if (collectionTreeRows[0].isUnfiled()) { + this.setVirtual(collectionTreeRows[0].ref.libraryID, 'unfiled', false); return; } // Remove virtual recently read collection - else if (collectionTreeRow.isRecentlyRead()) { - this.setVirtual(collectionTreeRow.ref.libraryID, 'recentlyRead', false); + else if (collectionTreeRows[0].isRecentlyRead()) { + this.setVirtual(collectionTreeRows[0].ref.libraryID, 'recentlyRead', false); return; } // Remove virtual retracted collection - else if (collectionTreeRow.isRetracted()) { - this.setVirtual(collectionTreeRow.ref.libraryID, 'retracted', false); + else if (collectionTreeRows[0].isRetracted()) { + this.setVirtual(collectionTreeRows[0].ref.libraryID, 'retracted', false); return; } // Hide "My Publications" - else if (collectionTreeRow.isPublications()) { - this.setVirtual(collectionTreeRow.ref.libraryID, 'publications', false); + else if (collectionTreeRows[0].isPublications()) { + this.setVirtual(collectionTreeRows[0].ref.libraryID, 'publications', false); return; } - if (!this.canEdit() && !collectionTreeRow.isFeedsOrFeed()) { + if (!this.canEdit() && !collectionTreeRows[0].isFeedsOrFeed()) { this.displayCannotEditLibraryMessage(); return; } - var ps = Services.prompt; buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL; - if (this.getCollectionTreeRow()) { - var title, message; - // Work out the required title and message - if (collectionTreeRow.isCollection()) { - if (deleteItems) { - title = Zotero.getString('pane.collections.deleteWithItems.title'); - message = Zotero.getString('pane.collections.deleteWithItems'); - } - else { - title = Zotero.getString('pane.collections.delete.title'); - message = Zotero.getString('pane.collections.delete') - + "\n\n" - + Zotero.getString('pane.collections.delete.keepItems'); - } + var title, message; + // Work out the required title and message + if (collectionTreeRows[0].isCollection()) { + if (deleteItems) { + title = Zotero.getString('pane.collections.deleteWithItems.title'); + message = Zotero.getString('pane.collections.deleteWithItems'); } - else if (collectionTreeRow.isFeed()) { - title = Zotero.getString('pane.feed.deleteWithItems.title'); - message = Zotero.getString('pane.feed.deleteWithItems'); - } - else if (collectionTreeRow.isSearch()) { - title = Zotero.getString('pane.collections.deleteSearch.title'); - message = Zotero.getString('pane.collections.deleteSearch'); + else { + title = Zotero.getString('pane.collections.delete.title'); + message = Zotero.getString('pane.collections.delete') + + "\n\n" + + Zotero.getString('pane.collections.delete.keepItems'); } + } + else if (collectionTreeRows[0].isFeed()) { + title = Zotero.getString('pane.feed.deleteWithItems.title'); + message = Zotero.getString('pane.feed.deleteWithItems'); + } + else if (collectionTreeRows[0].isSearch()) { + title = Zotero.getString('pane.collections.deleteSearch.title'); + message = Zotero.getString('pane.collections.deleteSearch'); + } - // Display prompt - var index = ps.confirmEx( - null, - title, - message, - buttonFlags, - title, - "", "", "", {} - ); - if (index == 0) { - return this.collectionsView.deleteSelection(deleteItems); - } + // Display prompt + var index = ps.confirmEx( + null, + title, + message, + buttonFlags, + title, + "", "", "", {} + ); + if (index == 0) { + return this.collectionsView.deleteSelection(deleteItems); } } @@ -2567,8 +2651,7 @@ var ZoteroPane = new function () { * @return {Boolean} */ this.canRestoreSelectedItems = function () { - let collectionTreeRow = this.getCollectionTreeRow(); - if (!collectionTreeRow.isTrash()) { + if (!this.getCollectionTreeRows()[0].isTrash()) { return false; } @@ -2766,10 +2849,10 @@ var ZoteroPane = new function () { }; this.markFeedRead = async function () { - var row = this.getCollectionTreeRow(); - if (!row) return; + var rows = this.getCollectionTreeRows(); + if (!rows.length) return; - let feeds = row.isFeeds() ? Zotero.Feeds.getAll() : [row.ref]; + let feeds = rows.some(row => row.isFeeds()) ? Zotero.Feeds.getAll() : rows.map(row => row.ref); for (let feed of feeds) { let feedItemIDs = await Zotero.FeedItems.getAll(feed.libraryID, true, false, true); await Zotero.FeedItems.toggleReadByID(feedItemIDs, true); @@ -2778,8 +2861,9 @@ var ZoteroPane = new function () { this.editSelectedFeed = async function () { - var row = this.getCollectionTreeRow(); - if (!row) return; + var rows = this.getCollectionTreeRows(); + if (!rows.length) return; + var row = rows[0]; let feed = row.ref; let data = { @@ -3219,8 +3303,14 @@ var ZoteroPane = new function () { } - function getSelectedCollection(asID) { - return this.collectionsView.getSelectedCollection(asID); + this.getSelectedCollection = function (asID) { + Zotero.debug("ZoteroPane.getSelectedCollection() is deprecated -- use getSelectedCollections()"); + return this.getSelectedCollections(asID)[0]; + } + + + this.getSelectedCollections = function (asID) { + return this.collectionsView.getSelectedCollections(asID); } @@ -3294,6 +3384,23 @@ var ZoteroPane = new function () { return this.getSelectedItems(asIDs); } }; + + + /** + * Returns all items in the selected collection tree rows, ignoring quicksearch, + * tag, and advanced search filters + * + * @return {Promise} + */ + this.getUnfilteredItems = async function () { + var itemSet = new Set(); + for (let row of this.getCollectionTreeRows()) { + for (let item of await row.getItems({ unfiltered: true })) { + itemSet.add(item); + } + } + return [...itemSet]; + }; function getSortField() { @@ -3507,17 +3614,19 @@ var ZoteroPane = new function () { var libraryID = this.getSelectedLibraryID(); var options = _collectionContextMenuOptions; - var collectionTreeRow = this.getCollectionTreeRow(); + var collectionTreeRows = this.getCollectionTreeRows(); // This can happen if selection is changing during delayed second call below - if (!collectionTreeRow) { + if (!collectionTreeRows.length) { return; } + let libraryIDs = new Set(collectionTreeRows.map(o => o.ref.libraryID)); + let multipleLibraries = libraryIDs.size > 1; // If the items view isn't initialized, this was a right-click on a different collection // and the new collection's items are still loading, so continue menu after loading is // done. This causes some menu items (e.g., export/createBib/loadReport) to appear gray // in the menu at first and then turn black once there are items - if (!collectionTreeRow.isHeader() && !this.itemsView.initialized) { + if (!collectionTreeRows[0].isHeader() && !this.itemsView.initialized) { await this.itemsView.waitForLoad(); } @@ -3546,7 +3655,7 @@ var ZoteroPane = new function () { var show = [], disable = []; let useHideOrDelete = "delete"; - if (collectionTreeRow.isCollection()) { + if (collectionTreeRows[0].isCollection()) { show = [ 'newSubcollection', 'sep2', @@ -3564,8 +3673,8 @@ var ZoteroPane = new function () { if (!this.itemsView.rowCount) { disable = ['createBibCollection', 'loadReport']; - // If no items in subcollections either, disable export - if (!(await collectionTreeRow.ref.getDescendents(false, 'item', false).length)) { + // If no items in any of the collections' subcollections either, disable export + if (!(await Promise.all(collectionTreeRows.map(o => o.ref.getDescendents(false, 'item', false)))).flat().length) { disable.push('exportCollection'); } } @@ -3580,17 +3689,32 @@ var ZoteroPane = new function () { m.exportCollection.setAttribute('label', Zotero.getString('pane.collections.menu.export.collection')); m.createBibCollection.setAttribute('label', Zotero.getString('pane.collections.menu.createBib.collection')); m.loadReport.setAttribute('label', Zotero.getString('pane.collections.menu.generateReport.collection')); + + // New Subcollection and Rename act on a single collection, so hide them + // when more than one row is selected + if (collectionTreeRows.length > 1) { + show = show.filter(id => id != 'newSubcollection' && id != 'editSelectedCollection'); + } + + // Hide move/copy when collections span multiple libraries, and disable + // the report (its URL is scoped to a single library) + if (multipleLibraries) { + show = show.filter(id => id != 'moveCollection' && id != 'copyCollection'); + disable.push('loadReport'); + } } - else if (collectionTreeRow.isFeed()) { + else if (collectionTreeRows[0].isFeed()) { show = [ 'refreshFeed', 'sep2', 'markReadFeed', - 'editSelectedFeed', - 'deleteCollectionAndItems' + 'deleteCollectionAndItems', ]; + if (collectionTreeRows.length == 1) { + show.push('editSelectedFeed'); + } - if (collectionTreeRow.ref.unreadCount == 0) { + if (collectionTreeRows.every(o => o.ref.unreadCount == 0)) { disable = ['markReadFeed']; } @@ -3599,7 +3723,7 @@ var ZoteroPane = new function () { m.markReadFeed.setAttribute('label', Zotero.getString('pane.collections.menu.markAsRead.feed')); m.deleteCollectionAndItems.setAttribute('label', Zotero.getString('pane.collections.menu.delete.feedAndItems')); } - else if (collectionTreeRow.isFeeds()) { + else if (collectionTreeRows.some(row => row.isFeeds())) { show = [ 'refreshFeed', 'sep2', @@ -3607,7 +3731,7 @@ var ZoteroPane = new function () { 'addFeed', ]; - if (collectionTreeRow.ref.unreadCount == 0) { + if (collectionTreeRows.every(row => row.ref.unreadCount === 0)) { disable = ['markReadFeed']; } @@ -3615,17 +3739,17 @@ var ZoteroPane = new function () { m.refreshFeed.setAttribute('label', Zotero.getString('pane.collections.menu.refresh.allFeeds')); m.markReadFeed.setAttribute('label', Zotero.getString('pane.collections.menu.markAsRead.allFeeds')); } - else if (collectionTreeRow.isSearch()) { + else if (collectionTreeRows[0].isSearch()) { show = [ - 'editSelectedCollection', - 'duplicate', 'deleteCollection', 'sep3', 'exportCollection', 'createBibCollection', 'loadReport' ]; - + if (collectionTreeRows.length == 1) { + show.push('editSelectedCollection', 'duplicate'); + } if (!this.itemsView.rowCount) { disable.push('exportCollection', 'createBibCollection', 'loadReport'); @@ -3641,19 +3765,19 @@ var ZoteroPane = new function () { m.createBibCollection.setAttribute('label', Zotero.getString('pane.collections.menu.createBib.savedSearch')); m.loadReport.setAttribute('label', Zotero.getString('pane.collections.menu.generateReport.savedSearch')); } - else if (collectionTreeRow.isTrash()) { + else if (collectionTreeRows[0].isTrash()) { show = ['emptyTrash']; } - else if (collectionTreeRow.isDuplicates() || collectionTreeRow.isUnfiled() || collectionTreeRow.isRecentlyRead() - || collectionTreeRow.isRetracted()) { + else if (collectionTreeRows[0].isDuplicates() || collectionTreeRows[0].isUnfiled() || collectionTreeRows[0].isRecentlyRead() + || collectionTreeRows[0].isRetracted()) { show = ['deleteCollection']; m.deleteCollection.setAttribute('label', Zotero.getString('general.hide')); useHideOrDelete = "hide"; } - else if (collectionTreeRow.isHeader()) { + else if (collectionTreeRows[0].isHeader()) { } - else if (collectionTreeRow.isPublications()) { + else if (collectionTreeRows[0].isPublications()) { show = ['exportFile', 'deleteCollection']; m.deleteCollection.setAttribute('label', Zotero.getString('general.hide')); useHideOrDelete = "hide"; @@ -3728,11 +3852,11 @@ var ZoteroPane = new function () { // Disable some actions if user doesn't have write access // // Some actions are disabled via their commands in onCollectionSelected() - if (collectionTreeRow.isWithinGroup() - && !collectionTreeRow.editable - && !collectionTreeRow.isDuplicates() - && !collectionTreeRow.isUnfiled() - && !collectionTreeRow.isRetracted()) { + if (collectionTreeRows[0].isWithinGroup() + && collectionTreeRows.every(o => !o.editable) + && !collectionTreeRows[0].isDuplicates() + && !collectionTreeRows[0].isUnfiled() + && !collectionTreeRows[0].isRetracted()) { disable.push( 'newSubcollection', 'editSelectedCollection', @@ -3743,8 +3867,8 @@ var ZoteroPane = new function () { } // If within non-editable group or trash it empty, disable Empty Trash - if (collectionTreeRow.isTrash()) { - if ((collectionTreeRow.isWithinGroup() && !collectionTreeRow.isWithinEditableGroup()) || !this.itemsView.rowCount) { + if (collectionTreeRows[0].isTrash()) { + if ((collectionTreeRows[0].isWithinGroup() && !collectionTreeRows[0].isWithinEditableGroup()) || !this.itemsView.rowCount) { disable.push('emptyTrash'); } } @@ -3768,7 +3892,10 @@ var ZoteroPane = new function () { "main/library/collection", { getContext: () => ({ - collectionTreeRow, + // collectionTreeRow is the primary (first) selected row, kept for + // backward compatibility; collectionTreeRows is the full selection + collectionTreeRow: collectionTreeRows[0], + collectionTreeRows, tabType: "library", tabSubType: undefined, tabID: "zotero-pane", @@ -3828,8 +3955,8 @@ var ZoteroPane = new function () { return; } - var collectionTreeRow = this.getCollectionTreeRow(); - var isTrash = collectionTreeRow.isTrash(); + var collectionTreeRows = this.getCollectionTreeRows(); + var isTrash = collectionTreeRows[0].isTrash(); if (isTrash) { show.add(m.deleteFromLibrary); @@ -3841,11 +3968,11 @@ var ZoteroPane = new function () { disable.add(m.restoreToLibrary); } } - else if (!collectionTreeRow.isFeedsOrFeed()) { + else if (!collectionTreeRows[0].isFeedsOrFeed()) { show.add(m.moveToTrash); } - if(!collectionTreeRow.isFeedsOrFeed()) { + if (!collectionTreeRows[0].isFeedsOrFeed()) { show.add(m.sep4); show.add(m.exportItems); show.add(m.createBib); @@ -3853,7 +3980,13 @@ var ZoteroPane = new function () { } var items = this.getSelectedItems(); - + + // A report URL is scoped to a single library, so disable it when the selected + // items span libraries (possible in a cross-library items list) + if (new Set(items.map(item => item.libraryID)).size > 1) { + disable.add(m.loadReport); + } + if (items.length > 0) { // Multiple items selected if (items.length > 1) { @@ -3864,11 +3997,11 @@ var ZoteroPane = new function () { canIndex = true, canRecognize = true, canUnrecognize = true; - var canMarkRead = collectionTreeRow.isFeedsOrFeed(); + var canMarkRead = collectionTreeRows[0].isFeedsOrFeed(); var markUnread = true; for (let item of items) { - if (canMerge && (!item.isRegularItem() || item.isFeedItem || collectionTreeRow.isDuplicates())) { + if (canMerge && (!item.isRegularItem() || item.isFeedItem || collectionTreeRows[0].isDuplicates())) { canMerge = false; } @@ -3931,9 +4064,9 @@ var ZoteroPane = new function () { } // "Add/Create Note from Annotations" and "Find Available PDFs" - if (collectionTreeRow.filesEditable - && !collectionTreeRow.isDuplicates() - && !collectionTreeRow.isFeedsOrFeed()) { + if (collectionTreeRows[0].filesEditable + && !collectionTreeRows[0].isDuplicates() + && !collectionTreeRows[0].isFeedsOrFeed()) { if (items.some(item => attachmentsWithExtractableAnnotations(item).length) || items.some(item => isAttachmentWithExtractableAnnotations(item)) || items.some(item => item.isAnnotation())) { @@ -3983,7 +4116,7 @@ var ZoteroPane = new function () { // Block certain actions on files if no access and at least one item is a file // attachment - if (!collectionTreeRow.filesEditable) { + if (!collectionTreeRows[0].filesEditable) { for (let item of items) { if (item.isFileAttachment()) { disable.add(m.moveToTrash); @@ -4004,7 +4137,7 @@ var ZoteroPane = new function () { if (!isTrash) { // Show in Library - if (!collectionTreeRow.isLibrary(true)) { + if (!collectionTreeRows.every(o => o.isLibrary(true))) { show.add(m.showInLibrary); show.add(m.sep1); } @@ -4045,7 +4178,7 @@ var ZoteroPane = new function () { if (Zotero.Attachments.canFindFileForItem(item)) { show.add(m.findFile); show.add(m.sep3); - if (!collectionTreeRow.filesEditable) { + if (!collectionTreeRows[0].filesEditable) { disable.add(m.findFile); } } @@ -4090,7 +4223,7 @@ var ZoteroPane = new function () { menu.childNodes[m.toggleRead].setAttribute('label', Zotero.getString('pane.item.markAsRead')); } } - else if (!collectionTreeRow.isPublications()) { + else if (!collectionTreeRows[0].isPublications()) { if (item.itemType == 'book' || item.itemType == 'bookSection') { let toBookMenuItem = menu.childNodes[m.duplicateAndConvert]; toBookMenuItem.setAttribute('label', Zotero.getString('pane.items.menu.duplicateAndConvert.' @@ -4117,7 +4250,7 @@ var ZoteroPane = new function () { }); // Block certain actions on files if no access - if (item.isFileAttachment() && !collectionTreeRow.filesEditable) { + if (item.isFileAttachment() && !collectionTreeRows[0].filesEditable) { [m.moveToTrash, m.createParent] .forEach(function (x) { disable.add(x); @@ -4129,7 +4262,7 @@ var ZoteroPane = new function () { else { // Show in Library - if (!collectionTreeRow.isLibrary()) { + if (!collectionTreeRows.every(o => o.isLibrary(true))) { show.add(m.showInLibrary); show.add(m.sep1); } @@ -4146,6 +4279,7 @@ var ZoteroPane = new function () { ].forEach(x => disable.add(x)); } + // Show "Export Note…" if all notes or attachments var noteExport = items.every(item => item.isNote() || item.isAttachment()); // Disable export if all notes are empty @@ -4167,7 +4301,7 @@ var ZoteroPane = new function () { show.delete(m.createBib); } - if ((!collectionTreeRow.editable || collectionTreeRow.isPublications()) && !collectionTreeRow.isFeedsOrFeed()) { + if ((!collectionTreeRows[0].editable || collectionTreeRows[0].isPublications()) && !collectionTreeRows[0].isFeedsOrFeed()) { for (let i in m) { // Still allow some options for non-editable views switch (i) { @@ -4185,7 +4319,7 @@ var ZoteroPane = new function () { continue; } } - else if (collectionTreeRow.isPublications()) { + else if (collectionTreeRows[0].isPublications()) { switch (i) { case 'addNote': case 'removeItems': @@ -4198,8 +4332,8 @@ var ZoteroPane = new function () { } // Add to collection - if (!collectionTreeRow.isFeedsOrFeed() - && collectionTreeRow.editable + if (!collectionTreeRows[0].isFeedsOrFeed() + && collectionTreeRows[0].editable && Zotero.Items.keepTopLevel(items).every(item => item.isTopLevelItem()) ) { menu.childNodes[m.addToCollection].setAttribute('label', Zotero.getString('pane.items.menu.addToCollection')); @@ -4208,15 +4342,15 @@ var ZoteroPane = new function () { // Remove from collection / Recently Read menu.childNodes[m.removeItems].removeAttribute('data-l10n-id'); - if (collectionTreeRow.isCollection() && items.every(item => item.isTopLevelItem())) { + if (collectionTreeRows[0].isCollection() && items.every(item => item.isTopLevelItem())) { menu.childNodes[m.removeItems].setAttribute('label', Zotero.getString('pane.items.menu.remove' + multiple)); show.add(m.removeItems); } - else if (collectionTreeRow.isPublications()) { + else if (collectionTreeRows[0].isPublications()) { menu.childNodes[m.removeItems].setAttribute('label', Zotero.getString('pane.items.menu.removeFromPublications' + multiple)); show.add(m.removeItems); } - else if (collectionTreeRow.isRecentlyRead()) { + else if (collectionTreeRows[0].isRecentlyRead()) { // Disable for child items that aren't attachments with lastRead let canRemove = items.every((item) => { if (item.isTopLevelItem()) return true; @@ -4231,7 +4365,7 @@ var ZoteroPane = new function () { } // Show in library - if (collectionTreeRow.isFeeds()) { + if (collectionTreeRows[0].isFeeds()) { menu.childNodes[m.showInLibrary].setAttribute('label', Zotero.getString('pane.items.menu.showInFeed')); } else { @@ -4301,7 +4435,10 @@ var ZoteroPane = new function () { "main/library/item", { getContext: () => ({ - collectionTreeRow, + // collectionTreeRow is the primary (first) selected row, kept for + // backward compatibility; collectionTreeRows is the full selection + collectionTreeRow: collectionTreeRows[0], + collectionTreeRows, items, tabType: "library", tabSubType: undefined, @@ -4319,7 +4456,7 @@ var ZoteroPane = new function () { let popup = event.target; popup.replaceChildren(); - let selected = this.getSelectedCollection(); + let selected = this.getSelectedCollections(); // Add current library at the top to be able to move collections into it let library = Zotero.Libraries.get(ZoteroPane.getSelectedLibraryID()); @@ -4333,8 +4470,8 @@ var ZoteroPane = new function () { event.stopPropagation(); } }); - // Disable for already top-level collections - libraryMenuItem.disabled = !selected.parentID; + // Disable if all collections are already top-level collections + libraryMenuItem.disabled = selected.every(o => !o.parentID); libraryMenuItem.classList.add('menuitem-iconic'); popup.appendChild(libraryMenuItem); popup.appendChild(document.createXULElement("menuseparator")); @@ -4355,9 +4492,11 @@ var ZoteroPane = new function () { (target) => { // can't move collection into itself, its parent or its children - return selected == target - || selected.parentKey == target.key - || selected.hasDescendent('collection', target.id); + return selected.some((c) => { + return c == target + || c.parentKey == target.key + || c.hasDescendent('collection', target.id); + }); } ); popup.append(menuItem); @@ -4634,6 +4773,13 @@ var ZoteroPane = new function () { * popup mode (where a note isn't created immediately), or false if library isn't editable */ this.newNote = async function (popup, parentKey, text, citeURI) { + // Shouldn't be reachable with multiple libraries selected (toolbar is disabled), + // but just in case + let rows = this.getCollectionTreeRows(); + if (new Set(rows.map(r => r.ref.libraryID)).size > 1) { + return; + } + if (!this.canEdit()) { this.displayCannotEditLibraryMessage(); return false; @@ -4641,13 +4787,14 @@ var ZoteroPane = new function () { if (popup) { // TODO: _text_ - var c = this.getSelectedCollection(); - if (c) { - this.openNote(null, { col: c.id, parentKey }); - } - else { - this.openNote(null, { parentKey }); - } + let collections = this.getSelectedCollections(); + this.openNote( + null, + { + collections: collections.length ? collections : undefined, + parentKey + } + ); return null; } @@ -4668,8 +4815,10 @@ var ZoteroPane = new function () { if (parentKey) { item.parentKey = parentKey; } - else if (this.getCollectionTreeRow().isCollection()) { - item.addToCollection(this.getCollectionTreeRow().ref.id); + else if (this.getCollectionTreeRows().every(row => row.isCollection())) { + for (let row of this.getCollectionTreeRows()) { + item.addToCollection(row.ref.id); + } } var itemID = await item.saveTx({ notifierData: { @@ -4739,21 +4888,22 @@ var ZoteroPane = new function () { this.openNote = function (itemID, options = { - col: undefined, + collections: undefined, parentKey: undefined, openInWindow: undefined }) { let { - col, + collections, parentKey, openInWindow, } = options; if (openInWindow === undefined) { openInWindow = Zotero.Prefs.get('openNoteInNewWindow'); } - + return Zotero.Notes.open(itemID, undefined, { openInWindow, + collections, }); }; @@ -4805,7 +4955,13 @@ var ZoteroPane = new function () { this.addAttachmentFromDialog = async function (link, parentItemID, files = null) { var libraryID; if (Zotero_Tabs.selectedType === 'library') { - let collectionTreeRow = this.getCollectionTreeRow(); + // Shouldn't be reachable with multiple libraries selected (toolbar is disabled), + // but just in case + let rows = this.getCollectionTreeRows(); + if (new Set(rows.map(r => r.ref.libraryID)).size > 1) { + return null; + } + let collectionTreeRow = rows[0]; if (link && collectionTreeRow.isPublications()) { Zotero.alert( null, @@ -4847,9 +5003,9 @@ var ZoteroPane = new function () { } var addedItems = []; var notifierQueue = new Zotero.Notifier.Queue(); - var collection; + var collections; var fileBaseName; - + try { if (parentItemID) { // If only one item is being added, automatic renaming is enabled, and the parent item @@ -4864,9 +5020,9 @@ var ZoteroPane = new function () { } } } - // If not adding to an item, add to the current collection + // If not adding to an item, add to all selected collections else { - collection = this.getSelectedCollection(true); + collections = this.getSelectedCollections(true); } // If we have more than one file, we only want to call setAutoAttachmentTitle() @@ -4900,7 +5056,7 @@ var ZoteroPane = new function () { file, title: delaySetAutoAttachmentTitle ? '' : undefined, parentItemID, - collections: collection ? [collection] : undefined, + collections: collections && collections.length ? collections : undefined, saveOptions: { notifierQueue }, @@ -4919,7 +5075,7 @@ var ZoteroPane = new function () { fileBaseName, title: delaySetAutoAttachmentTitle ? '' : undefined, parentItemID, - collections: collection ? [collection] : undefined, + collections: collections && collections.length ? collections : undefined, saveOptions: { notifierQueue }, @@ -6859,6 +7015,7 @@ var ZoteroPane = new function () { Zotero.Prefs.set('autoRenameFiles.bannerShown', true); }; + /** * Sets the layout to either a three-vertical-pane layout and a layout where itemsPane is above itemPane */ diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index 9f4d1cf0c2..498225b2bc 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -1030,13 +1030,13 @@ - - - [one] { $count } Note diff --git a/scss/components/_collection-tree.scss b/scss/components/_collection-tree.scss index 46b4afe395..6836afdeec 100644 --- a/scss/components/_collection-tree.scss +++ b/scss/components/_collection-tree.scss @@ -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) { diff --git a/test/tests/advancedSearchTest.js b/test/tests/advancedSearchTest.js index e1cb58be3a..952f3bed9e 100644 --- a/test/tests/advancedSearchTest.js +++ b/test/tests/advancedSearchTest.js @@ -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 () { diff --git a/test/tests/citationDialogTest.js b/test/tests/citationDialogTest.js index d3e9dd605e..1f3f3cac7f 100644 --- a/test/tests/citationDialogTest.js +++ b/test/tests/citationDialogTest.js @@ -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 () { diff --git a/test/tests/collectionTreeTest.js b/test/tests/collectionTreeTest.js index 6a31e182e9..8248e6bc6a 100644 --- a/test/tests/collectionTreeTest.js +++ b/test/tests/collectionTreeTest.js @@ -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', diff --git a/test/tests/collectionViewItemTreeTest.js b/test/tests/collectionViewItemTreeTest.js index 7b8132f6a8..6db0d466c0 100644 --- a/test/tests/collectionViewItemTreeTest.js +++ b/test/tests/collectionViewItemTreeTest.js @@ -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 { diff --git a/test/tests/itemPaneTest.js b/test/tests/itemPaneTest.js index ce9afb8a78..ea7bed0607 100644 --- a/test/tests/itemPaneTest.js +++ b/test/tests/itemPaneTest.js @@ -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 () { diff --git a/test/tests/pluginAPITest.js b/test/tests/pluginAPITest.js index cf282aba74..04bf2651f9 100644 --- a/test/tests/pluginAPITest.js +++ b/test/tests/pluginAPITest.js @@ -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": { diff --git a/test/tests/server_connectorTest.js b/test/tests/server_connectorTest.js index 5759c4b22a..7fe704082e 100644 --- a/test/tests/server_connectorTest.js +++ b/test/tests/server_connectorTest.js @@ -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); diff --git a/test/tests/tagSelectorTest.js b/test/tests/tagSelectorTest.js index 965de0c9e5..55c8e93cf0 100644 --- a/test/tests/tagSelectorTest.js +++ b/test/tests/tagSelectorTest.js @@ -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(). diff --git a/test/tests/zoteroPaneTest.js b/test/tests/zoteroPaneTest.js index fba6e7dcff..5755e4e85d 100644 --- a/test/tests/zoteroPaneTest.js +++ b/test/tests/zoteroPaneTest.js @@ -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"); + }); + }); + }); })