Move cross-library item copying to Zotero.Items

This commit is contained in:
Adomas Venčkauskas 2026-08-12 07:17:52 +00:00
parent af5ab8427b
commit a7df23cdb8
3 changed files with 199 additions and 166 deletions

View file

@ -2045,159 +2045,11 @@ 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) {
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 }) {
async _copyCollections({ descendents, parentID, addItems, targetLibraryID, copyOptions }) {
for (var desc of descendents) {
// Collections
if (desc.type == 'collection') {
@ -2219,7 +2071,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
parentID: collectionID,
addItems,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
@ -2230,12 +2081,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
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
});
let copiedItem = await Zotero.Items.copyToLibrary(
item, targetLibraryID, copyOptions
);
id = copiedItem && copiedItem.id;
// Standalone attachments might not get copied
if (!id) {
continue;
@ -2274,10 +2123,9 @@ var CollectionTree = class CollectionTree extends LibraryTree {
* @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 }) {
async executeCollectionCopy({ collection, targetCollectionID, targetLibraryID, copyOptions }) {
await Zotero.DB.executeTransaction(async () => {
var collections = [{
id: collection.id,
@ -2291,7 +2139,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
parentID: targetCollectionID,
addItems,
targetLibraryID,
targetTreeRow,
copyOptions
});
for (let [collectionID, items] of addItems.entries()) {
@ -2388,7 +2235,6 @@ var CollectionTree = class CollectionTree extends LibraryTree {
collection: droppedCollection,
targetCollectionID,
targetLibraryID,
targetTreeRow,
copyOptions
});
}
@ -2507,12 +2353,10 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return Zotero.DB.executeTransaction(async () => {
let copiedItemIDs = [];
for (let item of chunk) {
var id = await this._copyItem({
item,
targetLibraryID,
targetTreeRow,
options: copyOptions
});
let copiedItem = await Zotero.Items.copyToLibrary(
item, targetLibraryID, copyOptions
);
let id = copiedItem && copiedItem.id;
// Standalone attachments might not get copied
if (!id) {
continue;

View file

@ -967,6 +967,128 @@ Zotero.Items = function () {
};
/**
* Copy an item to another library, including enabled child items
*
* Requires a transaction
*
* @param {Zotero.Item} item
* @param {Integer} targetLibraryID
* @param {Object} [options]
* @param {Boolean} [options.tags]
* @param {Boolean} [options.childNotes]
* @param {Boolean} [options.childLinks]
* @param {Boolean} [options.childFileAttachments]
* @param {Boolean} [options.annotations]
* @return {Promise<Zotero.Item|false>} The copied or existing linked item, or false if an
* attachment was skipped
*/
this.copyToLibrary = async function (item, targetLibraryID, options = {}) {
Zotero.DB.requireTransaction();
if (!item.isRegularItem() && !item.isNote() && !item.isAttachment()) {
throw new Error("Only regular items, notes, and attachments can be copied between libraries");
}
// Check if there's already a copy of this item in the library
let linkedItem = await item.getLinkedItem(targetLibraryID, true);
if (linkedItem) {
// TODO: Support reconciling tags, related items, attachments, etc.
return linkedItem;
}
let targetLibrary = Zotero.Libraries.get(targetLibraryID);
let {
tags: copyTags = Zotero.Prefs.get('groups.copyTags'),
childNotes: copyChildNotes = Zotero.Prefs.get('groups.copyChildNotes'),
childLinks: copyChildLinks = Zotero.Prefs.get('groups.copyChildLinks'),
childFileAttachments: copyChildFileAttachments
= Zotero.Prefs.get('groups.copyChildFileAttachments'),
annotations: copyAnnotations = Zotero.Prefs.get('groups.copyAnnotations'),
} = options;
// Standalone attachment
if (item.isAttachment()) {
// Skip linked files
if (item.attachmentLinkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping standalone linked file attachment on copy");
return false;
}
if (!targetLibrary.filesEditable) {
Zotero.debug("Skipping standalone file attachment on copy");
return false;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(item, targetLibraryID);
if (copyAnnotations) {
await this.copyChildItems(item, newAttachment);
}
return newAttachment;
}
// Create new clone item in target library
let newItem = item.clone(targetLibraryID, { skipTags: !copyTags });
await newItem.save({ skipSelect: true });
// Record link
await newItem.addLinkedItem(item);
if (item.isNote()) {
if (targetLibrary.filesEditable) {
await Zotero.Notes.copyEmbeddedImages(item, newItem);
}
return newItem;
}
// For regular items, add child items if prefs and permissions allow
// Child notes
if (copyChildNotes) {
for (let note of Zotero.Items.get(item.getNotes())) {
let newNote = note.clone(targetLibraryID, { skipTags: !copyTags });
newNote.parentID = newItem.id;
await newNote.save({ skipSelect: true });
if (targetLibrary.filesEditable) {
await Zotero.Notes.copyEmbeddedImages(note, newNote);
}
await newNote.addLinkedItem(note);
}
}
// Child attachments
if (copyChildLinks || copyChildFileAttachments) {
for (let attachment of Zotero.Items.get(item.getAttachments())) {
let linkMode = attachment.attachmentLinkMode;
// Skip linked files
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
Zotero.debug("Skipping child linked file attachment on copy");
continue;
}
// Skip imported files if we don't have pref and permissions
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_URL) {
if (!copyChildLinks) {
Zotero.debug("Skipping child link attachment on copy");
continue;
}
}
else if (!copyChildFileAttachments || !targetLibrary.filesEditable) {
Zotero.debug("Skipping child file attachment on copy");
continue;
}
let newAttachment = await Zotero.Attachments.copyAttachmentToLibrary(
attachment, targetLibraryID, newItem.id
);
if (copyAnnotations) {
await this.copyChildItems(attachment, newAttachment);
}
}
}
return newItem;
};
/**
* Copy child items from one item to another (e.g., in another library)
*

View file

@ -153,6 +153,73 @@ describe("Zotero.Items", function () {
});
describe("#copyToLibrary()", function () {
var group;
var copyChildNotes;
before(async function () {
group = await createGroup();
});
beforeEach(function () {
copyChildNotes = Zotero.Prefs.get('groups.copyChildNotes');
Zotero.Prefs.set('groups.copyChildNotes', true);
});
afterEach(function () {
Zotero.Prefs.set('groups.copyChildNotes', copyChildNotes);
});
after(async function () {
await group.eraseTx();
});
it("should copy and link a regular item into a group and reuse it", async function () {
var item = await createDataObject('item');
var newItem;
await Zotero.DB.executeTransaction(async function () {
newItem = await Zotero.Items.copyToLibrary(item, group.libraryID);
});
assert.instanceOf(newItem, Zotero.Item);
assert.equal(newItem.libraryID, group.libraryID);
assert.equal(await item.getLinkedItem(group.libraryID, true), newItem);
var reusedItem;
await Zotero.DB.executeTransaction(async function () {
reusedItem = await Zotero.Items.copyToLibrary(item, group.libraryID);
});
assert.strictEqual(reusedItem, newItem);
});
it("should copy a child note", async function () {
var item = await createDataObject('item');
var note = await createDataObject('item', { itemType: 'note', parentID: item.id });
var newItem;
await Zotero.DB.executeTransaction(async function () {
newItem = await Zotero.Items.copyToLibrary(item, group.libraryID);
});
var notes = Zotero.Items.get(newItem.getNotes());
assert.lengthOf(notes, 1);
assert.equal(await note.getLinkedItem(group.libraryID, true), notes[0]);
});
it("should honor disabled child-note copying", async function () {
var item = await createDataObject('item');
await createDataObject('item', { itemType: 'note', parentID: item.id });
var newItem;
await Zotero.DB.executeTransaction(async function () {
newItem = await Zotero.Items.copyToLibrary(item, group.libraryID, {
childNotes: false,
});
});
assert.isEmpty(newItem.getNotes());
});
});
describe("#copyChildItems()", function () {
var group;