From 2bc78ccafb35085fd09e8847f9e875ad46c8d5c6 Mon Sep 17 00:00:00 2001 From: zadam Date: Sat, 1 Apr 2023 23:55:04 +0200 Subject: [PATCH] wip attachment widget --- db/schema.sql | 13 ++- src/becca/becca.js | 2 +- src/becca/entities/bnote.js | 56 ++++++++- src/public/app/services/froca_updater.js | 5 +- src/public/app/services/load_results.js | 13 ++- src/public/app/widgets/attachment_detail.js | 39 ++++++- .../widgets/buttons/attachments_actions.js | 25 +++- .../app/widgets/type_widgets/attachments.js | 19 ++- src/routes/api/attachments.js | 108 ++++++++++++++++++ src/routes/api/notes.js | 69 +---------- src/routes/routes.js | 9 +- src/services/ws.js | 2 + 12 files changed, 268 insertions(+), 92 deletions(-) create mode 100644 src/routes/api/attachments.js diff --git a/db/schema.sql b/db/schema.sql index 2dafcdbd1..9f1941680 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -35,13 +35,12 @@ CREATE TABLE IF NOT EXISTS "notes" ( `isProtected` INT NOT NULL DEFAULT 0, `type` TEXT NOT NULL DEFAULT 'text', `mime` TEXT NOT NULL DEFAULT 'text/html', - `blobId` TEXT DEFAULT NULL, `isDeleted` INT NOT NULL DEFAULT 0, `deleteId` TEXT DEFAULT NULL, `dateCreated` TEXT NOT NULL, `dateModified` TEXT NOT NULL, `utcDateCreated` TEXT NOT NULL, - `utcDateModified` TEXT NOT NULL + `utcDateModified` TEXT NOT NULL, blobId TEXT DEFAULT NULL, PRIMARY KEY(`noteId`)); CREATE TABLE IF NOT EXISTS "note_revisions" (`noteRevisionId` TEXT NOT NULL PRIMARY KEY, `noteId` TEXT NOT NULL, @@ -49,12 +48,11 @@ CREATE TABLE IF NOT EXISTS "note_revisions" (`noteRevisionId` TEXT NOT NULL PRIM mime TEXT DEFAULT '' NOT NULL, `title` TEXT NOT NULL, `isProtected` INT NOT NULL DEFAULT 0, - `blobId` TEXT DEFAULT NULL, `utcDateLastEdited` TEXT NOT NULL, `utcDateCreated` TEXT NOT NULL, `utcDateModified` TEXT NOT NULL, `dateLastEdited` TEXT NOT NULL, - `dateCreated` TEXT NOT NULL); + `dateCreated` TEXT NOT NULL, blobId TEXT DEFAULT NULL); CREATE TABLE IF NOT EXISTS "options" ( name TEXT not null PRIMARY KEY, @@ -104,6 +102,13 @@ CREATE TABLE IF NOT EXISTS "recent_notes" notePath TEXT not null, utcDateCreated TEXT not null ); +CREATE TABLE IF NOT EXISTS "blobs" ( + `blobId` TEXT NOT NULL, + `content` TEXT NULL DEFAULT NULL, + `dateModified` TEXT NOT NULL, + `utcDateModified` TEXT NOT NULL, + PRIMARY KEY(`blobId`) +); CREATE TABLE IF NOT EXISTS "attachments" ( attachmentId TEXT not null primary key, diff --git a/src/becca/becca.js b/src/becca/becca.js index 42ec60da3..1b3b72d4f 100644 --- a/src/becca/becca.js +++ b/src/becca/becca.js @@ -123,7 +123,7 @@ class Becca { /** @returns {BAttachment|null} */ getAttachment(attachmentId) { - const row = sql.getRow("SELECT * FROM attachments WHERE attachmentId = ?", [attachmentId]); + const row = sql.getRow("SELECT * FROM attachments WHERE attachmentId = ? AND isDeleted = 0", [attachmentId]); const BAttachment = require("./entities/battachment"); // avoiding circular dependency problems return row ? new BAttachment(row) : null; diff --git a/src/becca/entities/bnote.js b/src/becca/entities/bnote.js index 69ecffa0b..adb42b24b 100644 --- a/src/becca/entities/bnote.js +++ b/src/becca/entities/bnote.js @@ -1352,7 +1352,6 @@ class BNote extends AbstractBeccaEntity { * * @returns {BAttachment|null} - null if note is not eligible for conversion */ - convertToParentAttachment(opts = {force: false}) { if (this.type !== 'image' || !this.isContentAvailable() || this.hasChildren() || this.getParentBranches().length !== 1) { return null; @@ -1394,6 +1393,61 @@ class BNote extends AbstractBeccaEntity { return attachment; } + /** + * @param attachmentId + * @returns {{note: BNote, branch: BBranch}} + */ + convertAttachmentToChildNote(attachmentId) { + if (this.type === 'search') { + throw new Error(`Note of type search cannot have child notes`); + } + + const attachment = this.getAttachmentById(attachmentId); + + if (!attachment) { + throw new NotFoundError(`Attachment '${attachmentId} of note '${this.noteId}' doesn't exist.`); + } + + const attachmentRoleToNoteTypeMapping = { + 'image': 'image' + }; + + if (!(attachment.role in attachmentRoleToNoteTypeMapping)) { + throw new Error(`Mapping from attachment role '${attachment.role}' to note's type is not defined`); + } + + if (!this.isContentAvailable()) { // isProtected is same for attachment + throw new Error(`Cannot convert protected attachment outside of protected session`); + } + + const noteService = require('../../services/notes'); + + const {note, branch} = noteService.createNewNote({ + parentNoteId: this.noteId, + title: attachment.title, + type: attachmentRoleToNoteTypeMapping[attachment.role], + mime: attachment.mime, + content: attachment.getContent(), + isProtected: this.isProtected + }); + + attachment.markAsDeleted(); + + if (attachment.role === 'image' && this.type === 'text') { + const origContent = this.getContent(); + const oldAttachmentUrl = `api/notes/${this.noteId}/images/${attachment.attachmentId}/`; + const newNoteUrl = `api/images/${note.noteId}/`; + + const fixedContent = utils.replaceAll(origContent, oldAttachmentUrl, newNoteUrl); + + if (origContent !== fixedContent) { + this.setContent(fixedContent); + } + } + + return { note, branch }; + } + /** * (Soft) delete a note and all its descendants. * diff --git a/src/public/app/services/froca_updater.js b/src/public/app/services/froca_updater.js index f0587e4a6..d02af030e 100644 --- a/src/public/app/services/froca_updater.js +++ b/src/public/app/services/froca_updater.js @@ -33,8 +33,9 @@ async function processEntityChanges(entityChanges) { options.set(ec.entity.name, ec.entity.value); loadResults.addOption(ec.entity.name); - } - else if (['etapi_tokens', 'attachments'].includes(ec.entityName)) { + } else if (ec.entityName === 'attachments') { + loadResults.addAttachment(ec.entity); + } else if (ec.entityName === 'etapi_tokens') { // NOOP } else { diff --git a/src/public/app/services/load_results.js b/src/public/app/services/load_results.js index 6e8fe4245..fe71831aa 100644 --- a/src/public/app/services/load_results.js +++ b/src/public/app/services/load_results.js @@ -23,6 +23,8 @@ export default class LoadResults { this.contentNoteIdToComponentId = []; this.options = []; + + this.attachments = []; } getEntity(entityName, entityId) { @@ -116,6 +118,14 @@ export default class LoadResults { return this.options.includes(name); } + addAttachment(attachment) { + this.attachments.push(attachment); + } + + getAttachments() { + return this.attachments; + } + /** * @returns {boolean} true if there are changes which could affect the attributes (including inherited ones) * notably changes in note itself should not have any effect on attributes @@ -132,7 +142,8 @@ export default class LoadResults { && this.noteReorderings.length === 0 && this.noteRevisions.length === 0 && this.contentNoteIdToComponentId.length === 0 - && this.options.length === 0; + && this.options.length === 0 + && this.attachments.length === 0; } isEmptyForTree() { diff --git a/src/public/app/widgets/attachment_detail.js b/src/public/app/widgets/attachment_detail.js index 7a100a2c6..0ec796415 100644 --- a/src/public/app/widgets/attachment_detail.js +++ b/src/public/app/widgets/attachment_detail.js @@ -1,6 +1,7 @@ -import utils from "../../services/utils.js"; -import AttachmentActionsWidget from "../buttons/attachments_actions.js"; +import utils from "../services/utils.js"; +import AttachmentActionsWidget from "./buttons/attachments_actions.js"; import BasicWidget from "./basic_widget.js"; +import server from "../services/server.js"; const TPL = `
@@ -38,7 +39,7 @@ const TPL = `

-
+
@@ -50,6 +51,7 @@ export default class AttachmentDetailWidget extends BasicWidget { constructor(attachment) { super(); + this.contentSized(); this.attachment = attachment; this.attachmentActionsWidget = new AttachmentActionsWidget(attachment); this.child(this.attachmentActionsWidget); @@ -57,14 +59,25 @@ export default class AttachmentDetailWidget extends BasicWidget { doRender() { this.$widget = $(TPL); + this.refresh(); + + super.doRender(); + } + + refresh() { + this.$widget.find('.attachment-detail-wrapper') + .empty() + .append( + $(TPL) + .find('.attachment-detail-wrapper') + .html() + ); this.$wrapper = this.$widget.find('.attachment-detail-wrapper'); this.$wrapper.find('.attachment-title').text(this.attachment.title); this.$wrapper.find('.attachment-details') .text(`Role: ${this.attachment.role}, Size: ${utils.formatSize(this.attachment.contentLength)}`); this.$wrapper.find('.attachment-actions-container').append(this.attachmentActionsWidget.render()); this.$wrapper.find('.attachment-content').append(this.renderContent()); - - super.doRender(); } renderContent() { @@ -76,4 +89,20 @@ export default class AttachmentDetailWidget extends BasicWidget { return ''; } } + + async entitiesReloadedEvent({loadResults}) { + console.log("AttachmentDetailWidget: entitiesReloadedEvent"); + + const attachmentChange = loadResults.getAttachments().find(att => att.attachmentId === this.attachment.attachmentId); + + if (attachmentChange) { + if (attachmentChange.isDeleted) { + this.toggleInt(false); + } else { + this.attachment = await server.get(`notes/${this.attachment.parentId}/attachments/${this.attachment.attachmentId}?includeContent=true`); + + this.refresh(); + } + } + } } diff --git a/src/public/app/widgets/buttons/attachments_actions.js b/src/public/app/widgets/buttons/attachments_actions.js index bfae9cec3..053b37661 100644 --- a/src/public/app/widgets/buttons/attachments_actions.js +++ b/src/public/app/widgets/buttons/attachments_actions.js @@ -1,6 +1,9 @@ import BasicWidget from "../basic_widget.js"; import server from "../../services/server.js"; 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"; const TPL = ` `; @@ -46,6 +49,20 @@ export default class AttachmentActionsWidget extends BasicWidget { async deleteAttachmentCommand() { if (await dialogService.confirm(`Are you sure you want to delete attachment '${this.attachment.title}'?`)) { await server.remove(`notes/${this.attachment.parentId}/attachments/${this.attachment.attachmentId}`); + + toastService.showMessage(`Attachment '${this.attachment.title}' has been deleted.`); + } + } + + async convertAttachmentIntoNoteCommand() { + if (await dialogService.confirm(`Are you sure you want to convert attachment '${this.attachment.title}' into a separate note?`)) { + const {note: newNote} = await server.post(`notes/${this.attachment.parentId}/attachments/${this.attachment.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/type_widgets/attachments.js b/src/public/app/widgets/type_widgets/attachments.js index f0a6afcc4..b90d55409 100644 --- a/src/public/app/widgets/type_widgets/attachments.js +++ b/src/public/app/widgets/type_widgets/attachments.js @@ -1,7 +1,5 @@ import TypeWidget from "./type_widget.js"; import server from "../../services/server.js"; -import utils from "../../services/utils.js"; -import AttachmentActionsWidget from "../buttons/attachments_actions.js"; import AttachmentDetailWidget from "../attachment_detail.js"; const TPL = ` @@ -16,7 +14,9 @@ const TPL = `
`; export default class AttachmentsTypeWidget extends TypeWidget { - static getType() { return "attachments"; } + static getType() { + return "attachments"; + } doRender() { this.$widget = $(TPL); @@ -28,6 +28,7 @@ export default class AttachmentsTypeWidget extends TypeWidget { async doRefresh(note) { this.$list.empty(); this.children = []; + this.renderedAttachmentIds = new Set(); const attachments = await server.get(`notes/${this.noteId}/attachments?includeContent=true`); @@ -41,7 +42,19 @@ export default class AttachmentsTypeWidget extends TypeWidget { const attachmentDetailWidget = new AttachmentDetailWidget(attachment); this.child(attachmentDetailWidget); + this.renderedAttachmentIds.add(attachment.attachmentId); + this.$list.append(attachmentDetailWidget.render()); } } + + async entitiesReloadedEvent({loadResults}) { + // updates and deletions are handled by the detail, for new attachments the whole list has to be refreshed + const attachmentsAdded = loadResults.getAttachments() + .find(att => this.renderedAttachmentIds.has(att.attachmentId)); + + if (attachmentsAdded) { + this.refresh(); + } + } } diff --git a/src/routes/api/attachments.js b/src/routes/api/attachments.js new file mode 100644 index 000000000..818a254d1 --- /dev/null +++ b/src/routes/api/attachments.js @@ -0,0 +1,108 @@ +const becca = require("../../becca/becca"); +const NotFoundError = require("../../errors/not_found_error"); +const utils = require("../../services/utils"); +const noteService = require("../../services/notes"); + +function getAttachments(req) { + const includeContent = req.query.includeContent === 'true'; + const {noteId} = req.params; + + const note = becca.getNote(noteId); + + if (!note) { + throw new NotFoundError(`Note '${noteId}' doesn't exist.`); + } + + return note.getAttachments() + .map(attachment => processAttachment(attachment, includeContent)); +} + +function getAttachment(req) { + const includeContent = req.query.includeContent === 'true'; + const {noteId, attachmentId} = req.params; + + const note = becca.getNote(noteId); + + if (!note) { + throw new NotFoundError(`Note '${noteId}' doesn't exist.`); + } + + const attachment = note.getAttachmentById(attachmentId); + + if (!attachment) { + throw new NotFoundError(`Attachment '${attachmentId} of note '${noteId}' doesn't exist.`); + } + + return processAttachment(attachment, includeContent); +} + +function processAttachment(attachment, includeContent) { + const pojo = attachment.getPojo(); + + if (includeContent) { + if (utils.isStringNote(null, attachment.mime)) { + pojo.content = attachment.getContent()?.toString(); + pojo.contentLength = pojo.content.length; + + const MAX_ATTACHMENT_LENGTH = 1_000_000; + + if (pojo.content.length > MAX_ATTACHMENT_LENGTH) { + pojo.content = pojo.content.substring(0, MAX_ATTACHMENT_LENGTH); + } + } else { + const content = attachment.getContent(); + pojo.contentLength = content?.length; + } + } + + return pojo; +} + +function saveAttachment(req) { + const {noteId} = req.params; + const {attachmentId, role, mime, title, content} = req.body; + + const note = becca.getNote(noteId); + + if (!note) { + throw new NotFoundError(`Note '${noteId}' doesn't exist.`); + } + + note.saveAttachment({attachmentId, role, mime, title, content}); +} + +function deleteAttachment(req) { + const {noteId, attachmentId} = req.params; + + const note = becca.getNote(noteId); + + if (!note) { + throw new NotFoundError(`Note '${noteId}' doesn't exist.`); + } + + const attachment = note.getAttachmentById(attachmentId); + + if (attachment) { + attachment.markAsDeleted(); + } +} + +function convertAttachmentToNote(req) { + const {noteId, attachmentId} = req.params; + + const note = becca.getNote(noteId); + + if (!note) { + throw new NotFoundError(`Note '${noteId}' doesn't exist.`); + } + + return note.convertAttachmentToChildNote(attachmentId); +} + +module.exports = { + getAttachments, + getAttachment, + saveAttachment, + deleteAttachment, + convertAttachmentToNote +}; diff --git a/src/routes/api/notes.js b/src/routes/api/notes.js index ce27e6b86..5f62e5ae8 100644 --- a/src/routes/api/notes.js +++ b/src/routes/api/notes.js @@ -127,70 +127,6 @@ function setNoteTypeMime(req) { note.save(); } -function getAttachments(req) { - const includeContent = req.query.includeContent === 'true'; - const {noteId} = req.params; - - const note = becca.getNote(noteId); - - if (!note) { - throw new NotFoundError(`Note '${noteId}' doesn't exist.`); - } - - const attachments = note.getAttachments(); - - return attachments.map(attachment => { - const pojo = attachment.getPojo(); - - if (includeContent) { - if (utils.isStringNote(null, attachment.mime)) { - pojo.content = attachment.getContent()?.toString(); - pojo.contentLength = pojo.content.length; - - const MAX_ATTACHMENT_LENGTH = 1_000_000; - - if (pojo.content.length > MAX_ATTACHMENT_LENGTH) { - pojo.content = pojo.content.substring(0, MAX_ATTACHMENT_LENGTH); - } - } else { - const content = attachment.getContent(); - pojo.contentLength = content?.length; - } - } - - return pojo; - }); -} - -function saveAttachment(req) { - const {noteId} = req.params; - const {attachmentId, role, mime, title, content} = req.body; - - const note = becca.getNote(noteId); - - if (!note) { - throw new NotFoundError(`Note '${noteId}' doesn't exist.`); - } - - note.saveAttachment({attachmentId, role, mime, title, content}); -} - -function deleteAttachment(req) { - const {noteId, attachmentId} = req.params; - - const note = becca.getNote(noteId); - - if (!note) { - throw new NotFoundError(`Note '${noteId}' doesn't exist.`); - } - - const attachment = note.getAttachmentById(attachmentId); - - if (attachment) { - attachment.markAsDeleted(); - } -} - function getRelationMap(req) { const {relationMapNoteId, noteIds} = req.body; @@ -404,8 +340,5 @@ module.exports = { eraseDeletedNotesNow, getDeleteNotesPreview, uploadModifiedFile, - forceSaveNoteRevision, - getAttachments, - saveAttachment, - deleteAttachment + forceSaveNoteRevision }; diff --git a/src/routes/routes.js b/src/routes/routes.js index 5787eaeae..035d16775 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -25,6 +25,7 @@ const indexRoute = require('./index'); const treeApiRoute = require('./api/tree'); const notesApiRoute = require('./api/notes'); const branchesApiRoute = require('./api/branches'); +const attachmentsApiRoute = require('./api/attachments'); const autocompleteApiRoute = require('./api/autocomplete'); const cloningApiRoute = require('./api/cloning'); const noteRevisionsApiRoute = require('./api/note_revisions'); @@ -126,9 +127,11 @@ function register(app) { apiRoute(PUT, '/api/notes/:noteId/sort-children', notesApiRoute.sortChildNotes); apiRoute(PUT, '/api/notes/:noteId/protect/:isProtected', notesApiRoute.protectNote); apiRoute(PUT, '/api/notes/:noteId/type', notesApiRoute.setNoteTypeMime); - apiRoute(GET, '/api/notes/:noteId/attachments', notesApiRoute.getAttachments); - apiRoute(POST, '/api/notes/:noteId/attachments', notesApiRoute.saveAttachment); - apiRoute(DELETE, '/api/notes/:noteId/attachments/:attachmentId', notesApiRoute.deleteAttachment); + apiRoute(GET, '/api/notes/:noteId/attachments', attachmentsApiRoute.getAttachments); + apiRoute(GET, '/api/notes/:noteId/attachments/:attachmentId', attachmentsApiRoute.getAttachment); + apiRoute(POST, '/api/notes/:noteId/attachments', attachmentsApiRoute.saveAttachment); + apiRoute(POST, '/api/notes/:noteId/attachments/:attachmentId/convert-to-note', attachmentsApiRoute.convertAttachmentToNote); + apiRoute(DELETE, '/api/notes/:noteId/attachments/:attachmentId', attachmentsApiRoute.deleteAttachment); apiRoute(GET, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.getNoteRevisions); apiRoute(DELETE, '/api/notes/:noteId/revisions', noteRevisionsApiRoute.eraseAllNoteRevisions); apiRoute(GET, '/api/notes/:noteId/revisions/:noteRevisionId', noteRevisionsApiRoute.getNoteRevision); diff --git a/src/services/ws.js b/src/services/ws.js index b54a8fb02..acd9ec573 100644 --- a/src/services/ws.js +++ b/src/services/ws.js @@ -137,6 +137,8 @@ function fillInAdditionalProperties(entityChange) { } } else if (entityChange.entityName === 'blobs') { entityChange.noteIds = sql.getColumn("SELECT noteId FROM notes WHERE blobId = ? AND isDeleted = 0", [entityChange.entityId]); + } else if (entityChange.entityName === 'attachments') { + entityChange.entity = sql.getRow(`SELECT * FROM attachments WHERE attachmentId = ?`, [entityChange.entityId]); } if (entityChange.entity instanceof AbstractBeccaEntity) {