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"); + }); + }); + }); })