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 = `
@@ -11,7 +14,7 @@ const TPL = `
}
.attachment-actions .dropdown-menu {
- width: 15em;
+ width: 20em;
}
.attachment-actions .dropdown-item[disabled], .attachment-actions .dropdown-item[disabled]:hover {
@@ -25,9 +28,9 @@ const TPL = `
aria-expanded="false" class="icon-action icon-action-always-border bx bx-dots-vertical-rounded">
`;
@@ -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) {