diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 90e2f1cad6..48f1e2bc0e 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -1771,27 +1771,9 @@ var CollectionTree = class CollectionTree extends LibraryTree { return true; } else if (dataType == 'zotero/collection') { - if (!treeRow.isLibrary(true) && !treeRow.isCollection()) { - return false; - } - let draggedCollectionID = data[0]; let draggedCollection = Zotero.Collections.get(draggedCollectionID); - - // Dragging within same library - if (treeRow.ref.libraryID == draggedCollection.libraryID) { - // Collections cannot be dropped on themselves - if (draggedCollectionID == treeRow.ref.id) { - return false; - } - - // Nor in their children - if (draggedCollection.hasDescendent('collection', treeRow.ref.id)) { - return false; - } - } - - return true; + return draggedCollection.canMoveToTarget(treeRow.ref, { debug: true }); } } return false; @@ -1863,30 +1845,12 @@ var CollectionTree = class CollectionTree extends LibraryTree { } } else if (dataType == 'zotero/collection') { + // Can drop collections into trash to delete it + if (treeRow.isTrash()) return true; let draggedCollectionID = data[0]; let draggedCollection = Zotero.Collections.get(draggedCollectionID); - - // Dragging a collection to a different library - if (treeRow.ref.libraryID != draggedCollection.libraryID) { - // Disallow if linked collection already exists - if (await draggedCollection.getLinkedCollection(treeRow.ref.libraryID, true)) { - Zotero.debug("Linked collection already exists in library"); - return false; - } - - let descendents = draggedCollection.getDescendents(false, 'collection'); - for (let descendent of descendents) { - descendent = Zotero.Collections.get(descendent.id); - // Disallow if linked collection already exists for any subcollections - // - // If this is allowed in the future for the root collection, - // need to allow drag only to root - if (await descendent.getLinkedCollection(treeRow.ref.libraryID, true)) { - Zotero.debug("Linked subcollection already exists in library"); - return false; - } - } - } + let canMove = await draggedCollection.canMoveToTargetAsync(treeRow.ref, { debug: true }); + return canMove; } } return true; @@ -2719,6 +2683,36 @@ var CollectionTree = class CollectionTree extends LibraryTree { } } + clearCollectionSearch(searchFieldSelector) { + let collectionsSearchField = document.querySelector(searchFieldSelector); + // If empty filter - just focus the collectionTree + if (collectionsSearchField.value.length == 0) { + return document.getElementById("collection-tree"); + } + // Clear the search field and focus collection tree + if (collectionsSearchField.value.length) { + collectionsSearchField.value = ''; + this.setFilter("", true); + } + return null; + } + + focusCollectionTree(searchFieldSelector) { + let collectionsSearchField = document.querySelector(searchFieldSelector); + // Prevent Enter/Tab pressed before the filtering ran from doing anything + if (!this.filterEquals(collectionsSearchField.value)) { + return null; + } + // If the current row passes the filter, make sure it is visible and focus collectionTree + if (this.focusedRowMatchesFilter()) { + this.ensureRowIsVisible(this.selection.focused); + return document.getElementById('collection-tree'); + } + // Otherwise, focus the first row passing the filter + this.focusFirstMatchingRow(false); + return null; + } + /** * Creates an extra hidden row to keep focus on it when a currently focused row does not match the filter. @@ -2756,17 +2750,6 @@ var CollectionTree = class CollectionTree extends LibraryTree { return this._filter === ""; } - clearFilter() { - // Clear the search field - if (collectionsSearchField.value.length) { - collectionsSearchField.value = ''; - ZoteroPane.handleCollectionSearchInput(); - return null; - } - // If the search field is empty, focus the collection tree - return document.getElementById('collection-tree'); - } - /** * Select and focus the first row matching the collection filter. If it is a child of a collapsed * container(s), the container(s) on the way will be toggled open. @@ -2972,7 +2955,8 @@ var CollectionTree = class CollectionTree extends LibraryTree { && this._virtualCollectionLibraries.duplicates[libraryID] !== false; var showUnfiled = this.props.hideSources.indexOf('unfiled') == -1 && this._virtualCollectionLibraries.unfiled?.[libraryID] !== false; - var showRecentlyRead = this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false; + var showRecentlyRead = this.props.hideSources.indexOf('recentlyRead') == -1 + && this._virtualCollectionLibraries.recentlyRead?.[libraryID] !== false; var showRetracted = this.props.hideSources.indexOf('retracted') == -1 && this._virtualCollectionLibraries.retracted?.[libraryID] !== false && Zotero.Retractions.libraryHasRetractedItems(libraryID); @@ -2980,6 +2964,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { && this._virtualCollectionLibraries.publications?.[libraryID] !== false && libraryID == Zotero.Libraries.userLibraryID; var showTrash = this.props.hideSources.indexOf('trash') == -1; + var showSavedSearches = this.props.hideSources.indexOf('searches') == -1; } else { var savedSearches = []; @@ -2989,6 +2974,7 @@ var CollectionTree = class CollectionTree extends LibraryTree { var showRetracted = false; var showPublications = false; var showTrash = false; + var showSavedSearches = false; } // If not a manual open and either the library is set to be collapsed or this is a collection that isn't explicitly opened, @@ -3048,14 +3034,16 @@ var CollectionTree = class CollectionTree extends LibraryTree { } // Add searches - for (var i = 0, len = savedSearches.length; i < len; i++) { - // Skip searches in trash - if (savedSearches[i].deleted) continue; - // Skip searches not matching the filter - if (!this._includedInTree(savedSearches[i])) continue; - rows.splice(row + 1 + newRows, 0, - new Zotero.CollectionTreeRow(this, 'search', savedSearches[i], level + 1)); - newRows++; + if (showSavedSearches) { + for (let i = 0; i < savedSearches.length; i++) { + // Skip searches in trash + if (savedSearches[i].deleted) continue; + // Skip searches not matching the filter + if (!this._includedInTree(savedSearches[i])) continue; + rows.splice(row + 1 + newRows, 0, + new Zotero.CollectionTreeRow(this, 'search', savedSearches[i], level + 1)); + newRows++; + } } if (showPublications && this._isFilterEmpty()) { diff --git a/chrome/content/zotero/newCollectionDialog.js b/chrome/content/zotero/newCollectionDialog.js index d956642b82..1887269a00 100644 --- a/chrome/content/zotero/newCollectionDialog.js +++ b/chrome/content/zotero/newCollectionDialog.js @@ -23,6 +23,8 @@ ***** END LICENSE BLOCK ***** */ +import CollectionTree from 'zotero/collectionTree'; + var Zotero_New_Collection_Dialog = { _handleLoad() { let io = window.arguments[0]; @@ -34,7 +36,20 @@ var Zotero_New_Collection_Dialog = { this._libraryID = io.libraryID; this._parentCollectionID = io.parentCollectionID; - this._updateMenu(); + + // depending on how many collections there are, display the dropdown + // or the entire collection tree + if (Zotero.Collections.canListCollectionsInMenu(this._libraryID)) { + document.getElementById("create-in").hidden = false; + this._updateMenu(); + } + else { + document.getElementById("zotero-collections-tree-container").hidden = false; + setTimeout(() => { + window.moveToAlertPosition(); + this._initCollectionTree(); + }); + } }, _handleAccept() { @@ -45,6 +60,26 @@ var Zotero_New_Collection_Dialog = { }; }, + async _initCollectionTree() { + this._collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), { + onSelectionChange: () => { + let selectedCollection = this._collectionsView.getSelectedCollection(); + this._parentCollectionID = selectedCollection ? selectedCollection.id : null; + }, + filterLibraryIDs: [this._libraryID], + hideSources: ['duplicates', 'trash', 'feeds', 'unfiled', 'retracted', 'publications', 'searches', 'recentlyRead'] + }); + + await this._collectionsView.makeVisible(); + + if (this._parentCollectionID) { + await this._collectionsView.selectByID('C' + this._parentCollectionID); + } + else { + await this._collectionsView.selectByID('L' + this._libraryID); + } + }, + _updateMenu() { let createInField = document.querySelector('#create-in'); let menupopup = createInField.firstElementChild; diff --git a/chrome/content/zotero/newCollectionDialog.xhtml b/chrome/content/zotero/newCollectionDialog.xhtml index 5d62da35a3..a38358cae8 100644 --- a/chrome/content/zotero/newCollectionDialog.xhtml +++ b/chrome/content/zotero/newCollectionDialog.xhtml @@ -65,9 +65,12 @@ diff --git a/chrome/content/zotero/selectCollectionDialog.js b/chrome/content/zotero/selectCollectionDialog.js new file mode 100644 index 0000000000..b2c52f2919 --- /dev/null +++ b/chrome/content/zotero/selectCollectionDialog.js @@ -0,0 +1,120 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2009 Center for History and New Media + George Mason University, Fairfax, Virginia, USA + http://zotero.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +import CollectionTree from 'zotero/collectionTree'; + +var collectionsView; +var loaded; +var io; + +var doLoad = async function () { + // Set font size from pref + var sbc = document.getElementById('zotero-select-collection-container'); + Zotero.UIProperties.registerRoot(sbc); + + io = window.arguments[0]; + if (io.wrappedJSObject) io = io.wrappedJSObject; + + let filterLibraryIDs = io.filterLibraryIDs || false; + collectionsView = await CollectionTree.init(document.getElementById('zotero-collections-tree'), { + onSelectionChange: async () => { + let selectedRow = collectionsView.getRow(collectionsView.selection.focused); + let canSelect = await io.canSelect(selectedRow.ref); + document.getElementById("select-collection-dialog").getButton("accept").disabled = !canSelect; + }, + filterLibraryIDs, + hideSources: ['duplicates', 'trash', 'feeds', 'unfiled', 'retracted', 'publications', 'searches', 'recentlyRead'], + onActivate: () => { + document.querySelector('dialog').acceptDialog(); + } + }); + + await collectionsView.makeVisible(); + + if (io.currentCollectionID) { + await collectionsView.selectByID('C' + io.currentCollectionID); + } + + document.addEventListener('dialogaccept', doAccept); + + // Setup keyboard navigation + let collectionTree = document.getElementById('collection-tree'); + let searchField = document.getElementById('zotero-collections-search'); + + searchField.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + _handleCollectionFilterEscape(event); + } + else if (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) { + _handleCollectionFilterEnter(event); + } + }); + + collectionTree.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + _handleCollectionFilterEscape(event); + } + }); + + // Used in tests + loaded = true; +}; + +function doUnload() +{ + collectionsView.unregister(); + + io.deferred && io.deferred.resolve(); +} + +function handleCollectionSearchInput() { + let collectionsSearchField = document.getElementById("zotero-collections-search"); + collectionsView.setFilter(collectionsSearchField.value); +} + +function doAccept() { + let selected = collectionsView.getRow(collectionsView.selection.focused); + io.dataOut = selected.ref; +} + +function _handleCollectionFilterEnter(event) { + let result = collectionsView.focusCollectionTree("#zotero-collections-search"); + if (result) { + result.focus(); + event.preventDefault(); + event.stopPropagation(); + } +} + +function _handleCollectionFilterEscape(event) { + if (!document.getElementById("zotero-collections-search").value) return; + + let result = collectionsView.clearCollectionSearch("#zotero-collections-search"); + if (result) { + result.focus(); + } + event.preventDefault(); + event.stopPropagation(); +} diff --git a/chrome/content/zotero/selectCollectionDialog.xhtml b/chrome/content/zotero/selectCollectionDialog.xhtml new file mode 100644 index 0000000000..2421c35fb3 --- /dev/null +++ b/chrome/content/zotero/selectCollectionDialog.xhtml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/chrome/content/zotero/xpcom/data/collection.js b/chrome/content/zotero/xpcom/data/collection.js index 2c7b4f9f2b..a416e6edd9 100644 --- a/chrome/content/zotero/xpcom/data/collection.js +++ b/chrome/content/zotero/xpcom/data/collection.js @@ -987,3 +987,82 @@ Zotero.Collection.prototype._unregisterChildItem = function (itemID) { this._childItems.delete(itemID); } } + +Zotero.Collection.prototype.canMoveToTarget = function (target, options = {}) { + let logMessage = (message) => { + if (options.debug) { + Zotero.debug(message); + } + }; + if (!(target instanceof Zotero.Library || target instanceof Zotero.Collection)) { + logMessage("Can only add collection to another collection or group"); + return false; + } + let targetLibrary = target instanceof Zotero.Library + ? target + : Zotero.Libraries.get(target.libraryID); + if (!targetLibrary.editable) { + logMessage("Not editable target library."); + return false; + } + if (target instanceof Zotero.Collection) { + if (target.id == this.id) { + logMessage("Cannot add collection to itself"); + return false; + } + if (this.hasDescendent('collection', target.id)) { + logMessage("Cannot add collection to its descendent"); + return false; + } + if (this.parentID == target.id) { + logMessage("Cannot add collection to its direct parent"); + return false; + } + } + + return true; +}; + +Zotero.Collection.prototype.canMoveToTargetAsync = async function (target, options = {}) { + let logMessage = (message) => { + if (options.debug) { + Zotero.debug(message); + } + }; + if (!this.canMoveToTarget(target, options)) return false; + + if (target.libraryID !== this.libraryID) { + let linkedCollection = await this.getLinkedCollection(target.libraryID, true); + if (linkedCollection && !linkedCollection.deleted) { + logMessage(`Linked collection already exists in library ${target.libraryID}`); + return false; + } + let descendents = this.getDescendents(false, 'collection'); + for (let descendent of descendents) { + descendent = Zotero.Collections.get(descendent.id); + // Disallow if linked collection already exists for any subcollections + // + // If this is allowed in the future for the root collection, + // need to allow drag only to root + let linkedSubcollection = await descendent.getLinkedCollection(target.libraryID, true); + if (linkedSubcollection && !linkedSubcollection.deleted) { + logMessage("Linked subcollection already exists in library"); + return false; + } + } + } + return true; +}; + +// Determine which libraries the collection can be copied to +Zotero.Collection.prototype.getLibrariesForCopying = async function () { + let libraries = Zotero.Libraries.getAll().filter(lib => !(lib instanceof Zotero.Feed)); + let librariesWhereOneCanCopy = []; + for (let library of libraries) { + let canCopy = await this.canMoveToTargetAsync(library); + if (canCopy) { + librariesWhereOneCanCopy.push(library); + } + } + return librariesWhereOneCanCopy; +}; diff --git a/chrome/content/zotero/xpcom/data/collections.js b/chrome/content/zotero/xpcom/data/collections.js index e38e29527d..6e679f3e07 100644 --- a/chrome/content/zotero/xpcom/data/collections.js +++ b/chrome/content/zotero/xpcom/data/collections.js @@ -85,6 +85,41 @@ Zotero.Collections = function () { return _getByContainer(null, parentCollectionID, recursive, includeTrashed); } + + /** + * Determine if the user has too many collections in a library to list them all in a menu. + * @param {Integer[]} libraryIDs - ids of libraries whose collections will be counted. + * If not provided, will check all libraries. + * @returns {Boolean} true if the number of collections exceeds the limits set in preferences. + */ + this.canListCollectionsInMenu = function (libraryIDs) { + if (!Array.isArray(libraryIDs)) { + libraryIDs = [libraryIDs]; + } + let maxEntriesTotal = Zotero.Prefs.get("collectionsMenu.maxEntriesTotal"); + let maxEntriesAtOneLevel = Zotero.Prefs.get("collectionsMenu.maxEntriesAtOneLevel"); + let totalCollectionsCount = 0; + for (let libraryID of libraryIDs) { + let allCollections = Zotero.Collections.getByLibrary(libraryID, true, false); + totalCollectionsCount += allCollections.length; + // Too many top-level collections in this library + let topLevelCollections = Zotero.Collections.getByLibrary(libraryID); + if (topLevelCollections.length > maxEntriesAtOneLevel) { + return false; + } + // Any single collection has too many children + for (let collection of allCollections) { + if (collection.getChildCollections().length > maxEntriesAtOneLevel) { + return false; + } + } + } + // Too many total collections across all libraries + if (totalCollectionsCount > maxEntriesTotal) { + return false; + } + return true; + }; var _getByContainer = function (libraryID, parentID, recursive, includeTrashed) { let children = []; diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 5a48549b52..d3f8146be4 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -313,33 +313,15 @@ var ZoteroPane = new function () { moveFocus(actionsMap, event, true); }); - let collectionsSearchField = document.getElementById("zotero-collections-search"); let clearCollectionSearch = () => { - // If empty filter - just focus the collectionTree - if (collectionsSearchField.value.length == 0) { - return document.getElementById("collection-tree"); + let result = ZoteroPane.collectionsView.clearCollectionSearch("#zotero-collections-search"); + if (result === null) { + ZoteroPane.hideCollectionSearch(); } - // Clear the search field and focus collection tree - if (collectionsSearchField.value.length) { - collectionsSearchField.value = ''; - ZoteroPane.collectionsView.setFilter("", true); - } - ZoteroPane.hideCollectionSearch(); - return null; + return result; }; let focusCollectionTree = () => { - // Prevent Enter/Tab pressed before the filtering ran from doing anything - if (!ZoteroPane.collectionsView.filterEquals(collectionsSearchField.value)) { - return null; - } - // If the current row passes the filter, make sure it is visible and focus collectionTree - if (ZoteroPane.collectionsView.focusedRowMatchesFilter()) { - ZoteroPane.collectionsView.ensureRowIsVisible(ZoteroPane.collectionsView.selection.focused); - return document.getElementById('collection-tree'); - } - // Otherwise, focus the first row passing the filter - ZoteroPane.collectionsView.focusFirstMatchingRow(false); - return null; + return ZoteroPane.collectionsView.focusCollectionTree("#zotero-collections-search"); }; collectionTreeToolbar.addEventListener("keydown", (event) => { let actionsMap = { @@ -2622,6 +2604,26 @@ var ZoteroPane = new function () { await selected.saveTx(); }; + this.moveCollectionViaDialog = async function () { + let selected = this.getSelectedCollection(); + if (!selected) return; + + let io = { + dataIn: null, + dataOut: null, + filterLibraryIDs: [selected.libraryID], + currentCollectionID: selected.id, + canSelect: async (target) => { + return selected.canMoveToTargetAsync(target); + } + }; + window.openDialog('chrome://zotero/content/selectCollectionDialog.xhtml', '', + 'chrome,modal,centerscreen,resizable=no', io); + + if (!io.dataOut) return; + await this.moveCollection(io.dataOut); + }; + // Copy selected collection into another collection or library. // Partially, a replication of drag-drop mechanism from CollectionTree.onDrop. this.copyCollection = async (target) => { @@ -2652,6 +2654,28 @@ var ZoteroPane = new function () { }); }; + this.copyCollectionViaDialog = async function () { + let selected = this.getSelectedCollection(); + if (!selected) return; + + let librariesWhereOneCanCopy = await selected.getLibrariesForCopying(); + + let io = { + dataIn: null, + dataOut: null, + filterLibraryIDs: librariesWhereOneCanCopy.map(lib => lib.libraryID), + currentCollectionID: selected.id, + canSelect: async (collection) => { + return selected.canMoveToTargetAsync(collection); + } + }; + window.openDialog('chrome://zotero/content/selectCollectionDialog.xhtml', '', + 'chrome,modal,centerscreen,resizable=no', io); + + if (!io.dataOut) return; + await this.copyCollection(io.dataOut); + }; + this.toggleSelectedItemsRead = async function () { await Zotero.FeedItems.toggleReadByID(this.getSelectedItems(true)); }; @@ -3325,9 +3349,17 @@ var ZoteroPane = new function () { { id: "moveCollection", }, + { + id: "moveCollectionDialog", + oncommand: () => this.moveCollectionViaDialog() + }, { id: "copyCollection" }, + { + id: "copyCollectionDialog", + oncommand: () => this.copyCollectionViaDialog() + }, { id: "duplicate", oncommand: () => this.duplicateSelectedCollection() @@ -3446,8 +3478,6 @@ var ZoteroPane = new function () { 'newSubcollection', 'sep2', 'editSelectedCollection', - 'moveCollection', - 'copyCollection', 'deleteCollection', 'deleteCollectionAndItems', 'sep3', @@ -3455,6 +3485,16 @@ var ZoteroPane = new function () { 'createBibCollection', 'loadReport' ]; + + // Decide if collections can be listed as menuitems for move/copy menus + // or (if there are too many collections), collections will be selected in the dialog + let librariesWhereOneCanCopy = await collectionTreeRow.ref.getLibrariesForCopying(); + if (Zotero.Collections.canListCollectionsInMenu(librariesWhereOneCanCopy.map(lib => lib.libraryID))) { + show.push('moveCollection', 'copyCollection'); + } + else { + show.push('moveCollectionDialog', 'copyCollectionDialog'); + } if (!this.itemsView.rowCount) { disable = ['createBibCollection', 'loadReport']; @@ -3687,6 +3727,7 @@ var ZoteroPane = new function () { 'toggleRead', 'changeParentItem', 'addToCollection', + 'addToCollectionDialog', 'removeItems', 'duplicateAndConvert', 'duplicateItem', @@ -4098,8 +4139,15 @@ var ZoteroPane = new function () { && collectionTreeRow.editable && Zotero.Items.keepTopLevel(items).every(item => item.isTopLevelItem()) ) { - menu.childNodes[m.addToCollection].setAttribute('label', Zotero.getString('pane.items.menu.addToCollection')); - show.add(m.addToCollection); + // List collections if possible. If there are too many of them, collection + // will be selected in the dialog. + if (Zotero.Collections.canListCollectionsInMenu([items[0].libraryID])) { + menu.childNodes[m.addToCollection].setAttribute('label', Zotero.getString('pane.items.menu.addToCollection')); + show.add(m.addToCollection); + } + else { + show.add(m.addToCollectionDialog); + } } // Remove from collection / Recently Read @@ -4248,20 +4296,14 @@ var ZoteroPane = new function () { event.stopPropagation(); } }, - - (target) => { - // can't move collection into itself, its parent or its children - return selected == target - || selected.parentKey == target.key - || selected.hasDescendent('collection', target.id); - } + target => !selected.canMoveToTarget(target) ); popup.append(menuItem); } }; - this.buildCopyCollectionMenu = function (event) { + this.buildCopyCollectionMenu = Zotero.Utilities.Internal.serial(async function (event) { if (event.target !== event.currentTarget) return; let popup = document.getElementById("zotero-copy-collection-popup"); popup.replaceChildren(); @@ -4270,33 +4312,6 @@ var ZoteroPane = new function () { // Fetch all libraries let topLevelEntries = Zotero.Libraries.getAll().filter(lib => !(lib instanceof Zotero.Feed)); - // Check which libraries have collections linked to the selected collection - // and disable their menuitems. Same logic as in CollectionTree.canDropCheckAsync. - let linkedCollectionsExist = {}; - (async () => { - for (let library of topLevelEntries) { - if (library.libraryID == selected.libraryID) continue; - // Check which library has a collection linked to the selected collection - let linkedCollection = await selected.getLinkedCollection(library.libraryID, true); - linkedCollectionsExist[library.libraryID] = linkedCollection; - // Also check which library has collections linked to a subcollection of the selected collection - for (let descendent of selected.getDescendents(false, 'collection')) { - let subcollection = Zotero.Collections.get(descendent.id); - let linkedSubcollection = await subcollection.getLinkedCollection(library.libraryID, true); - if (linkedSubcollection) { - linkedCollectionsExist[library.libraryID] = linkedSubcollection; - } - } - } - // Libraries that have linked collections have their menus disabled - for (let libraryMenuItem of [...popup.childNodes]) { - let menuItemLibID = libraryMenuItem.getAttribute("value").substring(1); - if (linkedCollectionsExist[menuItemLibID]) { - libraryMenuItem.disabled = true; - } - } - })(); - // If there is only one library, display its collections as top-level menuitems if (topLevelEntries.length == 1) { // Manually add My Library menuitem at the top, so one can still copy into it @@ -4321,6 +4336,20 @@ var ZoteroPane = new function () { // Build menus for all libraries (or collections) for (let obj of topLevelEntries) { + // For libraries that cannot be copied to, just add a disabled stub + if (obj instanceof Zotero.Library) { + let canCopy = await selected.canMoveToTargetAsync(obj); + if (!canCopy) { + let menuItem = document.createXULElement("menu"); + menuItem.setAttribute("label", obj.name); + menuItem.setAttribute("image", obj.treeViewImage); + menuItem.setAttribute("value", obj.treeViewID); + menuItem.classList.add('menu-iconic'); + menuItem.disabled = true; + popup.append(menuItem); + continue; + } + } let menuItem = Zotero.Utilities.Internal.createMenuForTarget( obj, popup, @@ -4331,16 +4360,11 @@ var ZoteroPane = new function () { event.stopPropagation(); } }, - - (target) => { - // can't copy collection into itself or into non-editable groups - return selected == target - || (target instanceof Zotero.Group && !target.editable); - } + target => !selected.canMoveToTarget(target) ); popup.append(menuItem); } - }; + }); this.buildAddItemToCollectionMenu = function (event, items = this.getSelectedItems()) { if (event.target !== event.currentTarget) return; @@ -4386,6 +4410,33 @@ var ZoteroPane = new function () { separator.hidden = !collections.length; }; + this.addToCollectionViaDialog = async function (event, items = this.getSelectedItems()) { + items = Zotero.Items.keepTopLevel(items); + if (!items.length) return; + + let libraryID = items[0].libraryID; + if (items.some(item => item.libraryID !== libraryID)) return; + + let selectedCollection = ZoteroPane.collectionsView.getSelectedCollection(); + let io = { + dataIn: null, + dataOut: null, + filterLibraryIDs: [libraryID], + currentCollectionID: selectedCollection?.id, + canSelect: (collection) => { + if (!(collection instanceof Zotero.Collection)) return false; + if (selectedCollection && collection.id === selectedCollection.id) return false; + if (items.every(item => collection.hasItem(item))) return false; + return true; + } + }; + window.openDialog('chrome://zotero/content/selectCollectionDialog.xhtml', '', + 'chrome,modal,centerscreen,resizable=no', io); + + if (!io.dataOut) return; + await this.addItemsToCollection(items, io.dataOut); + }; + this.addItemsToCollection = async function (items, collection, createNew = false) { items = Zotero.Items.keepTopLevel(items); diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index 62de1383fd..b5ee5a7073 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -950,10 +950,12 @@ + + @@ -1011,6 +1013,7 @@ + diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index df65a30cbf..7d70b8338f 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -193,6 +193,10 @@ collections-menu-move-collection = .label = Move To collections-menu-copy-collection = .label = Copy To +collections-menu-move-collection-dialog = + .label = Move To… +collections-menu-copy-collection-dialog = + .label = Copy To… item-creator-moveDown = .label = Move Down @@ -234,6 +238,8 @@ item-menu-change-parent-item = .label = Change Parent Item… item-menu-relate-items = .label = Relate Items +item-menu-add-to-collection-dialog = + .label = Add to Collection… view-online = View Online item-menu-option-view-online = diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index 054b7bfd43..356e9bd2d4 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -32,6 +32,8 @@ pref("extensions.zotero.hideContextAnnotationRows", true); pref("extensions.zotero.fontSize", "1.00"); pref("extensions.zotero.layout", "standard"); pref("extensions.zotero.recursiveCollections", false); +pref("extensions.zotero.collectionsMenu.maxEntriesTotal", 5000); +pref("extensions.zotero.collectionsMenu.maxEntriesAtOneLevel", 100); pref("extensions.zotero.autoRecognizeFiles", true); pref("extensions.zotero.autoRenameFiles", true); pref("extensions.zotero.autoRenameFiles.linked", false); diff --git a/scss/_zotero.scss b/scss/_zotero.scss index e027b26b82..a97b92111d 100644 --- a/scss/_zotero.scss +++ b/scss/_zotero.scss @@ -59,6 +59,7 @@ @import "components/search"; @import "components/select"; @import "components/selectItemsDialog"; +@import "components/selectCollectionDialog"; @import "components/spinner"; @import "components/syncButtonTooltip"; @import "components/tabBar"; diff --git a/scss/components/_menu.scss b/scss/components/_menu.scss index 2771d641ac..d9484891a6 100644 --- a/scss/components/_menu.scss +++ b/scss/components/_menu.scss @@ -58,6 +58,7 @@ $menu-icons: ( rename-from-parent: "rename", create-note-from-annotations: "light-dark:note-annotation", add-to-collection: "new-collection", + add-to-collection-dialog: "new-collection", new-tab: "new-tab", new-window: "new-window", pin: "pin", diff --git a/scss/components/_newCollectionDialog.scss b/scss/components/_newCollectionDialog.scss index 9e251b1395..d898ad0f73 100644 --- a/scss/components/_newCollectionDialog.scss +++ b/scss/components/_newCollectionDialog.scss @@ -6,4 +6,24 @@ menulist::part(icon) { margin-inline-end: 3px; } + + [hidden="true"] { + display: none !important; + } + + #zotero-collections-tree-container { + min-width: 300px; + min-height: 300px; + max-height: 400px; + margin-top: 8px; + + .virtualized-table-body { + padding: 8px; + } + } + + #zotero-collections-tree { + border-radius: 5px; + background: var(--material-sidepane); + } } diff --git a/scss/components/_selectCollectionDialog.scss b/scss/components/_selectCollectionDialog.scss new file mode 100644 index 0000000000..e71853b741 --- /dev/null +++ b/scss/components/_selectCollectionDialog.scss @@ -0,0 +1,48 @@ +#zotero-select-collection-dialog { + display: flex; + min-width: 400px; + min-height: 450px; +} + +#select-collection-dialog { + appearance: none; + background: var(--material-background); + display: flex; + flex-direction: column; + -moz-window-dragging: drag; + height: 100%; + + #zotero-select-collection-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 100%; + -moz-window-dragging: no-drag; + margin: -15px -15px 0 -15px; + + #search-toolbar { + margin: 10px; + @media (-moz-platform: windows) { + margin: 20px 15px 10px 15px; + } + #zotero-collections-search { + max-width: 100%; + width: 100%; + } + } + } + + #zotero-collections-tree-container { + min-width: 400px; + flex: 1; + background: var(--material-sidepane); + + .virtualized-table-body { + padding: 16px 8px; + } + } + + #zotero-collections-tree { + background: var(--material-sidepane); + } +} diff --git a/test/tests/zoteroPaneTest.js b/test/tests/zoteroPaneTest.js index b4fe9b147e..8144951680 100644 --- a/test/tests/zoteroPaneTest.js +++ b/test/tests/zoteroPaneTest.js @@ -1136,6 +1136,76 @@ describe("ZoteroPane", function () { var restoreMenuItem = menu.querySelector('.zotero-menuitem-restore-to-library'); assert.isTrue(restoreMenuItem.disabled); }); + + it("should list collections when not too many", async function () { + Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000); + + let collection = await createDataObject('collection'); + let item = await createDataObject('item'); + + await zp.selectItems([item.id]); + await zp.buildItemContextMenu(); + + let menu = win.document.getElementById('zotero-itemmenu'); + menu.openPopup(); + // The menu (not menuitem) variant should be shown + let addToCollectionMenu = menu.querySelector('.zotero-menuitem-add-to-collection'); + assert.isFalse(addToCollectionMenu.hidden); + // The dialog variant should be hidden + let addToCollectionDialog = menu.querySelector('.zotero-menuitem-add-to-collection-dialog'); + assert.isTrue(addToCollectionDialog.hidden); + + // Open the menupopup so it gets populated + let popup = addToCollectionMenu.querySelector('menupopup'); + popup.openPopup(); + + // Find the menuitem for our collection and click it + let collectionMenuItem = popup.querySelector(`[value="C${collection.id}"]`); + assert.ok(collectionMenuItem); + collectionMenuItem.doCommand(); + popup.hidePopup(); + await waitForNotifierEvent("modify", "item"); + + // Verify item was added to the collection + assert.isTrue(collection.hasItem(item.id)); + }); + + it("should not list collections if too many", async function () { + let collection = await createDataObject('collection'); + let item = await createDataObject('item'); + + Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 0); + + await zp.selectItems([item.id]); + await zp.buildItemContextMenu(); + + let menu = win.document.getElementById('zotero-itemmenu'); + // The menu variant should be hidden + let addToCollectionMenu = menu.querySelector('.zotero-menuitem-add-to-collection'); + assert.isTrue(addToCollectionMenu.hidden); + // The dialog menuitem should be shown + let addToCollectionDialog = menu.querySelector('.zotero-menuitem-add-to-collection-dialog'); + assert.isFalse(addToCollectionDialog.hidden); + + // Set up listener for the dialog before triggering it + waitForWindow('chrome://zotero/content/selectCollectionDialog.xhtml', async (selectWin) => { + // Wait for the dialog to finish loading + do { + await Zotero.Promise.delay(50); + } + while (!selectWin.loaded); + await selectWin.collectionsView.makeVisible(); + // Select the target collection + await selectWin.collectionsView.selectByID('C' + collection.id); + selectWin.document.querySelector('dialog').acceptDialog(); + }); + await zp.addToCollectionViaDialog(); + + // Verify item was added to the collection + assert.isTrue(collection.hasItem(item.id)); + + Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000); + }); }); describe("#restoreSelectedItems()", function () { @@ -1887,7 +1957,7 @@ describe("ZoteroPane", function () { await waitForNotifierEvent("add", "collection"); - // Collection has been copies + // Collection has been copied let groupCollections = groupCollection.getDescendents(false, 'collection'); let newCollectionID = groupCollections.find(col => col.name == collectionChild.name).id; let newCollection = Zotero.Collections.get(newCollectionID); @@ -1935,6 +2005,122 @@ describe("ZoteroPane", function () { // Copied collection is a top-level collection assert.notOk(newCollection.parentID); }); + + it("should allow copying between libraries if there is a linked collection but it is in trash", async function () { + let groupDestination = await createGroup(); + let groupCollection = await createDataObject('collection', { libraryID: groupDestination.libraryID }); + + let collection = await createDataObject('collection'); + let collectionChild = await createDataObject('collection', { parentID: collection.id }); + + await zp.collectionsView.selectByID("C" + collectionChild.id); + + await zp.copyCollection(groupCollection); + + await waitForNotifierEvent("add", "collection"); + + // Collection has been copied + let newCollection = zp.getSelectedCollection(); + assert.equal(newCollection.libraryID, groupCollection.libraryID); + assert.equal(newCollection.name, collectionChild.name); + + // Send new collection to trash + newCollection.deleted = true; + await newCollection.saveTx(); + + // Right click on the selected collection + await zp.collectionsView.selectByID("C" + collectionChild.id); + await zp.buildCopyCollectionMenu({}); + await Zotero.Promise.delay(); + + let groupMenu = doc.querySelector(`#zotero-copy-collection-popup menu[value="L${groupDestination.libraryID}"]`); + // Menu of the library with linked collection should be enabled + assert.equal(groupMenu.disabled, false); + + // Copy collection again + await zp.copyCollection(groupCollection); + await waitForNotifierEvent("add", "collection"); + let anotherNewCollection = zp.getSelectedCollection(); + // Newly created collection is in the group + assert.equal(anotherNewCollection.libraryID, groupCollection.libraryID); + }); + }); + describe("#buildCollectionContextMenu()", function () { + it("should list collections for copy when not too many", async function () { + Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000); + + let collectionA = await createDataObject('collection'); + let collectionB = await createDataObject('collection'); + + await select(win, collectionA); + await zp.buildCollectionContextMenu(); + + let menu = doc.getElementById('zotero-collectionmenu'); + menu.openPopup(); + + // The menu (not menuitem) variant for copy should be shown + let copyCollectionMenu = menu.querySelector('#copyCollection'); + assert.isFalse(copyCollectionMenu.hidden); + // The dialog variant should be hidden + let copyCollectionDialog = menu.querySelector('#copyCollectionDialog'); + assert.isTrue(copyCollectionDialog.hidden); + + // Open the menupopup so it gets populated + let popup = doc.getElementById('zotero-copy-collection-popup'); + popup.openPopup(); + // Wait a moment to populate the popup + await Zotero.Promise.delay(); + + // Find the menuitem for collection B and click it + let collectionMenuItem = popup.querySelector(`[value="C${collectionB.id}"]`); + assert.ok(collectionMenuItem); + collectionMenuItem.doCommand(); + popup.hidePopup(); + await waitForNotifierEvent("add", "collection"); + + // Verify collection A was copied into collection B + let childNames = collectionB.getDescendents(false, 'collection').map(col => col.name); + assert.include(childNames, collectionA.name); + }); + + it("should not list collections for copy if too many", async function () { + let collectionA = await createDataObject('collection'); + let collectionB = await createDataObject('collection'); + + Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 0); + + await select(win, collectionA); + await zp.buildCollectionContextMenu(); + + let menu = doc.getElementById('zotero-collectionmenu'); + + // The menu variant for copy should be hidden + let copyCollectionMenu = menu.querySelector('#copyCollection'); + assert.isTrue(copyCollectionMenu.hidden); + // The dialog menuitem should be shown + let copyCollectionDialog = menu.querySelector('#copyCollectionDialog'); + assert.isFalse(copyCollectionDialog.hidden); + + // Set up listener for the dialog before triggering it + waitForWindow('chrome://zotero/content/selectCollectionDialog.xhtml', async (selectWin) => { + do { + await Zotero.Promise.delay(50); + } + while (!selectWin.loaded); + await selectWin.collectionsView.makeVisible(); + // Select the target collection + await selectWin.collectionsView.selectByID('C' + collectionB.id); + selectWin.document.querySelector('dialog').acceptDialog(); + }); + await zp.copyCollectionViaDialog(); + await waitForNotifierEvent("add", "collection"); + + // Verify collection A was copied into collection B + let childNames = collectionB.getDescendents(false, 'collection').map(col => col.name); + assert.include(childNames, collectionA.name); + + Zotero.Prefs.set('collectionsMenu.maxEntriesTotal', 1000); + }); }); describe("#moveCollection", function () { it("should move collection into another collection of the same library", async function () {