From d8bc9c298286126d9f78194e3ea26a212696d798 Mon Sep 17 00:00:00 2001 From: zadam Date: Wed, 3 May 2023 10:23:20 +0200 Subject: [PATCH] attachment actions --- src/becca/entities/abstract_becca_entity.js | 8 +- src/becca/entities/battachment.js | 7 + src/becca/entities/bnote.js | 5 + src/public/app/layouts/desktop_layout.js | 4 +- src/public/app/services/file_watcher.js | 36 +++- src/public/app/services/froca_updater.js | 10 +- src/public/app/services/image.js | 32 +++ src/public/app/services/link.js | 2 - src/public/app/services/open.js | 84 +++++--- src/public/app/widgets/attachment_detail.js | 5 + .../widgets/buttons/attachments_actions.js | 35 +++- .../app/widgets/buttons/note_actions.js | 6 +- src/public/app/widgets/note_update_status.js | 64 ------ .../ribbon_widgets/image_properties.js | 2 +- src/public/app/widgets/type_widgets/image.js | 35 +--- .../app/widgets/watched_file_update_status.js | 96 +++++++++ src/routes/api/files.js | 183 +++++++++++++----- src/routes/api/notes.js | 24 --- src/routes/custom.js | 14 +- src/routes/routes.js | 14 +- src/services/notes.js | 23 ++- src/services/utils.js | 24 +-- 22 files changed, 466 insertions(+), 247 deletions(-) create mode 100644 src/public/app/services/image.js delete mode 100644 src/public/app/widgets/note_update_status.js create mode 100644 src/public/app/widgets/watched_file_update_status.js diff --git a/src/becca/entities/abstract_becca_entity.js b/src/becca/entities/abstract_becca_entity.js index 072455b50..5e49b7c93 100644 --- a/src/becca/entities/abstract_becca_entity.js +++ b/src/becca/entities/abstract_becca_entity.js @@ -136,6 +136,7 @@ class AbstractBeccaEntity { // client code asks to save entity even if blobId didn't change (something else was changed) opts.forceSave = !!opts.forceSave; opts.forceCold = !!opts.forceCold; + opts.forceFrontendReload = !!opts.forceFrontendReload; if (content === null || content === undefined) { throw new Error(`Cannot set null content to ${this.constructor.primaryKeyName} '${this[this.constructor.primaryKeyName]}'`); @@ -176,7 +177,7 @@ class AbstractBeccaEntity { } /** @protected */ - _saveBlob(content, unencryptedContentForHashCalculation, opts) { + _saveBlob(content, unencryptedContentForHashCalculation, opts = {}) { let newBlobId; let blobNeedsInsert; @@ -212,7 +213,10 @@ class AbstractBeccaEntity { hash: hash, isErased: false, utcDateChanged: pojo.utcDateModified, - isSynced: true + isSynced: true, + // overriding componentId will cause frontend to think the change is coming from a different component + // and thus reload + componentId: opts.forceFrontendReload ? utils.randomString(10) : null }); eventService.emit(eventService.ENTITY_CHANGED, { diff --git a/src/becca/entities/battachment.js b/src/becca/entities/battachment.js index 2f60d54ec..adc84380a 100644 --- a/src/becca/entities/battachment.js +++ b/src/becca/entities/battachment.js @@ -96,6 +96,7 @@ class BAttachment extends AbstractBeccaEntity { * @param {object} [opts] * @param {object} [opts.forceSave=false] - will also save this BAttachment entity * @param {object} [opts.forceCold=false] - blob has to be saved as cold + * @param {object} [opts.forceFrontendReload=false] - override frontend heuristics on when to reload, instruct to reload */ setContent(content, opts) { this._setContent(content, opts); @@ -154,6 +155,12 @@ class BAttachment extends AbstractBeccaEntity { return { note, branch }; } + getFileName() { + const type = this.role === 'image' ? 'image' : 'file'; + + return utils.formatDownloadTitle(this.title, type, this.mime); + } + beforeSaving() { super.beforeSaving(); diff --git a/src/becca/entities/bnote.js b/src/becca/entities/bnote.js index 0fd93ab06..ac56bceb2 100644 --- a/src/becca/entities/bnote.js +++ b/src/becca/entities/bnote.js @@ -242,6 +242,7 @@ class BNote extends AbstractBeccaEntity { * @param {object} [opts] * @param {object} [opts.forceSave=false] - will also save this BNote entity * @param {object} [opts.forceCold=false] - blob has to be saved as cold + * @param {object} [opts.forceFrontendReload=false] - override frontend heuristics on when to reload, instruct to reload */ setContent(content, opts) { this._setContent(content, opts); @@ -1642,6 +1643,10 @@ class BNote extends AbstractBeccaEntity { return attachment; } + getFileName() { + return utils.formatDownloadTitle(this.title, this.type, this.mime); + } + beforeSaving() { super.beforeSaving(); diff --git a/src/public/app/layouts/desktop_layout.js b/src/public/app/layouts/desktop_layout.js index 17dcc99a7..cff7f91cc 100644 --- a/src/public/app/layouts/desktop_layout.js +++ b/src/public/app/layouts/desktop_layout.js @@ -22,7 +22,7 @@ import NoteIconWidget from "../widgets/note_icon.js"; import SearchResultWidget from "../widgets/search_result.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js"; import RootContainer from "../widgets/containers/root_container.js"; -import NoteUpdateStatusWidget from "../widgets/note_update_status.js"; +import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; import SpacerWidget from "../widgets/spacer.js"; import QuickSearchWidget from "../widgets/quick_search.js"; import SplitNoteContainer from "../widgets/containers/split_note_container.js"; @@ -150,7 +150,7 @@ export default class DesktopLayout { .button(new NoteActionsWidget()) ) .child(new SharedInfoWidget()) - .child(new NoteUpdateStatusWidget()) + .child(new WatchedFileUpdateStatusWidget()) .child(new FloatingButtons() .child(new EditButton()) .child(new CodeButtonsWidget()) diff --git a/src/public/app/services/file_watcher.js b/src/public/app/services/file_watcher.js index 74d135b73..a0db524cc 100644 --- a/src/public/app/services/file_watcher.js +++ b/src/public/app/services/file_watcher.js @@ -1,18 +1,33 @@ import ws from "./ws.js"; import appContext from "../components/app_context.js"; -const fileModificationStatus = {}; +const fileModificationStatus = { + notes: {}, + attachments: {} +}; -function getFileModificationStatus(noteId) { - return fileModificationStatus[noteId]; +function checkType(type) { + if (type !== 'notes' && type !== 'attachments') { + throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`); + } } -function fileModificationUploaded(noteId) { - delete fileModificationStatus[noteId]; +function getFileModificationStatus(entityType, entityId) { + checkType(entityType); + + return fileModificationStatus[entityType][entityId]; } -function ignoreModification(noteId) { - delete fileModificationStatus[noteId]; +function fileModificationUploaded(entityType, entityId) { + checkType(entityType); + + delete fileModificationStatus[entityType][entityId]; +} + +function ignoreModification(entityType, entityId) { + checkType(entityType); + + delete fileModificationStatus[entityType][entityId]; } ws.subscribeToMessages(async message => { @@ -20,10 +35,13 @@ ws.subscribeToMessages(async message => { return; } - fileModificationStatus[message.noteId] = message; + checkType(message.entityType); + + fileModificationStatus[message.entityType][message.entityId] = message; appContext.triggerEvent('openedFileUpdated', { - noteId: message.noteId, + entityType: message.entityType, + entityId: message.entityId, lastModifiedMs: message.lastModifiedMs, filePath: message.filePath }); diff --git a/src/public/app/services/froca_updater.js b/src/public/app/services/froca_updater.js index f6b5f35b8..0b5314fd6 100644 --- a/src/public/app/services/froca_updater.js +++ b/src/public/app/services/froca_updater.js @@ -39,7 +39,7 @@ async function processEntityChanges(entityChanges) { // NOOP } else { - throw new Error(`Unknown entityName ${ec.entityName}`); + throw new Error(`Unknown entityName '${ec.entityName}'`); } } catch (e) { @@ -92,7 +92,7 @@ function processNoteChange(loadResults, ec) { loadResults.addNote(ec.entityId, ec.componentId); if (ec.isErased && ec.entityId in froca.notes) { - utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`); + utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); return; } @@ -106,7 +106,7 @@ function processNoteChange(loadResults, ec) { async function processBranchChange(loadResults, ec) { if (ec.isErased && ec.entityId in froca.branches) { - utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`); + utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); return; } @@ -192,7 +192,7 @@ function processAttributeChange(loadResults, ec) { let attribute = froca.attributes[ec.entityId]; if (ec.isErased && ec.entityId in froca.attributes) { - utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`); + utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); return; } @@ -241,7 +241,7 @@ function processAttributeChange(loadResults, ec) { function processAttachment(loadResults, ec) { if (ec.isErased && ec.entityId in froca.attachments) { - utils.reloadFrontendApp(`${ec.entityName} ${ec.entityId} is erased, need to do complete reload.`); + utils.reloadFrontendApp(`${ec.entityName} '${ec.entityId}' is erased, need to do complete reload.`); return; } diff --git a/src/public/app/services/image.js b/src/public/app/services/image.js new file mode 100644 index 000000000..9e1f358d3 --- /dev/null +++ b/src/public/app/services/image.js @@ -0,0 +1,32 @@ +import toastService from "./toast.js"; + +function copyImageReferenceToClipboard($imageWrapper) { + try { + $imageWrapper.attr('contenteditable', 'true'); + selectImage($imageWrapper.get(0)); + + const success = document.execCommand('copy'); + + if (success) { + toastService.showMessage("Image copied to the clipboard"); + } else { + toastService.showAndLogError("Could not copy the image to clipboard."); + } + } + finally { + window.getSelection().removeAllRanges(); + $imageWrapper.removeAttr('contenteditable'); + } +} + +function selectImage(element) { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection.removeAllRanges(); + selection.addRange(range); +} + +export default { + copyImageReferenceToClipboard +}; diff --git a/src/public/app/services/link.js b/src/public/app/services/link.js index 4a63443fe..3e5e1c3d0 100644 --- a/src/public/app/services/link.js +++ b/src/public/app/services/link.js @@ -125,8 +125,6 @@ function calculateHash({notePath, ntxId, hoistedNoteId, viewScope = {}}) { hash += `?${paramStr}`; } - console.log(hash); - return hash; } diff --git a/src/public/app/services/open.js b/src/public/app/services/open.js index b1df01dd7..a6205173a 100644 --- a/src/public/app/services/open.js +++ b/src/public/app/services/open.js @@ -1,11 +1,22 @@ import utils from "./utils.js"; import server from "./server.js"; -function getFileUrl(noteId) { - return getUrlForDownload(`api/notes/${noteId}/download`); +function checkType(type) { + if (type !== 'notes' && type !== 'attachments') { + throw new Error(`Unrecognized type '${type}', should be 'notes' or 'attachments'`); + } } -function getOpenFileUrl(noteId) { - return getUrlForDownload(`api/notes/${noteId}/open`); + +function getFileUrl(type, noteId) { + checkType(type); + + return getUrlForDownload(`api/${type}/${noteId}/download`); +} + +function getOpenFileUrl(type, noteId) { + checkType(type); + + return getUrlForDownload(`api/${type}/${noteId}/open`); } function download(url) { @@ -19,32 +30,15 @@ function download(url) { } function downloadFileNote(noteId) { - const url = `${getFileUrl(noteId)}?${Date.now()}`; // don't use cache + const url = `${getFileUrl('notes', noteId)}?${Date.now()}`; // don't use cache download(url); } -async function openNoteExternally(noteId, mime) { - if (utils.isElectron()) { - const resp = await server.post(`notes/${noteId}/save-to-tmp-dir`); +function downloadAttachment(attachmentId) { + const url = `${getFileUrl('attachments', attachmentId)}?${Date.now()}`; // don't use cache - const electron = utils.dynamicRequire('electron'); - const res = await electron.shell.openPath(resp.tmpFilePath); - - if (res) { - // fallback in case there's no default application for this file - open(getFileUrl(noteId), {url: true}); - } - } - else { - // allow browser to handle opening common file - if (mime === "application/pdf" || mime.startsWith("image") || mime.startsWith("audio") || mime.startsWith("video")){ - window.open(getOpenFileUrl(noteId)); - } - else { - window.location.href = getFileUrl(noteId); - } - } + download(url); } function downloadNoteRevision(noteId, noteRevisionId) { @@ -67,6 +61,40 @@ function getUrlForDownload(url) { } } +function canOpenInBrowser(mime) { + return mime === "application/pdf" + || mime.startsWith("image") + || mime.startsWith("audio") + || mime.startsWith("video"); +} + +async function openExternally(type, entityId, mime) { + checkType(type); + + if (utils.isElectron()) { + const resp = await server.post(`${type}/${entityId}/save-to-tmp-dir`); + + const electron = utils.dynamicRequire('electron'); + const res = await electron.shell.openPath(resp.tmpFilePath); + + if (res) { + // fallback in case there's no default application for this file + window.open(getFileUrl(type, entityId), { url: true }); + } + } + else { + // allow browser to handle opening common file + if (canOpenInBrowser(mime)) { + window.open(getOpenFileUrl(type, entityId)); + } else { + window.location.href = getFileUrl(type, entityId); + } + } +} + +const openNoteExternally = async (noteId, mime) => await openExternally('notes', noteId, mime); +const openAttachmentExternally = async (attachmentId, mime) => await openExternally('attachments', attachmentId, mime); + function getHost() { const url = new URL(window.location.href); return `${url.protocol}//${url.hostname}:${url.port}`; @@ -75,7 +103,9 @@ function getHost() { export default { download, downloadFileNote, - openNoteExternally, downloadNoteRevision, - getUrlForDownload + downloadAttachment, + getUrlForDownload, + openNoteExternally, + openAttachmentExternally, } diff --git a/src/public/app/widgets/attachment_detail.js b/src/public/app/widgets/attachment_detail.js index 2995e2f61..4030ba2c6 100644 --- a/src/public/app/widgets/attachment_detail.js +++ b/src/public/app/widgets/attachment_detail.js @@ -3,6 +3,7 @@ import AttachmentActionsWidget from "./buttons/attachments_actions.js"; import BasicWidget from "./basic_widget.js"; import server from "../services/server.js"; import options from "../services/options.js"; +import imageService from "../services/image.js"; const TPL = `
@@ -148,6 +149,10 @@ export default class AttachmentDetailWidget extends BasicWidget { } } + copyAttachmentReferenceToClipboard() { + imageService.copyImageReferenceToClipboard(this.$wrapper.find('.attachment-content')); + } + async entitiesReloadedEvent({loadResults}) { const attachmentChange = loadResults.getAttachments().find(att => att.attachmentId === this.attachment.attachmentId); diff --git a/src/public/app/widgets/buttons/attachments_actions.js b/src/public/app/widgets/buttons/attachments_actions.js index 497bd9117..745fb8d1d 100644 --- a/src/public/app/widgets/buttons/attachments_actions.js +++ b/src/public/app/widgets/buttons/attachments_actions.js @@ -4,6 +4,7 @@ import dialogService from "../../services/dialog.js"; import toastService from "../../services/toast.js"; import ws from "../../services/ws.js"; import appContext from "../../components/app_context.js"; +import openService from "../../services/open.js"; const TPL = ` `; @@ -40,9 +48,30 @@ export default class AttachmentActionsWidget extends BasicWidget { this.attachment = attachment; } + get attachmentId() { + return this.attachment.attachmentId; + } + doRender() { this.$widget = $(TPL); this.$widget.on('click', '.dropdown-item', () => this.$widget.find("[data-toggle='dropdown']").dropdown('toggle')); + this.$widget.find("[data-trigger-command='copyAttachmentReferenceToClipboard']").toggle(this.attachment.role === 'image'); + } + + async openAttachmentCommand() { + await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime); + } + + async downloadAttachmentCommand() { + await openService.downloadAttachment(this.attachmentId); + } + + async copyAttachmentReferenceToClipboardCommand() { + this.parent.copyAttachmentReferenceToClipboard(); + } + + async openAttachmentExternallyCommand() { + await openService.openAttachmentExternally(this.attachmentId, this.attachment.mime); } async deleteAttachmentCommand() { @@ -50,7 +79,7 @@ export default class AttachmentActionsWidget extends BasicWidget { return; } - await server.remove(`attachments/${this.attachment.attachmentId}`); + await server.remove(`attachments/${this.attachmentId}`); toastService.showMessage(`Attachment '${this.attachment.title}' has been deleted.`); } @@ -59,7 +88,7 @@ export default class AttachmentActionsWidget extends BasicWidget { return; } - const {note: newNote} = await server.post(`attachments/${this.attachment.attachmentId}/convert-to-note`) + const {note: newNote} = await server.post(`attachments/${this.attachmentId}/convert-to-note`) toastService.showMessage(`Attachment '${this.attachment.title}' has been converted to note.`); await ws.waitForMaxKnownEntityChangeId(); await appContext.tabManager.getActiveContext().setNote(newNote.noteId); diff --git a/src/public/app/widgets/buttons/note_actions.js b/src/public/app/widgets/buttons/note_actions.js index b11f8d8a4..58eec0007 100644 --- a/src/public/app/widgets/buttons/note_actions.js +++ b/src/public/app/widgets/buttons/note_actions.js @@ -35,7 +35,11 @@ const TPL = ` Search in note Note source Note attachments - Open note externally + + + Open note externally + Import files Export note Delete note diff --git a/src/public/app/widgets/note_update_status.js b/src/public/app/widgets/note_update_status.js deleted file mode 100644 index b73febf0b..000000000 --- a/src/public/app/widgets/note_update_status.js +++ /dev/null @@ -1,64 +0,0 @@ -import NoteContextAwareWidget from "./note_context_aware_widget.js"; -import server from "../services/server.js"; -import fileWatcher from "../services/file_watcher.js"; - -const TPL = ` -`; - -export default class NoteUpdateStatusWidget extends NoteContextAwareWidget { - isEnabled() { - return super.isEnabled() - && !!fileWatcher.getFileModificationStatus(this.noteId); - } - - doRender() { - this.$widget = $(TPL); - - this.$filePath = this.$widget.find(".file-path"); - this.$fileLastModified = this.$widget.find(".file-last-modified"); - this.$fileUploadButton = this.$widget.find(".file-upload-button"); - - this.$fileUploadButton.on("click", async () => { - await server.post(`notes/${this.noteId}/upload-modified-file`, { - filePath: this.$filePath.text() - }); - - fileWatcher.fileModificationUploaded(this.noteId); - this.refresh(); - }); - - this.$ignoreThisChangeButton = this.$widget.find(".ignore-this-change-button"); - this.$ignoreThisChangeButton.on('click', () => { - fileWatcher.ignoreModification(this.noteId); - this.refresh(); - }); - } - - refreshWithNote(note) { - const status = fileWatcher.getFileModificationStatus(note.noteId); - - this.$filePath.text(status.filePath); - this.$fileLastModified.text(dayjs.unix(status.lastModifiedMs / 1000).format("HH:mm:ss")); - } - - openedFileUpdatedEvent(data) { - if (data.noteId === this.noteId) { - this.refresh(); - } - } -} diff --git a/src/public/app/widgets/ribbon_widgets/image_properties.js b/src/public/app/widgets/ribbon_widgets/image_properties.js index 9cf31bf65..9032d0d39 100644 --- a/src/public/app/widgets/ribbon_widgets/image_properties.js +++ b/src/public/app/widgets/ribbon_widgets/image_properties.js @@ -69,7 +69,7 @@ export default class ImagePropertiesWidget extends NoteContextAwareWidget { this.$fileSize = this.$widget.find(".image-filesize"); this.$openButton = this.$widget.find(".image-open"); - this.$openButton.on('click', () => openService.openNoteExternally(this.noteId, this.note.mime )); + this.$openButton.on('click', () => openService.openNoteExternally(this.noteId, this.note.mime)); this.$imageDownloadButton = this.$widget.find(".image-download"); this.$imageDownloadButton.on('click', () => openService.downloadFileNote(this.noteId)); diff --git a/src/public/app/widgets/type_widgets/image.js b/src/public/app/widgets/type_widgets/image.js index 467f9b1cd..d35f9b634 100644 --- a/src/public/app/widgets/type_widgets/image.js +++ b/src/public/app/widgets/type_widgets/image.js @@ -1,8 +1,8 @@ import utils from "../../services/utils.js"; -import toastService from "../../services/toast.js"; import TypeWidget from "./type_widget.js"; import libraryLoader from "../../services/library_loader.js"; import contextMenu from "../../menus/context_menu.js"; +import imageService from "../../services/image.js"; const TPL = `
@@ -73,7 +73,7 @@ class ImageTypeWidget extends TypeWidget { ], selectMenuItemHandler: ({command}) => { if (command === 'copyImageReferenceToClipboard') { - this.copyImageReferenceToClipboard(); + imageService.copyImageReferenceToClipboard(this.$imageWrapper); } else if (command === 'copyImageToClipboard') { const webContents = utils.dynamicRequire('@electron/remote').getCurrentWebContents(); utils.dynamicRequire('electron'); @@ -98,36 +98,7 @@ class ImageTypeWidget extends TypeWidget { return; } - this.copyImageReferenceToClipboard(); - } - - copyImageReferenceToClipboard() { - this.$imageWrapper.attr('contenteditable','true'); - - try { - this.selectImage(this.$imageWrapper.get(0)); - - const success = document.execCommand('copy'); - - if (success) { - toastService.showMessage("Image copied to the clipboard"); - } - else { - toastService.showAndLogError("Could not copy the image to clipboard."); - } - } - finally { - window.getSelection().removeAllRanges(); - this.$imageWrapper.removeAttr('contenteditable'); - } - } - - selectImage(element) { - const selection = window.getSelection(); - const range = document.createRange(); - range.selectNodeContents(element); - selection.removeAllRanges(); - selection.addRange(range); + imageService.copyImageReferenceToClipboard(this.$imageWrapper); } } diff --git a/src/public/app/widgets/watched_file_update_status.js b/src/public/app/widgets/watched_file_update_status.js new file mode 100644 index 000000000..f8aa8529c --- /dev/null +++ b/src/public/app/widgets/watched_file_update_status.js @@ -0,0 +1,96 @@ +import NoteContextAwareWidget from "./note_context_aware_widget.js"; +import server from "../services/server.js"; +import fileWatcher from "../services/file_watcher.js"; + +const TPL = ` +`; + +export default class WatchedFileUpdateStatusWidget extends NoteContextAwareWidget { + isEnabled() { + const { entityType, entityId } = this.getEntity(); + + console.log(entityType, entityId); + + return super.isEnabled() && !!fileWatcher.getFileModificationStatus(entityType, entityId); + } + + doRender() { + this.$widget = $(TPL); + + this.$filePath = this.$widget.find(".file-path"); + this.$fileLastModified = this.$widget.find(".file-last-modified"); + this.$fileUploadButton = this.$widget.find(".file-upload-button"); + + this.$fileUploadButton.on("click", async () => { + const { entityType, entityId } = this.getEntity(); + + await server.post(`${entityType}/${entityId}/upload-modified-file`, { + filePath: this.$filePath.text() + }); + + fileWatcher.fileModificationUploaded(entityType, entityId); + this.refresh(); + }); + + this.$ignoreThisChangeButton = this.$widget.find(".ignore-this-change-button"); + this.$ignoreThisChangeButton.on('click', () => { + const { entityType, entityId } = this.getEntity(); + + fileWatcher.ignoreModification(entityType, entityId); + this.refresh(); + }); + } + + refreshWithNote(note) { + const { entityType, entityId } = this.getEntity(); + const status = fileWatcher.getFileModificationStatus(entityType, entityId); + + console.log("status", status); + + this.$filePath.text(status.filePath); + this.$fileLastModified.text(dayjs.unix(status.lastModifiedMs / 1000).format("HH:mm:ss")); + } + + getEntity() { + if (!this.noteContext) { + return {}; + } + + const { viewScope } = this.noteContext; + + if (viewScope.viewMode === 'attachments' && viewScope.attachmentId) { + return { + entityType: 'attachments', + entityId: viewScope.attachmentId + }; + } else { + return { + entityType: 'notes', + entityId: this.noteId + }; + } + } + + openedFileUpdatedEvent(data) {console.log(data); + const { entityType, entityId } = this.getEntity(); + + if (data.entityType === entityType && data.entityId === entityId) { + this.refresh(); + } + } +} diff --git a/src/routes/api/files.js b/src/routes/api/files.js index 7d4911916..90faba559 100644 --- a/src/routes/api/files.js +++ b/src/routes/api/files.js @@ -11,6 +11,7 @@ const chokidar = require('chokidar'); const ws = require('../../services/ws'); const becca = require("../../becca/becca"); const NotFoundError = require("../../errors/not_found_error"); +const ValidationError = require("../../errors/validation_error.js"); function updateFile(req) { const {noteId} = req.params; @@ -38,61 +39,84 @@ function updateFile(req) { }; } -function getFilename(note) { - // (one) reason we're not using the originFileName (available as label) is that it's not - // available for older note revisions and thus would be inconsistent - return utils.formatDownloadTitle(note.title, note.type, note.mime); +/** + * @param {BNote|BAttachment} noteOrAttachment + * @param res + * @param {boolean} contentDisposition + */ +function downloadData(noteOrAttachment, res, contentDisposition) { + if (noteOrAttachment.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { + return res.status(401).send("Protected session not available"); + } + + if (contentDisposition) { + const fileName = noteOrAttachment.getFileName(); + + res.setHeader('Content-Disposition', utils.getContentDisposition(fileName)); + } + + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader('Content-Type', noteOrAttachment.mime); + + res.send(noteOrAttachment.getContent()); } -function downloadNoteFile(noteId, res, contentDisposition = true) { +function downloadNoteInt(noteId, res, contentDisposition = true) { const note = becca.getNote(noteId); if (!note) { return res.setHeader("Content-Type", "text/plain") .status(404) - .send(`Note ${noteId} doesn't exist.`); + .send(`Note '${noteId}' doesn't exist.`); } - if (note.isProtected && !protectedSessionService.isProtectedSessionAvailable()) { - return res.status(401).send("Protected session not available"); + return downloadData(note, res, contentDisposition); +} + +function downloadAttachmentInt(attachmentId, res, contentDisposition = true) { + const attachment = becca.getAttachment(attachmentId); + + if (!attachment) { + return res.setHeader("Content-Type", "text/plain") + .status(404) + .send(`Attachment '${attachmentId}' doesn't exist.`); } - if (contentDisposition) { - const filename = getFilename(note); - - res.setHeader('Content-Disposition', utils.getContentDisposition(filename)); - } - - res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); - res.setHeader('Content-Type', note.mime); - - res.send(note.getContent()); + return downloadData(attachment, res, contentDisposition); } -function downloadFile(req, res) { - const noteId = req.params.noteId; +const downloadFile = (req, res) => downloadNoteInt(req.params.noteId, res, true); +const openFile = (req, res) => downloadNoteInt(req.params.noteId, res, false); - return downloadNoteFile(noteId, res); -} - -function openFile(req, res) { - const noteId = req.params.noteId; - - return downloadNoteFile(noteId, res, false); -} +const downloadAttachment = (req, res) => downloadAttachmentInt(req.params.attachmentId, res, true); +const openAttachment = (req, res) => downloadAttachmentInt(req.params.attachmentId, res, false); function fileContentProvider(req) { // Read file name from route params. const note = becca.getNote(req.params.noteId); - const fileName = getFilename(note); - let content = note.getContent(); + if (!note) { + throw new NotFoundError(`Note '${req.params.noteId}' doesn't exist.`); + } + return streamContent(note.getContent(), note.getFileName(), note.mime); +} + +function attachmentContentProvider(req) { + // Read file name from route params. + const attachment = becca.getAttachment(req.params.attachmentId); + if (!attachment) { + throw new NotFoundError(`Attachment '${req.params.attachmentId}' doesn't exist.`); + } + + return streamContent(attachment.getContent(), attachment.getFileName(), attachment.mime); +} + +function streamContent(content, fileName, mimeType) { if (typeof content === "string") { - content = Buffer.from(content, 'utf8'); + content = Buffer.from(content, 'utf8'); } const totalSize = content.byteLength; - const mimeType = note.mime; const getStream = range => { if (!range) { @@ -100,7 +124,7 @@ function fileContentProvider(req) { return Readable.from(content); } // Partial content request. - const { start, end } = range; + const {start, end} = range; return Readable.from(content.slice(start, end + 1)); } @@ -113,27 +137,44 @@ function fileContentProvider(req) { }; } -function saveToTmpDir(req) { - const noteId = req.params.noteId; - - const note = becca.getNote(noteId); - +function saveNoteToTmpDir(req) { + const note = becca.getNote(req.params.noteId); if (!note) { - throw new NotFoundError(`Note '${noteId}' doesn't exist.`); + throw new NotFoundError(`Note '${req.params.noteId}' doesn't exist.`); } - const tmpObj = tmp.fileSync({postfix: getFilename(note)}); + const fileName = note.getFileName(); + const content = note.getContent(); - fs.writeSync(tmpObj.fd, note.getContent()); + return saveToTmpDir(fileName, content, 'notes', note.noteId); +} + +function saveAttachmentToTmpDir(req) { + const attachment = becca.getAttachment(req.params.attachmentId); + if (!attachment) { + throw new NotFoundError(`Attachment '${req.params.attachmentId}' doesn't exist.`); + } + + const fileName = attachment.getFileName(); + const content = attachment.getContent(); + + return saveToTmpDir(fileName, content, 'attachments', attachment.attachmentId); +} + +function saveToTmpDir(fileName, content, entityType, entityId) { + const tmpObj = tmp.fileSync({ postfix: fileName }); + + fs.writeSync(tmpObj.fd, content); fs.closeSync(tmpObj.fd); - log.info(`Saved temporary file for note ${noteId} into ${tmpObj.name}`); + log.info(`Saved temporary file ${tmpObj.name}`); if (utils.isElectron()) { chokidar.watch(tmpObj.name).on('change', (path, stats) => { ws.sendMessageToAllClients({ type: 'openedFileUpdated', - noteId: noteId, + entityType: entityType, + entityId: entityId, lastModifiedMs: stats.atimeMs, filePath: tmpObj.name }); @@ -145,11 +186,63 @@ function saveToTmpDir(req) { }; } +function uploadModifiedFileToNote(req) { + const noteId = req.params.noteId; + const {filePath} = req.body; + + const note = becca.getNote(noteId); + + if (!note) { + throw new NotFoundError(`Note '${noteId}' has not been found`); + } + + log.info(`Updating note '${noteId}' with content from '${filePath}'`); + + note.saveNoteRevision(); + + const fileContent = fs.readFileSync(filePath); + + if (!fileContent) { + throw new ValidationError(`File '${fileContent}' is empty`); + } + + note.setContent(fileContent); +} + +function uploadModifiedFileToAttachment(req) { + const {attachmentId} = req.params; + const {filePath} = req.body; + + const attachment = becca.getAttachment(attachmentId); + + if (!attachment) { + throw new NotFoundError(`Attachment '${attachmentId}' has not been found`); + } + + log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`); + + attachment.getNote().saveNoteRevision(); + + const fileContent = fs.readFileSync(filePath); + + if (!fileContent) { + throw new ValidationError(`File '${fileContent}' is empty`); + } + + attachment.setContent(fileContent); +} + module.exports = { updateFile, openFile, fileContentProvider, downloadFile, - downloadNoteFile, - saveToTmpDir + downloadNoteInt, + saveNoteToTmpDir, + openAttachment, + downloadAttachment, + saveAttachmentToTmpDir, + attachmentContentProvider, + uploadModifiedFileToNote, + uploadModifiedFileToAttachment }; diff --git a/src/routes/api/notes.js b/src/routes/api/notes.js index 9663d0536..97e81411d 100644 --- a/src/routes/api/notes.js +++ b/src/routes/api/notes.js @@ -229,29 +229,6 @@ function getDeleteNotesPreview(req) { }; } -function uploadModifiedFile(req) { - const noteId = req.params.noteId; - const {filePath} = req.body; - - const note = becca.getNote(noteId); - - if (!note) { - throw new NotFoundError(`Note '${noteId}' has not been found`); - } - - log.info(`Updating note '${noteId}' with content from ${filePath}`); - - note.saveNoteRevision(); - - const fileContent = fs.readFileSync(filePath); - - if (!fileContent) { - throw new ValidationError(`File '${fileContent}' is empty`); - } - - note.setContent(fileContent); -} - function forceSaveNoteRevision(req) { const {noteId} = req.params; const note = becca.getNote(noteId); @@ -294,7 +271,6 @@ module.exports = { eraseDeletedNotesNow, eraseUnusedAttachmentsNow, getDeleteNotesPreview, - uploadModifiedFile, forceSaveNoteRevision, convertNoteToAttachment }; diff --git a/src/routes/custom.js b/src/routes/custom.js index f32f7445f..c99db4066 100644 --- a/src/routes/custom.js +++ b/src/routes/custom.js @@ -1,5 +1,5 @@ const log = require('../services/log'); -const fileUploadService = require('./api/files'); +const fileService = require('./api/files'); const scriptService = require('../services/script'); const cls = require('../services/cls'); const sql = require("../services/sql"); @@ -26,7 +26,7 @@ function handleRequest(req, res) { match = path.match(regex); } catch (e) { - log.error(`Testing path for label ${attr.attributeId}, regex=${attr.value} failed with error ${e.stack}`); + log.error(`Testing path for label '${attr.attributeId}', regex '${attr.value}' failed with error: ${e.message}, stack: ${e.stack}`); continue; } @@ -37,7 +37,7 @@ function handleRequest(req, res) { if (attr.name === 'customRequestHandler') { const note = attr.getNote(); - log.info(`Handling custom request "${path}" with note ${note.noteId}`); + log.info(`Handling custom request '${path}' with note '${note.noteId}'`); try { scriptService.executeNote(note, { @@ -47,7 +47,7 @@ function handleRequest(req, res) { }); } catch (e) { - log.error(`Custom handler ${note.noteId} failed with ${e.message}`); + log.error(`Custom handler '${note.noteId}' failed with: ${e.message}, ${e.stack}`); res.setHeader("Content-Type", "text/plain") .status(500) @@ -55,16 +55,16 @@ function handleRequest(req, res) { } } else if (attr.name === 'customResourceProvider') { - fileUploadService.downloadNoteFile(attr.noteId, res); + fileService.downloadNoteInt(attr.noteId, res); } else { - throw new Error(`Unrecognized attribute name ${attr.name}`); + throw new Error(`Unrecognized attribute name '${attr.name}'`); } return; // only first handler is executed } - const message = `No handler matched for custom ${path} request.`; + const message = `No handler matched for custom '${path}' request.`; log.info(message); res.setHeader("Content-Type", "text/plain") diff --git a/src/routes/routes.js b/src/routes/routes.js index 8e8dcc61d..ff9ab57be 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -122,7 +122,6 @@ function register(app) { apiRoute(PUT, '/api/notes/:noteId/type', notesApiRoute.setNoteTypeMime); apiRoute(PUT, '/api/notes/:noteId/title', notesApiRoute.changeTitle); apiRoute(PST, '/api/notes/:noteId/duplicate/:parentNoteId', notesApiRoute.duplicateSubtree); - apiRoute(PST, '/api/notes/:noteId/upload-modified-file', notesApiRoute.uploadModifiedFile); apiRoute(PUT, '/api/notes/:noteId/clone-to-branch/:parentBranchId', cloningApiRoute.cloneNoteToBranch); apiRoute(PUT, '/api/notes/:noteId/toggle-in-parent/:parentNoteId/:present', cloningApiRoute.toggleNoteInParent); apiRoute(PUT, '/api/notes/:noteId/clone-to-note/:parentNoteId', cloningApiRoute.cloneNoteToParentNote); @@ -137,7 +136,8 @@ function register(app) { route(GET, '/api/notes/:noteId/download', [auth.checkApiAuthOrElectron], filesRoute.downloadFile); // this "hacky" path is used for easier referencing of CSS resources route(GET, '/api/notes/download/:noteId', [auth.checkApiAuthOrElectron], filesRoute.downloadFile); - apiRoute(PST, '/api/notes/:noteId/save-to-tmp-dir', filesRoute.saveToTmpDir); + apiRoute(PST, '/api/notes/:noteId/save-to-tmp-dir', filesRoute.saveNoteToTmpDir); + apiRoute(PST, '/api/notes/:noteId/upload-modified-file', filesRoute.uploadModifiedFileToNote); apiRoute(PST, '/api/notes/:noteId/convert-to-attachment', notesApiRoute.convertNoteToAttachment); apiRoute(PUT, '/api/branches/:branchId/move-to/:parentBranchId', branchesApiRoute.moveBranchToParent); @@ -154,6 +154,16 @@ function register(app) { apiRoute(PST, '/api/attachments/:attachmentId/convert-to-note', attachmentsApiRoute.convertAttachmentToNote); apiRoute(DEL, '/api/attachments/:attachmentId', attachmentsApiRoute.deleteAttachment); route(GET, '/api/attachments/:attachmentId/image/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnAttachedImage); + route(GET, '/api/attachments/:attachmentId/open', [auth.checkApiAuthOrElectron], filesRoute.openAttachment); + route(GET, '/api/attachments/:attachmentId/open-partial', [auth.checkApiAuthOrElectron], + createPartialContentHandler(filesRoute.attachmentContentProvider, { + debug: (string, extra) => { console.log(string, extra); } + })); + route(GET, '/api/attachments/:attachmentId/download', [auth.checkApiAuthOrElectron], filesRoute.downloadAttachment); + // this "hacky" path is used for easier referencing of CSS resources + route(GET, '/api/attachments/download/:attachmentId', [auth.checkApiAuthOrElectron], filesRoute.downloadAttachment); + apiRoute(PST, '/api/attachments/:attachmentId/save-to-tmp-dir', filesRoute.saveAttachmentToTmpDir); + apiRoute(PST, '/api/attachments/:attachmentId/upload-modified-file', filesRoute.uploadModifiedFileToAttachment); apiRoute(GET, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.getNoteRevisions); apiRoute(DEL, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.eraseAllNoteRevisions); diff --git a/src/services/notes.js b/src/services/notes.js index 796c89070..091d2f6e9 100644 --- a/src/services/notes.js +++ b/src/services/notes.js @@ -362,8 +362,9 @@ function checkImageAttachments(note, content) { const existingAttachmentIds = new Set(imageAttachments.map(att => att.attachmentId)); const unknownAttachmentIds = Array.from(foundAttachmentIds).filter(foundAttId => !existingAttachmentIds.has(foundAttId)); + const unknownAttachments = becca.getAttachments(unknownAttachmentIds); - for (const unknownAttachment of becca.getAttachments(unknownAttachmentIds)) { + for (const unknownAttachment of unknownAttachments) { // the attachment belongs to a different note (was copy pasted), we need to make a copy for this note. const newAttachment = unknownAttachment.copy(); newAttachment.parentId = note.noteId; @@ -374,7 +375,10 @@ function checkImageAttachments(note, content) { log.info(`Copied attachment '${unknownAttachment.attachmentId}' to new '${newAttachment.attachmentId}'`); } - return content; + return { + forceFrontendReload: unknownAttachments.length > 0, + content + }; } @@ -591,6 +595,7 @@ function saveLinks(note, content) { } const foundLinks = []; + let forceFrontendReload = false; if (note.type === 'text') { content = downloadImages(note.noteId, content); @@ -599,7 +604,7 @@ function saveLinks(note, content) { content = findInternalLinks(content, foundLinks); content = findIncludeNoteLinks(content, foundLinks); - content = checkImageAttachments(note, content); + ({forceFrontendReload, content} = checkImageAttachments(note, content)); } else if (note.type === 'relationMap') { findRelationMapLinks(content, foundLinks); @@ -643,7 +648,7 @@ function saveLinks(note, content) { unusedLink.markAsDeleted(); } - return content; + return { forceFrontendReload, content }; } /** @param {BNote} note */ @@ -677,9 +682,9 @@ function updateNoteData(noteId, content) { saveNoteRevisionIfNeeded(note); - content = saveLinks(note, content); + const { forceFrontendReload, content: newContent } = saveLinks(note, content); - note.setContent(content); + note.setContent(newContent, { forceFrontendReload }); } /** @@ -780,15 +785,15 @@ function scanForLinks(note, content) { try { sql.transactional(() => { - const newContent = saveLinks(note, content); + const { forceFrontendReload, content: newContent } = saveLinks(note, content); if (content !== newContent) { - note.setContent(newContent); + note.setContent(newContent, { forceFrontendReload }); } }); } catch (e) { - log.error(`Could not scan for links note ${note.noteId}: ${e.message} ${e.stack}`); + log.error(`Could not scan for links note '${note.noteId}': ${e.message} ${e.stack}`); } } diff --git a/src/services/utils.js b/src/services/utils.js index 5a9cdb7a5..77a48e379 100644 --- a/src/services/utils.js +++ b/src/services/utils.js @@ -199,33 +199,33 @@ function replaceAll(string, replaceWhat, replaceWith) { return string.replace(new RegExp(quotedReplaceWhat, "g"), replaceWith); } -function formatDownloadTitle(filename, type, mime) { - if (!filename) { - filename = "untitled"; +function formatDownloadTitle(fileName, type, mime) { + if (!fileName) { + fileName = "untitled"; } - filename = sanitize(filename); + fileName = sanitize(fileName); if (type === 'text') { - return `${filename}.html`; + return `${fileName}.html`; } else if (['relationMap', 'canvas', 'search'].includes(type)) { - return `${filename}.json`; + return `${fileName}.json`; } else { if (!mime) { - return filename; + return fileName; } mime = mime.toLowerCase(); - const filenameLc = filename.toLowerCase(); + const filenameLc = fileName.toLowerCase(); const extensions = mimeTypes.extensions[mime]; if (!extensions || extensions.length === 0) { - return filename; + return fileName; } for (const ext of extensions) { if (filenameLc.endsWith(`.${ext}`)) { - return filename; + return fileName; } } @@ -234,10 +234,10 @@ function formatDownloadTitle(filename, type, mime) { // the current name without fake extension. It's possible that the title still preserves to correct // extension too - return filename; + return fileName; } - return `${filename}.${extensions[0]}`; + return `${fileName}.${extensions[0]}`; } }