This commit is contained in:
abaevbog 2026-06-06 13:56:09 +08:00 • committed by GitHub
commit 481031fa37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 788 additions and 133 deletions

View file

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

View file

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

View file

@ -65,9 +65,12 @@
<vbox>
<label data-l10n-id="new-collection-create-in" control="create-in"/>
<menulist id="create-in" native="true">
<menulist id="create-in" native="true" hidden="true">
<menupopup id="zotero-new-collection-menu" />
</menulist>
<vbox id="zotero-collections-tree-container" class="virtualized-table-container" hidden="true">
<html:div id="zotero-collections-tree"/>
</vbox>
</vbox>
</dialog>
</window>

View file

@ -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 <http://www.gnu.org/licenses/>.
***** 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();
}

View file

@ -0,0 +1,77 @@
<?xml version="1.0"?>
<!--
***** 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 <http://www.gnu.org/licenses/>.
***** END LICENSE BLOCK *****
-->
<?xml-stylesheet href="chrome://global/skin/" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/zotero.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero/skin/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/overlay.css" type="text/css"?>
<?xml-stylesheet href="chrome://zotero-platform/content/zotero.css"?>
<!DOCTYPE window SYSTEM "chrome://zotero/locale/zotero.dtd">
<window
id="zotero-select-collection-dialog"
windowtype="zotero:collection-selector"
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:html="http://www.w3.org/1999/xhtml"
onload="doLoad();"
onunload="doUnload();"
persist="screenX screenY width height"
class="zotero-dialog"
data-l10n-id="select-collection-window"
drawintitlebar-platforms="mac,win"
>
<dialog
id="select-collection-dialog"
orient="vertical"
buttons="cancel,accept"
data-l10n-id="select-collection-dialog"
data-l10n-attrs="buttonlabelaccept"
>
<script>
Services.scriptloader.loadSubScript("chrome://zotero/content/include.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/customElements.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/selectCollectionDialog.js", this);
Services.scriptloader.loadSubScript("chrome://zotero/content/titlebar.js", this);
</script>
<linkset>
<html:link rel="localization" href="zotero.ftl" />
</linkset>
<vbox id="zotero-select-collection-container" flex="1">
<hbox id="search-toolbar">
<search-textbox
id="zotero-collections-search"
data-l10n-id="zotero-collections-search"
data-l10n-attrs="placeholder"
oncommand="handleCollectionSearchInput()"/>
</hbox>
<vbox id="zotero-collections-tree-container" class="virtualized-table-container">
<html:div id="zotero-collections-tree"/>
</vbox>
</vbox>
</dialog>
</window>

View file

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

View file

@ -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 = [];

View file

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

View file

@ -950,10 +950,12 @@
<menupopup id="zotero-move-collection-popup" onpopupshowing="ZoteroPane_Local.buildMoveCollectionMenu(event)">
</menupopup>
</menu>
<menuitem class="zotero-menuitem-move-collection" data-l10n-id="collections-menu-move-collection-dialog"/>
<menu class="menu-iconic zotero-menuitem-copy-collection">
<menupopup id="zotero-copy-collection-popup" onpopupshowing="ZoteroPane_Local.buildCopyCollectionMenu(event)">
</menupopup>
</menu>
<menuitem class="zotero-menuitem-copy-collection" data-l10n-id="collections-menu-copy-collection-dialog"/>
<menuitem class="zotero-menuitem-duplicate-collection"/>
<menuitem class="zotero-menuitem-mark-read-feed"/>
<menuitem class="zotero-menuitem-edit-feed" label="&zotero.toolbar.feeds.edit;"/>
@ -1011,6 +1013,7 @@
<menu class="menu-iconic zotero-menuitem-add-to-collection">
<menupopup id="zotero-add-to-collection-popup" onpopupshowing="ZoteroPane_Local.buildAddItemToCollectionMenu(event)"/>
</menu>
<menuitem class="menuitem-iconic zotero-menuitem-add-to-collection-dialog" data-l10n-id="item-menu-add-to-collection-dialog" oncommand="ZoteroPane_Local.addToCollectionViaDialog();"/>
<menuitem class="menuitem-iconic zotero-menuitem-remove-items" oncommand="ZoteroPane_Local.deleteSelectedItems();"/>
<menuitem class="menuitem-iconic zotero-menuitem-duplicate-and-convert" oncommand="ZoteroPane_Local.duplicateAndConvertSelectedItem();"/>
<menuitem class="menuitem-iconic zotero-menuitem-duplicate-item" label="&zotero.items.menu.duplicateItem;" oncommand="ZoteroPane_Local.duplicateSelectedItem().done();"/>

View file

@ -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 =

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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