diff --git a/chrome/content/zotero/xpcom/attachments.js b/chrome/content/zotero/xpcom/attachments.js index 25a29609e5..9b4e2f2a55 100644 --- a/chrome/content/zotero/xpcom/attachments.js +++ b/chrome/content/zotero/xpcom/attachments.js @@ -445,6 +445,40 @@ Zotero.Attachments = new function(){ }; + /** + * Copy an image from one note to another + * + * @param {Object} params + * @param {Zotero.Item} params.attachment - Image attachment to copy + * @param {Zotero.Item} params.note - Note item to add attachment to + * @param {Object} [params.saveOptions] - Options to pass to Zotero.Item::save() + * @return {Promise} + */ + this.copyEmbeddedImage = async function ({ attachment, note, saveOptions }) { + Zotero.DB.requireTransaction(); + + if (!attachment.isEmbeddedImageAttachment()) { + throw new Error("'attachment' must be an embedded image"); + } + + if (!await attachment.fileExists()) { + throw new Error("Image attachment file doesn't exist"); + } + + var newAttachment = attachment.clone(note.libraryID); + // Attachment path isn't copied over by clone() if libraryID is different + newAttachment.attachmentPath = attachment.attachmentPath; + newAttachment.parentID = note.id; + await newAttachment.save(saveOptions); + + let dir = Zotero.Attachments.getStorageDirectory(attachment); + let newDir = await Zotero.Attachments.createDirectoryForItem(newAttachment); + await Zotero.File.copyDirectory(dir, newDir); + + return newAttachment; + }; + + /** * @param {Object} options * @param {Integer} options.libraryID diff --git a/chrome/content/zotero/xpcom/collectionTreeView.js b/chrome/content/zotero/xpcom/collectionTreeView.js index b299d7a758..b1956dafb9 100644 --- a/chrome/content/zotero/xpcom/collectionTreeView.js +++ b/chrome/content/zotero/xpcom/collectionTreeView.js @@ -2043,6 +2043,7 @@ Zotero.CollectionTreeView.prototype.drop = Zotero.Promise.coroutine(function* (r yield newItem.addLinkedItem(item); if (item.isNote()) { + yield Zotero.Notes.copyEmbeddedImages(item, newItem); return newItemID; } @@ -2058,7 +2059,7 @@ Zotero.CollectionTreeView.prototype.drop = Zotero.Promise.coroutine(function* (r yield newNote.save({ skipSelect: true }) - + yield Zotero.Notes.copyEmbeddedImages(note, newNote); yield newNote.addLinkedItem(note); } } diff --git a/chrome/content/zotero/xpcom/data/notes.js b/chrome/content/zotero/xpcom/data/notes.js index 3a2911b854..d8d9c93bcb 100644 --- a/chrome/content/zotero/xpcom/data/notes.js +++ b/chrome/content/zotero/xpcom/data/notes.js @@ -190,6 +190,92 @@ Zotero.Notes = new function() { return doc.body.innerHTML; }; + /** + * Download embedded images if they don't exist locally + * + * @param {Zotero.Item} item + * @returns {Promise} + */ + this.ensureEmbeddedImagesAreAvailable = async function (item) { + var attachments = Zotero.Items.get(item.getAttachments()); + for (let attachment of attachments) { + let path = await attachment.getFilePathAsync(); + if (!path) { + Zotero.debug(`Image file not found for item ${attachment.key}. Trying to download`); + let fileSyncingEnabled = Zotero.Sync.Storage.Local.getEnabledForLibrary(item.libraryID); + if (!fileSyncingEnabled) { + Zotero.debug('File sync is disabled'); + return false; + } + + try { + let results = await Zotero.Sync.Runner.downloadFile(attachment); + if (!results || !results.localChanges) { + Zotero.debug('Download failed'); + return false; + } + } + catch (e) { + Zotero.debug(e); + return false; + } + } + } + return true; + }; + + /** + * Copy embedded images from one note to another and update + * item keys in note HTML. + * + * Must be called after copying a note + * + * @param {Zotero.Item} fromNote + * @param {Zotero.Item} toNote + * @returns {Promise} + */ + this.copyEmbeddedImages = async function (fromNote, toNote) { + Zotero.DB.requireTransaction(); + + let attachments = Zotero.Items.get(fromNote.getAttachments()); + if (!attachments.length) { + return; + } + + let note = toNote.note; + let parser = Components.classes['@mozilla.org/xmlextras/domparser;1'] + .createInstance(Components.interfaces.nsIDOMParser); + let doc = parser.parseFromString(note, 'text/html'); + + // Copy note image attachments and replace keys in the new note + for (let attachment of attachments) { + if (await attachment.fileExists()) { + let copiedAttachment = await Zotero.Attachments.copyEmbeddedImage({ attachment, note: toNote }); + let node = doc.querySelector(`img[data-attachment-key="${attachment.key}"]`); + if (node) { + node.setAttribute('data-attachment-key', copiedAttachment.key); + } + } + } + toNote.setNote(doc.body.innerHTML); + await toNote.save({ skipDateModifiedUpdate: true }); + }; + + this.promptToIgnoreMissingImage = function () { + let ps = Services.prompt; + let buttonFlags = ps.BUTTON_POS_0 * ps.BUTTON_TITLE_IS_STRING + + ps.BUTTON_POS_1 * ps.BUTTON_TITLE_CANCEL; + let index = ps.confirmEx( + null, + Zotero.getString('general.warning'), + Zotero.getString('pane.item.notes.ignoreMissingImage'), + buttonFlags, + Zotero.getString('general.continue'), + null, null, null, {} + ); + return !index; + }; + this.hasSchemaVersion = function (note) { let parser = Components.classes['@mozilla.org/xmlextras/domparser;1'] .createInstance(Components.interfaces.nsIDOMParser); @@ -294,9 +380,7 @@ Zotero.Notes = new function() { } schemaVersion++; metadataContainer.setAttribute('data-schema-version', schemaVersion); - note = doc.body.innerHTML; - note = note.trim(); - item.setNote(note); + item.setNote(doc.body.innerHTML); await item.saveTx({ skipDateModifiedUpdate: true }); return true; }; diff --git a/chrome/content/zotero/xpcom/editorInstance.js b/chrome/content/zotero/xpcom/editorInstance.js index 61e3aaf280..b289b3dafe 100644 --- a/chrome/content/zotero/xpcom/editorInstance.js +++ b/chrome/content/zotero/xpcom/editorInstance.js @@ -115,6 +115,10 @@ class EditorInstance { ...Zotero.Intl.getPrefixedStrings('noteEditor.') } }); + + if (!this._item.isAttachment()) { + Zotero.Notes.ensureEmbeddedImagesAreAvailable(this._item); + } } uninit() { @@ -401,11 +405,16 @@ class EditorInstance { async _digestItems(ids) { let html = ''; - for (let id of ids) { - let item = await Zotero.Items.getAsync(id); - if (!item) { - continue; + let items = await Zotero.Items.getAsync(ids); + for (let item of items) { + if (item.isNote() + && !await Zotero.Notes.ensureEmbeddedImagesAreAvailable(item) + && !Zotero.Notes.promptToIgnoreMissingImage()) { + return null; } + } + + for (let item of items) { if (item.isRegularItem()) { let itemData = Zotero.Utilities.itemToCSLJSON(item); let citation = { @@ -483,27 +492,26 @@ class EditorInstance { } // Clone all note image attachments and replace keys in the new note - let attachments = await Zotero.Items.getAsync(item.getAttachments()); + let attachments = Zotero.Items.get(item.getAttachments()); for (let attachment of attachments) { - let path = await attachment.getFilePathAsync(); - let buf = await OS.File.read(path, {}); - buf = new Uint8Array(buf).buffer; - let blob = new (Zotero.getMainWindow()).Blob([buf], { type: attachment.attachmentContentType }); - // Image type is not additionally filtered because it was an attachment already - let clonedAttachment = await Zotero.Attachments.importEmbeddedImage({ - blob, - parentItemID: this._item.id, - saveOptions: { - notifierData: { - noteEditorID: this.instanceID + if (!await attachment.fileExists()) { + continue; + } + await Zotero.DB.executeTransaction(async () => { + let copiedAttachment = await Zotero.Attachments.copyEmbeddedImage({ + attachment, + note: this._item, + saveOptions: { + notifierData: { + noteEditorID: this.instanceID + } } + }); + let node = doc.querySelector(`img[data-attachment-key="${attachment.key}"]`); + if (node) { + node.setAttribute('data-attachment-key', copiedAttachment.key); } }); - - let node = doc.querySelector(`img[data-attachment-key="${attachment.key}"]`); - if (node) { - node.setAttribute('data-attachment-key', clonedAttachment.key); - } } html += `

${doc.body.innerHTML}

`; @@ -534,6 +542,9 @@ class EditorInstance { if (type === 'zotero/item') { let ids = data.split(',').map(id => parseInt(id)); html = await this._digestItems(ids); + if (!html) { + return; + } } else if (type === 'zotero/annotation') { let annotations = JSON.parse(data); diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js index 148462ff93..e307af98e4 100644 --- a/chrome/content/zotero/xpcom/sync/syncRunner.js +++ b/chrome/content/zotero/xpcom/sync/syncRunner.js @@ -880,8 +880,8 @@ Zotero.Sync.Runner_Module = function (options = {}) { return false; } - if (!item.isImportedAttachment()) { - throw new Error("Not an imported attachment"); + if (!item.isStoredFileAttachment()) { + throw new Error("Not a stored file attachment"); } if (yield item.getFilePathAsync()) { diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 8cd2ab9d7f..b8187b40f8 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -1784,6 +1784,12 @@ var ZoteroPane = new function() } var item = self.getSelectedItems()[0]; + if (item.isNote() + && !(yield Zotero.Notes.ensureEmbeddedImagesAreAvailable(item)) + && !Zotero.Notes.promptToIgnoreMissingImage()) { + return; + } + var newItem; yield Zotero.DB.executeTransaction(function* () { @@ -1793,6 +1799,9 @@ var ZoteroPane = new function() newItem.setCollections([self.collectionsView.selectedTreeRow.ref.id]); } yield newItem.save(); + if (item.isNote()) { + yield Zotero.Notes.copyEmbeddedImages(item, newItem); + } for (let relItemKey of item.relatedItems) { try { let relItem = yield Zotero.Items.getByLibraryAndKeyAsync(item.libraryID, relItemKey); diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index b7bdb448b4..62ea286a43 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -381,6 +381,7 @@ pane.item.notes.untitled = Untitled Note pane.item.notes.delete.confirm = Are you sure you want to delete this note? pane.item.notes.count = %1$S note;%1$S notes pane.item.notes.editingInWindow = Editing in separate window +pane.item.notes.ignoreMissingImage = Some note images are missing and cannot be copied. pane.item.attachments.rename.title = New title: pane.item.attachments.rename.renameAssociatedFile = Rename associated file pane.item.attachments.rename.error = An error occurred while renaming the file.