vpat 16: context menu as a drag-drop alternative to move/copy collections (#4420)

- Added menuitems to move collections within the same
library and to copy collections
- "Move to" only displays collections within the
current library
- "Copy to" displays all libraries, if more than
one library exists. If there is only one library,
top-level collections from "My Library" are displayed.
- while copying within the same library, create copies
of all collections and add items into them, without
actually duplicating items
- while copying between different libraries, items
will be duplicated, the same way it is done when
collections are dragged and dropped in another library
This commit is contained in:
abaevbog 2024-10-15 03:34:42 -07:00 • committed by GitHub
parent 2f5a7aa369
commit 7cf9097467
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 628 additions and 229 deletions

View file

@ -1796,7 +1796,276 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
return true;
}
/**
* Copy a given item into another library. Used when we need to create a copy of a collection
* in another library if collection is drag-dropped into a group it is not a part of.
*/
async _copyItem({ item, targetLibraryID, targetTreeRow, options }) {
// Check if there's already a copy of this item in the library
var linkedItem = await item.getLinkedItem(targetLibraryID, true);
if (linkedItem) {
// If linked item is in the trash, undelete it and remove it from collections
// (since it shouldn't be restored to previous collections)
if (linkedItem.deleted) {
linkedItem.setCollections();
linkedItem.deleted = false;
await linkedItem.save({
skipSelect: true
});
}
return linkedItem.id;
/*
// TODO: support tags, related, attachments, etc.
// Overlay source item fields on unsaved clone of linked item
var newItem = item.clone(false, linkedItem.clone(true));
newItem.setField('dateAdded', item.dateAdded);
newItem.setField('dateModified', item.dateModified);
var diff = newItem.diff(linkedItem, false, ["dateAdded", "dateModified"]);
if (!diff) {
// Check if creators changed
var creatorsChanged = false;
var creators = item.getCreators();
var linkedCreators = linkedItem.getCreators();
if (creators.length != linkedCreators.length) {
Zotero.debug('Creators have changed');
creatorsChanged = true;
}
else {
for (var i=0; i<creators.length; i++) {
if (!creators[i].ref.equals(linkedCreators[i].ref)) {
Zotero.debug('changed');
creatorsChanged = true;
break;
}
}
}
if (!creatorsChanged) {
Zotero.debug("Linked item hasn't changed -- skipping conflict resolution");
continue;
}
}
toReconcile.push([newItem, linkedItem]);
continue;
*/
}
// Standalone attachment
if (item.isAttachment()) {
var linkMode = item.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping standalone linked file attachment on drag");
return false;
}
if (!targetTreeRow.filesEditable) {
Zotero.debug("Skipping standalone file attachment on drag");
return false;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID);
if (options.annotations) {
await Zotero.Items.copyChildItems(item, newAttachment);
}
return newAttachment.id;
}
// Create new clone item in target library
var newItem = item.clone(targetLibraryID, { skipTags: !options.tags });
var newItemID = await newItem.save({
skipSelect: true
});
// Record link
await newItem.addLinkedItem(item);
if (item.isNote()) {
if (Zotero.Libraries.get(newItem.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(item, newItem);
}
return newItemID;
}
// For regular items, add child items if prefs and permissions allow
// Child notes
if (options.childNotes) {
var noteIDs = item.getNotes();
var notes = Zotero.Items.get(noteIDs);
for (let note of notes) {
let newNote = note.clone(targetLibraryID, { skipTags: !options.tags });
newNote.parentID = newItemID;
await newNote.save({
skipSelect: true
})
if (Zotero.Libraries.get(newNote.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(note, newNote);
}
await newNote.addLinkedItem(note);
}
}
// Child attachments
if (options.childLinks || options.childFileAttachments) {
var attachmentIDs = item.getAttachments();
var attachments = Zotero.Items.get(attachmentIDs);
for (let attachment of attachments) {
var linkMode = attachment.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on drag");
continue;
}
// Skip imported files if we don't have pref and permissions
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!options.childLinks) {
Zotero.debug("Skipping child link attachment on drag");
continue;
}
}
else {
if (!options.childFileAttachments
|| (!targetTreeRow.filesEditable && !targetTreeRow.isPublications())) {
Zotero.debug("Skipping child file attachment on drag");
continue;
}
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(
attachment, targetLibraryID, newItemID
);
if (options.annotations) {
await Zotero.Items.copyChildItems(attachment, newAttachment);
}
}
}
return newItemID;
}
/**
* Helper function used by executeCollectionCopy to recursively copy collections from one library
* into another, or from one collection to another within the same library.
*/
async _copyCollections({ descendents, parentID, addItems, targetLibraryID, targetTreeRow, copyOptions }) {
for (var desc of descendents) {
// Collections
if (desc.type == 'collection') {
var c = await Zotero.Collections.getAsync(desc.id);
let newCollection = c.clone(targetLibraryID);
if (parentID) {
newCollection.parentID = parentID;
}
var collectionID = await newCollection.save();
// Record link only if copying to a different library
if (targetLibraryID !== c.libraryID) {
await newCollection.addLinkedCollection(c);
}
// Recursively copy subcollections
if (desc.children.length) {
await this._copyCollections({
descendents: desc.children,
parentID: collectionID,
addItems,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
}
// Items
else {
var item = await Zotero.Items.getAsync(desc.id);
let id = desc.id;
// Actually copy items only if moving to another library
if (item.libraryID !== targetLibraryID) {
id = await this._copyItem({
item,
targetLibraryID,
targetTreeRow,
options: copyOptions
});
// Standalone attachments might not get copied
if (!id) {
continue;
}
}
// Mark copied item for adding to collection
if (parentID) {
let parentItems = addItems.get(parentID);
if (!parentItems) {
parentItems = [];
addItems.set(parentID, parentItems);
}
// If source item is a top-level non-regular item (which can exist in a
// collection) but target item is a child item (which can't), add
// target item's parent to collection instead
if (!item.isRegularItem()) {
let targetItem = await Zotero.Items.getAsync(id);
let targetItemParentID = targetItem.parentItemID;
if (targetItemParentID) {
id = targetItemParentID;
}
}
parentItems.push(id);
}
}
}
}
/**
* Copy a given collection into another library, or duplicate it within the same library
*
* Used for drag-and-drop and the "Copy To" context menu option
*
* @param {Zotero.Collection} collection - collection to copy
* @param {String} targetCollectionID - id of the collection to copy to
* @param {String} targetLibraryID - id of the library to copy to
* @param {Zotero.CollectionTreeRow } targetTreeRow - tree row of the target
* @param {Object} copyOptions - options how to perform the copy - see onDrop for an example
*/
async executeCollectionCopy({ collection, targetCollectionID, targetLibraryID, targetTreeRow, copyOptions }) {
await Zotero.DB.executeTransaction(async () => {
var collections = [{
id: collection.id,
children: collection.getDescendents(true),
type: 'collection'
}];
var addItems = new Map();
await this._copyCollections({
descendents: collections,
parentID: targetCollectionID,
addItems,
targetLibraryID,
targetTreeRow,
copyOptions
});
for (let [collectionID, items] of addItems.entries()) {
let collection = await Zotero.Collections.getAsync(collectionID);
await collection.addItems(items);
}
// TODO: add subcollections and subitems, if they don't already exist,
// and display a warning if any of the subcollections already exist
});
}
async onDrop(event, index) {
const treeRow = this.getRow(index);
this._dropRow = null;
@ -1809,7 +2078,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
|| !(await this.canDropCheckAsync(row, orient, dataTransfer))) {
return false;
}
var dragData = Zotero.DragDrop.getDataFromDataTransfer(dataTransfer);
if (!dragData) {
Zotero.debug("No drag data");
@ -1828,237 +2096,20 @@ var CollectionTree = class CollectionTree extends LibraryTree {
childFileAttachments: Zotero.Prefs.get('groups.copyChildFileAttachments'),
annotations: Zotero.Prefs.get('groups.copyAnnotations'),
};
var copyItem = async function (item, targetLibraryID, options) {
var targetLibraryType = Zotero.Libraries.get(targetLibraryID).libraryType;
// Check if there's already a copy of this item in the library
var linkedItem = await item.getLinkedItem(targetLibraryID, true);
if (linkedItem) {
// If linked item is in the trash, undelete it and remove it from collections
// (since it shouldn't be restored to previous collections)
if (linkedItem.deleted) {
linkedItem.setCollections();
linkedItem.deleted = false;
await linkedItem.save({
skipSelect: true
});
}
return linkedItem.id;
/*
// TODO: support tags, related, attachments, etc.
// Overlay source item fields on unsaved clone of linked item
var newItem = item.clone(false, linkedItem.clone(true));
newItem.setField('dateAdded', item.dateAdded);
newItem.setField('dateModified', item.dateModified);
var diff = newItem.diff(linkedItem, false, ["dateAdded", "dateModified"]);
if (!diff) {
// Check if creators changed
var creatorsChanged = false;
var creators = item.getCreators();
var linkedCreators = linkedItem.getCreators();
if (creators.length != linkedCreators.length) {
Zotero.debug('Creators have changed');
creatorsChanged = true;
}
else {
for (var i=0; i<creators.length; i++) {
if (!creators[i].ref.equals(linkedCreators[i].ref)) {
Zotero.debug('changed');
creatorsChanged = true;
break;
}
}
}
if (!creatorsChanged) {
Zotero.debug("Linked item hasn't changed -- skipping conflict resolution");
continue;
}
}
toReconcile.push([newItem, linkedItem]);
continue;
*/
}
// Standalone attachment
if (item.isAttachment()) {
var linkMode = item.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping standalone linked file attachment on drag");
return false;
}
if (!targetTreeRow.filesEditable) {
Zotero.debug("Skipping standalone file attachment on drag");
return false;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID);
if (options.annotations) {
await Zotero.Items.copyChildItems(item, newAttachment);
}
return newAttachment.id;
}
// Create new clone item in target library
var newItem = item.clone(targetLibraryID, { skipTags: !options.tags });
var newItemID = await newItem.save({
skipSelect: true
});
// Record link
await newItem.addLinkedItem(item);
if (item.isNote()) {
if (Zotero.Libraries.get(newItem.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(item, newItem);
}
return newItemID;
}
// For regular items, add child items if prefs and permissions allow
// Child notes
if (options.childNotes) {
var noteIDs = item.getNotes();
var notes = Zotero.Items.get(noteIDs);
for (let note of notes) {
let newNote = note.clone(targetLibraryID, { skipTags: !options.tags });
newNote.parentID = newItemID;
await newNote.save({
skipSelect: true
})
if (Zotero.Libraries.get(newNote.libraryID).filesEditable) {
await Zotero.Notes.copyEmbeddedImages(note, newNote);
}
await newNote.addLinkedItem(note);
}
}
// Child attachments
if (options.childLinks || options.childFileAttachments) {
var attachmentIDs = item.getAttachments();
var attachments = Zotero.Items.get(attachmentIDs);
for (let attachment of attachments) {
var linkMode = attachment.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on drag");
continue;
}
// Skip imported files if we don't have pref and permissions
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!options.childLinks) {
Zotero.debug("Skipping child link attachment on drag");
continue;
}
}
else {
if (!options.childFileAttachments
|| (!targetTreeRow.filesEditable && !targetTreeRow.isPublications())) {
Zotero.debug("Skipping child file attachment on drag");
continue;
}
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(
attachment, targetLibraryID, newItemID
);
if (options.annotations) {
await Zotero.Items.copyChildItems(attachment, newAttachment);
}
}
}
return newItemID;
};
var targetLibraryID = targetTreeRow.ref.libraryID;
var targetCollectionID = targetTreeRow.isCollection() ? targetTreeRow.ref.id : false;
if (dataType == 'zotero/collection') {
var droppedCollection = await Zotero.Collections.getAsync(data[0]);
// Collection drag between libraries
if (targetLibraryID != droppedCollection.libraryID) {
await Zotero.DB.executeTransaction(async function () {
var copyCollections = async function (descendents, parentID, addItems) {
for (var desc of descendents) {
// Collections
if (desc.type == 'collection') {
var c = await Zotero.Collections.getAsync(desc.id);
let newCollection = c.clone(targetLibraryID);
if (parentID) {
newCollection.parentID = parentID;
}
var collectionID = await newCollection.save();
// Record link
await newCollection.addLinkedCollection(c);
// Recursively copy subcollections
if (desc.children.length) {
await copyCollections(desc.children, collectionID, addItems);
}
}
// Items
else {
var item = await Zotero.Items.getAsync(desc.id);
var id = await copyItem(item, targetLibraryID, copyOptions);
// Standalone attachments might not get copied
if (!id) {
continue;
}
// Mark copied item for adding to collection
if (parentID) {
let parentItems = addItems.get(parentID);
if (!parentItems) {
parentItems = [];
addItems.set(parentID, parentItems);
}
// If source item is a top-level non-regular item (which can exist in a
// collection) but target item is a child item (which can't), add
// target item's parent to collection instead
if (!item.isRegularItem()) {
let targetItem = await Zotero.Items.getAsync(id);
let targetItemParentID = targetItem.parentItemID;
if (targetItemParentID) {
id = targetItemParentID;
}
}
parentItems.push(id);
}
}
}
};
var collections = [{
id: droppedCollection.id,
children: droppedCollection.getDescendents(true),
type: 'collection'
}];
var addItems = new Map();
await copyCollections(collections, targetCollectionID, addItems);
for (let [collectionID, items] of addItems.entries()) {
let collection = await Zotero.Collections.getAsync(collectionID);
await collection.addItems(items);
}
// TODO: add subcollections and subitems, if they don't already exist,
// and display a warning if any of the subcollections already exist
await this.executeCollectionCopy({
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
// Collection drag within a library
@ -2137,9 +2188,14 @@ var CollectionTree = class CollectionTree extends LibraryTree {
newItems,
100,
function (chunk) {
return Zotero.DB.executeTransaction(async function () {
return Zotero.DB.executeTransaction(async () => {
for (let item of chunk) {
var id = await copyItem(item, targetLibraryID, copyOptions)
var id = await this._copyItem({
item,
targetLibraryID,
targetTreeRow,
options: copyOptions
});
// Standalone attachments might not get copied
if (!id) {
continue;
@ -2147,7 +2203,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
newIDs.push(id);
}
});
}
}.bind(this)
);
if (toReconcile.length) {

View file

@ -2565,6 +2565,55 @@ var ZoteroPane = new function()
}
});
// Move selected collection to specified target collection or library.
// Target has to be in the same library as the currently selected collection.
this.moveCollection = async (target) => {
let selected = this.getSelectedCollection();
if (!selected) return;
if (target.libraryID !== selected.libraryID) {
throw new Error("Moving collections is only possible within the same library.");
}
if (target instanceof Zotero.Library) {
selected.parentID = null;
}
else {
selected.parentID = target.id;
}
await selected.saveTx();
};
// Copy selected collection into another collection or library.
// Partially, a replication of drag-drop mechanism from CollectionTree.onDrop.
this.copyCollection = async (target) => {
let selected = this.getSelectedCollection();
if (!selected) return;
let targetTreeRowID = `L${target.libraryID}`;
if (target instanceof Zotero.Collection) {
targetTreeRowID = `C${target.id}`;
// Make sure the row is actually visible
await ZoteroPane.collectionsView.expandToCollection(target.id);
}
let targetTreeRowIndex = ZoteroPane.collectionsView.getRowIndexByID(targetTreeRowID);
let targetTreeRow = ZoteroPane.collectionsView.getRow(targetTreeRowIndex);
let copyOptions = {
tags: Zotero.Prefs.get('groups.copyTags'),
childNotes: Zotero.Prefs.get('groups.copyChildNotes'),
childLinks: Zotero.Prefs.get('groups.copyChildLinks'),
childFileAttachments: Zotero.Prefs.get('groups.copyChildFileAttachments'),
annotations: Zotero.Prefs.get('groups.copyAnnotations'),
};
ZoteroPane.collectionsView.executeCollectionCopy({
collection: selected,
targetCollectionID: target instanceof Zotero.Collection ? target.id : null,
targetLibraryID: target.libraryID,
targetTreeRow,
copyOptions
});
};
this.toggleSelectedItemsRead = Zotero.Promise.coroutine(function* () {
yield Zotero.FeedItems.toggleReadByID(this.getSelectedItems(true));
});
@ -3179,6 +3228,12 @@ var ZoteroPane = new function()
id: "editSelectedCollection",
oncommand: () => this.editSelectedCollection()
},
{
id: "moveCollection",
},
{
id: "copyCollection"
},
{
id: "duplicate",
oncommand: () => this.duplicateSelectedCollection()
@ -3297,6 +3352,8 @@ var ZoteroPane = new function()
'newSubcollection',
'sep2',
'editSelectedCollection',
'moveCollection',
'copyCollection',
'deleteCollection',
'deleteCollectionAndItems',
'sep3',
@ -3316,6 +3373,8 @@ var ZoteroPane = new function()
// Adjust labels
document.l10n.setAttributes(m.editSelectedCollection, 'collections-menu-rename-collection');
document.l10n.setAttributes(m.moveCollection, 'collections-menu-move-collection');
document.l10n.setAttributes(m.copyCollection, 'collections-menu-copy-collection');
m.deleteCollection.setAttribute('label', Zotero.getString('pane.collections.menu.delete.collection'));
m.deleteCollectionAndItems.setAttribute('label', Zotero.getString('pane.collections.menu.delete.collectionAndItems'));
@ -3978,7 +4037,139 @@ var ZoteroPane = new function()
});
this.buildAddToCollectionMenu = function (event) {
// Build a menu to move or copy a collection into another collection and library.
// Alternative to dropping collection into another collection or group
this.buildMoveCollectionMenu = function (event) {
if (event.target !== event.currentTarget) return;
let popup = event.target;
popup.replaceChildren();
let selected = this.getSelectedCollection();
// Add current library at the top to be able to move collections into it
let library = Zotero.Libraries.get(ZoteroPane.getSelectedLibraryID());
let libraryMenuItem = document.createXULElement("menuitem");
libraryMenuItem.setAttribute("label", library.name);
libraryMenuItem.setAttribute("image", library.treeViewImage);
libraryMenuItem.setAttribute("value", library.treeViewID);
libraryMenuItem.addEventListener("command", (event) => {
if (event.target.tagName == 'menuitem') {
this.moveCollection(library);
event.stopPropagation();
}
});
// Disable for already top-level collections
libraryMenuItem.disabled = !selected.parentID;
popup.appendChild(libraryMenuItem);
popup.appendChild(document.createXULElement("menuseparator"));
// Build menus for each top-level collection of this library
let collections = Zotero.Collections.getByLibrary(this.getSelectedLibraryID());
for (let col of collections) {
let menuItem = Zotero.Utilities.Internal.createMenuForTarget(
col,
popup,
null,
(event, collection) => {
if (event.target.tagName == 'menuitem') {
this.moveCollection(collection);
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);
}
);
popup.append(menuItem);
}
};
this.buildCopyCollectionMenu = function (event) {
if (event.target !== event.currentTarget) return;
let popup = document.getElementById("zotero-copy-collection-popup");
popup.replaceChildren();
let selected = this.getSelectedCollection();
// 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
let myLibrary = topLevelEntries[0];
let myLibraryMenuItem = document.createXULElement("menuitem");
myLibraryMenuItem.setAttribute("label", myLibrary.name);
myLibraryMenuItem.setAttribute("image", myLibrary.treeViewImage);
myLibraryMenuItem.setAttribute("value", myLibrary.treeViewID);
myLibraryMenuItem.addEventListener("command", (event) => {
if (event.target.tagName == 'menuitem') {
this.copyCollection(myLibrary);
event.stopPropagation();
}
});
popup.appendChild(myLibraryMenuItem);
popup.appendChild(document.createXULElement("menuseparator"));
// Top-level collections used to construct the menus
topLevelEntries = Zotero.Collections.getByLibrary(topLevelEntries[0].id);
}
// Build menus for all libraries (or collections)
for (let obj of topLevelEntries) {
let menuItem = Zotero.Utilities.Internal.createMenuForTarget(
obj,
popup,
null,
(event, collection) => {
if (event.target.tagName == 'menuitem') {
this.copyCollection(collection);
event.stopPropagation();
}
},
(target) => {
// can't copy collection into itself or into non-editable groups
return selected == target
|| (target instanceof Zotero.Group && !target.editable);
}
);
popup.append(menuItem);
}
};
this.buildAddItemToCollectionMenu = function (event) {
if (event.target !== event.currentTarget) return;
let popup = event.target;

View file

@ -912,6 +912,14 @@
<menuitem class="zotero-menuitem-show-unfiled" label="&zotero.collections.showUnfiledItems;"/>
<menuitem class="zotero-menuitem-show-retracted" label="&zotero.collections.showRetractedItems;"/>
<menuitem class="zotero-menuitem-edit-collection"/>
<menu class="menu-iconic zotero-menuitem-move-collection">
<menupopup id="zotero-move-collection-popup" onpopupshowing="ZoteroPane_Local.buildMoveCollectionMenu(event)">
</menupopup>
</menu>
<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-duplicate-collection"/>
<menuitem class="zotero-menuitem-mark-read-feed"/>
<menuitem class="zotero-menuitem-edit-feed" label="&zotero.toolbar.feeds.edit;"/>
@ -967,7 +975,7 @@
<menuitem class="menuitem-iconic zotero-menuitem-toggle-read-item" oncommand="ZoteroPane_Local.toggleSelectedItemsRead();"/>
<menuitem class="menuitem-iconic zotero-menuitem-change-top-level-item" data-l10n-id="item-menu-change-parent-item" oncommand="ZoteroPane_Local.changeParentItem();"/>
<menu class="menu-iconic zotero-menuitem-add-to-collection">
<menupopup id="zotero-add-to-collection-popup" onpopupshowing="ZoteroPane_Local.buildAddToCollectionMenu(event)">
<menupopup id="zotero-add-to-collection-popup" onpopupshowing="ZoteroPane_Local.buildAddItemToCollectionMenu(event)">
<menuitem id="zotero-add-to-new-collection" label="&zotero.toolbar.newCollection.label;" oncommand="ZoteroPane_Local.addSelectedItemsToCollection(null, true)"/>
<menuseparator id="zotero-add-to-collection-separator"/>
</menupopup>

View file

@ -96,6 +96,10 @@ collections-menu-rename-collection =
.label = Rename Collection
collections-menu-edit-saved-search =
.label = Edit Saved Search
collections-menu-move-collection =
.label = Move To
collections-menu-copy-collection =
.label = Copy To
item-creator-moveDown =
.label = Move Down

View file

@ -1796,4 +1796,144 @@ describe("ZoteroPane", function() {
assert.equal(attachment.getCollections()[0], collection.id);
});
});
describe("#copyCollection", function () {
it("should copy collection within the same library", async function () {
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let collectionDestination = await createDataObject('collection');
let itemOne = await createDataObject('item', { collections: [collectionParent.id] });
let itemTwo = await createDataObject('item', { collections: [collectionChild.id] });
await zp.collectionsView.selectByID("C" + collectionParent.id);
await zp.copyCollection(collectionDestination);
await waitForNotifierEvent("add", "collection");
// Newly created collections have the same names as the original ones
let collectionNames = collectionDestination.getDescendents(false, 'collection').map(col => col.name);
assert.sameMembers(collectionNames, [collectionParent.name, collectionChild.name]);
// Newly created collections contain the same items
let items = collectionDestination.getDescendents(false, 'item').map(item => item.id);
assert.sameMembers(items, [itemOne.id, itemTwo.id]);
});
it("should duplicate top-level collection", async function () {
let collection = await createDataObject('collection');
let mylibrary = Zotero.Libraries.get(collection.libraryID);
let itemOne = await createDataObject('item', { collections: [collection.id] });
await zp.collectionsView.selectByID("C" + collection.id);
await zp.copyCollection(mylibrary);
await waitForNotifierEvent("add", "collection");
// Find the duplicated collection and make sure it exists
let topLevelCollections = Zotero.Collections.getByLibrary(mylibrary.id);
let newCollection = topLevelCollections.find(col => col.name == collection.name && collection.id !== col.id);
assert.exists(newCollection);
// Newly created collection contain the same item
let items = newCollection.getDescendents(false, 'item').map(item => item.id);
assert.sameMembers(items, [itemOne.id]);
});
it("should copy collection between libraries", async function () {
let groupDestination = await createGroup();
let groupCollection = await createDataObject('collection', { libraryID: groupDestination.libraryID });
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let itemOne = await createDataObject('item', { collections: [collectionParent.id] });
let itemTwo = await createDataObject('item', { collections: [collectionChild.id] });
await zp.collectionsView.selectByID("C" + collectionParent.id);
await zp.copyCollection(groupCollection);
await waitForNotifierEvent("add", "collection");
// Newly created collections have the same names as the original ones
let collectionNames = groupCollection.getDescendents(false, 'collection').map(col => col.name);
assert.sameMembers(collectionNames, [collectionParent.name, collectionChild.name]);
// Newly created collections also have copies of items
let items = groupCollection.getDescendents(false, 'item').map(item => Zotero.Items.get(item.id).getDisplayTitle());
assert.sameMembers(items, [itemOne.getDisplayTitle(), itemTwo.getDisplayTitle()]);
});
it("should not allow copying between libraries if there is a linked collection", 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 copies
let groupCollections = groupCollection.getDescendents(false, 'collection');
let newCollectionID = groupCollections.find(col => col.name == collectionChild.name).id;
let newCollection = Zotero.Collections.get(newCollectionID);
assert.exists(newCollection);
// Right click on the selected collection
await zp.collectionsView.selectByID("C" + collectionChild.id);
zp.buildCopyCollectionMenu({});
// Delay for menus to get disabled
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 disabled
assert.equal(groupMenu.disabled, true);
// Right click on the parent of the copies collection
await zp.collectionsView.selectByID("C" + collection.id);
zp.buildCopyCollectionMenu({});
// Delay for menus to get disabled
await Zotero.Promise.delay();
groupMenu = doc.querySelector(`#zotero-copy-collection-popup menu[value="L${groupDestination.libraryID}"]`);
// Menu of the library with linked sub-collection should be disabled
assert.equal(groupMenu.disabled, true);
});
});
describe("#moveCollection", function () {
it("should move collection into another collection of the same library", async function () {
let collection = await createDataObject('collection');
let collectionDestination = await createDataObject('collection');
await zp.collectionsView.selectByID("C" + collection.id);
let promise = waitForNotifierEvent("modify", "collection");
await zp.moveCollection(collectionDestination);
await promise;
// Collection was moved into destination collection
let collectionChildIDs = collectionDestination.getDescendents(false, 'collection').map(col => col.id);
assert.sameMembers(collectionChildIDs, [collection.id]);
});
it("should make collection a top-level collection", async function () {
let collectionParent = await createDataObject('collection');
let collectionChild = await createDataObject('collection', { parentID: collectionParent.id });
let library = Zotero.Libraries.get(collectionChild.libraryID);
await zp.collectionsView.selectByID("C" + collectionChild.id);
let promise = waitForNotifierEvent("modify", "collection");
await zp.moveCollection(library);
await promise;
// Child collection was pulled from under its parent to become a top-level collection
let topLevelCollections = Zotero.Collections.getByLibrary(library.id);
assert.includeMembers(topLevelCollections, [collectionChild]);
});
});
})