diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index a38c8c081..bbbc4ca3e 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -1115,13 +1115,75 @@ Zotero.Attachments = new function(){ /** - * Copy attachment item, including files, to another library + * Move attachment item, including file, to another library + */ + this.moveAttachmentToLibrary = async function (attachment, libraryID, parentItemID) { + if (attachment.libraryID == libraryID) { + throw new Error("Attachment is already in library " + libraryID); + } + + Zotero.DB.requireTransaction(); + + var newAttachment = attachment.clone(libraryID); + if (attachment.isImportedAttachment()) { + // Attachment path isn't copied over by clone() if libraryID is different + newAttachment.attachmentPath = attachment.attachmentPath; + } + if (parentItemID) { + newAttachment.parentID = parentItemID; + } + await newAttachment.save(); + + // Move files over if they exist + var oldDir; + var newDir; + if (newAttachment.isImportedAttachment()) { + oldDir = this.getStorageDirectory(attachment).path; + if (await OS.File.exists(oldDir)) { + newDir = this.getStorageDirectory(newAttachment).path; + // Target directory shouldn't exist, but remove it if it does + // + // Testing for directories in OS.File, used by removeDir(), is broken on Travis, + // so use nsIFile + if (Zotero.automatedTest) { + let nsIFile = Zotero.File.pathToFile(newDir); + if (nsIFile.exists()) { + nsIFile.remove(true); + } + } + else { + await OS.File.removeDir(newDir, { ignoreAbsent: true }); + } + await OS.File.move(oldDir, newDir); + } + } + + try { + await attachment.erase(); + } + catch (e) { + // Move files back if old item can't be deleted + if (newAttachment.isImportedAttachment()) { + try { + await OS.File.move(newDir, oldDir); + } + catch (e) { + Zotero.logError(e); + } + } + throw e; + } + + return newAttachment.id; + }; + + + /** + * Copy attachment item, including file, to another library */ this.copyAttachmentToLibrary = Zotero.Promise.coroutine(function* (attachment, libraryID, parentItemID) { - var linkMode = attachment.attachmentLinkMode; - if (attachment.libraryID == libraryID) { - throw ("Attachment is already in library " + libraryID); + throw new Error("Attachment is already in library " + libraryID); } Zotero.DB.requireTransaction(); diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index ab36b4ac8..e66a48cdb 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -3989,6 +3989,89 @@ Zotero.Item.prototype.clone = function (libraryID, options = {}) { } +/** + * @param {Zotero.Item} item + * @param {Integer} libraryID + * @return {Zotero.Item} - New item + */ +Zotero.Item.prototype.moveToLibrary = async function (libraryID, onSkippedAttachment) { + if (!this.isEditable) { + throw new Error("Can't move item in read-only library"); + } + var library = Zotero.Libraries.get(libraryID); + Zotero.debug("Moving item to " + library.name); + if (!library.editable) { + throw new Error("Can't move item to read-only library"); + } + var filesEditable = library.filesEditable; + var allowsLinkedFiles = library.allowsLinkedFiles; + + var newItem = await Zotero.DB.executeTransaction(async function () { + // Create new clone item in target library + var newItem = this.clone(libraryID); + var newItemID = await newItem.save({ + skipSelect: true + }); + + if (this.isNote()) { + // Delete old item + await this.erase(); + return newItem; + } + + // For regular items, add child items + + // Child notes + var noteIDs = this.getNotes(); + var notes = Zotero.Items.get(noteIDs); + for (let note of notes) { + let newNote = note.clone(libraryID); + newNote.parentID = newItemID; + await newNote.save({ + skipSelect: true + }); + } + + // Child attachments + var attachmentIDs = this.getAttachments(); + var attachments = Zotero.Items.get(attachmentIDs); + for (let attachment of attachments) { + let linkMode = attachment.attachmentLinkMode; + + // Skip linked files if not allowed in destination + if (!allowsLinkedFiles && linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) { + Zotero.debug("Target library doesn't support linked files -- skipping attachment"); + if (onSkippedAttachment) { + await onSkippedAttachment(attachment); + } + continue; + } + + // Skip files if not allowed in destination + if (!filesEditable && linkMode != Zotero.Attachments.LINK_MODE_LINKED_URL) { + Zotero.debug("Target library doesn't allow file editing -- skipping attachment"); + if (onSkippedAttachment) { + await onSkippedAttachment(attachment); + } + continue; + } + + await Zotero.Attachments.moveAttachmentToLibrary( + attachment, libraryID, newItemID + ); + } + + return newItem; + }.bind(this)); + + // Delete old item. Do this outside of a transaction so we don't leave stranded files + // in the target library if deleting fails. + await this.eraseTx(); + + return newItem; +}; + + Zotero.Item.prototype._eraseData = Zotero.Promise.coroutine(function* (env) { Zotero.DB.requireTransaction(); diff --git a/test/tests/itemTest.js b/test/tests/itemTest.js index deef846cb..63885fd05 100644 --- a/test/tests/itemTest.js +++ b/test/tests/itemTest.js @@ -1258,6 +1258,66 @@ describe("Zotero.Item", function () { }) }) + describe("#moveToLibrary()", function () { + it("should move items from My Library to a filesEditable group", async function () { + var group = await createGroup(); + + var item = await createDataObject('item'); + var attachment1 = await importFileAttachment('test.png', { parentID: item.id }); + var file = getTestDataDirectory(); + file.append('test.png'); + var attachment2 = await Zotero.Attachments.linkFromFile({ + file, + parentItemID: item.id + }); + var note = await createDataObject('item', { itemType: 'note', parentID: item.id }); + + var originalIDs = [item.id, attachment1.id, attachment2.id, note.id]; + var originalAttachmentFile = attachment1.getFilePath(); + var originalAttachmentHash = await attachment1.attachmentHash + + assert.isTrue(await OS.File.exists(originalAttachmentFile)); + + var newItem = await item.moveToLibrary(group.libraryID); + + // Old items and file should be gone + assert.isTrue(originalIDs.every(id => !Zotero.Items.get(id))); + assert.isFalse(await OS.File.exists(originalAttachmentFile)); + + // New items and stored file should exist; linked file should be gone + assert.equal(newItem.libraryID, group.libraryID); + assert.lengthOf(newItem.getAttachments(), 1); + var newAttachment = Zotero.Items.get(newItem.getAttachments()[0]); + assert.equal(await newAttachment.attachmentHash, originalAttachmentHash); + assert.lengthOf(newItem.getNotes(), 1); + }); + + it("should move items from My Library to a non-filesEditable group", async function () { + var group = await createGroup({ + filesEditable: false + }); + + var item = await createDataObject('item'); + var attachment = await importFileAttachment('test.png', { parentID: item.id }); + + var originalIDs = [item.id, attachment.id]; + var originalAttachmentFile = attachment.getFilePath(); + var originalAttachmentHash = await attachment.attachmentHash + + assert.isTrue(await OS.File.exists(originalAttachmentFile)); + + var newItem = await item.moveToLibrary(group.libraryID); + + // Old items and file should be gone + assert.isTrue(originalIDs.every(id => !Zotero.Items.get(id))); + assert.isFalse(await OS.File.exists(originalAttachmentFile)); + + // Parent should exist, but attachment should not + assert.equal(newItem.libraryID, group.libraryID); + assert.lengthOf(newItem.getAttachments(), 0); + }); + }); + describe("#toJSON()", function () { describe("default mode", function () { it("should output only fields with values", function* () {