diff --git a/apps/server/etapi.openapi.yaml b/apps/server/etapi.openapi.yaml index f35d9ad92..af05bdbe5 100644 --- a/apps/server/etapi.openapi.yaml +++ b/apps/server/etapi.openapi.yaml @@ -337,6 +337,130 @@ paths: application/json; charset=utf-8: schema: $ref: "#/components/schemas/Error" + /notes/{noteId}/revisions: + parameters: + - name: noteId + in: path + required: true + schema: + $ref: "#/components/schemas/EntityId" + get: + description: Returns all revisions for a note identified by its ID + operationId: getNoteRevisions + responses: + "200": + description: list of revisions + content: + application/json; charset=utf-8: + schema: + type: array + items: + $ref: "#/components/schemas/Revision" + default: + description: unexpected error + content: + application/json; charset=utf-8: + schema: + $ref: "#/components/schemas/Error" + /notes/{noteId}/undelete: + parameters: + - name: noteId + in: path + required: true + schema: + $ref: "#/components/schemas/EntityId" + post: + description: Restore a deleted note. The note must be deleted and must have at least one undeleted parent. + operationId: undeleteNote + responses: + "200": + description: note restored successfully + content: + application/json; charset=utf-8: + schema: + type: object + properties: + success: + type: boolean + example: true + default: + description: unexpected error + content: + application/json; charset=utf-8: + schema: + $ref: "#/components/schemas/Error" + /notes/history: + get: + description: Returns recent changes including note creations, modifications, and deletions + operationId: getNoteHistory + parameters: + - name: ancestorNoteId + in: query + required: false + description: Limit changes to a subtree identified by this note ID. Defaults to "root" (all notes). + schema: + $ref: "#/components/schemas/EntityId" + responses: + "200": + description: list of recent changes + content: + application/json; charset=utf-8: + schema: + type: array + items: + $ref: "#/components/schemas/RecentChange" + default: + description: unexpected error + content: + application/json; charset=utf-8: + schema: + $ref: "#/components/schemas/Error" + /revisions/{revisionId}: + parameters: + - name: revisionId + in: path + required: true + schema: + $ref: "#/components/schemas/EntityId" + get: + description: Returns a revision identified by its ID + operationId: getRevisionById + responses: + "200": + description: revision response + content: + application/json; charset=utf-8: + schema: + $ref: "#/components/schemas/Revision" + default: + description: unexpected error + content: + application/json; charset=utf-8: + schema: + $ref: "#/components/schemas/Error" + /revisions/{revisionId}/content: + parameters: + - name: revisionId + in: path + required: true + schema: + $ref: "#/components/schemas/EntityId" + get: + description: Returns revision content identified by its ID + operationId: getRevisionContent + responses: + "200": + description: revision content response + content: + text/html: + schema: + type: string + default: + description: unexpected error + content: + application/json; charset=utf-8: + schema: + $ref: "#/components/schemas/Error" /branches: post: description: > @@ -1186,3 +1310,93 @@ components: type: string description: Human readable error, potentially with more details, example: Note 'evnnmvHTCgIn' is protected and cannot be modified through ETAPI + Revision: + type: object + description: Revision represents a snapshot of note's title and content at some point in the past. + properties: + revisionId: + $ref: "#/components/schemas/EntityId" + readOnly: true + noteId: + $ref: "#/components/schemas/EntityId" + readOnly: true + type: + type: string + enum: + [ + text, + code, + render, + file, + image, + search, + relationMap, + book, + noteMap, + mermaid, + webView, + shortcut, + doc, + contentWidget, + launcher, + ] + mime: + type: string + isProtected: + type: boolean + readOnly: true + title: + type: string + blobId: + type: string + description: ID of the blob object which effectively serves as a content hash + dateLastEdited: + $ref: "#/components/schemas/LocalDateTime" + readOnly: true + dateCreated: + $ref: "#/components/schemas/LocalDateTime" + readOnly: true + utcDateLastEdited: + $ref: "#/components/schemas/UtcDateTime" + readOnly: true + utcDateCreated: + $ref: "#/components/schemas/UtcDateTime" + readOnly: true + utcDateModified: + $ref: "#/components/schemas/UtcDateTime" + readOnly: true + contentLength: + type: integer + format: int32 + readOnly: true + RecentChange: + type: object + description: Represents a recent change event (creation, modification, or deletion). + properties: + noteId: + $ref: "#/components/schemas/EntityId" + readOnly: true + title: + type: string + description: Title at the time of the change (may be "[protected]" for protected notes) + current_title: + type: string + description: Current title of the note (may be "[protected]" for protected notes) + current_isDeleted: + type: boolean + description: Whether the note is currently deleted + current_deleteId: + type: string + description: Delete ID if the note is deleted + current_isProtected: + type: boolean + description: Whether the note is protected + utcDate: + $ref: "#/components/schemas/UtcDateTime" + description: UTC timestamp of the change + date: + $ref: "#/components/schemas/LocalDateTime" + description: Local timestamp of the change + canBeUndeleted: + type: boolean + description: Whether the note can be undeleted (only present for deleted notes) diff --git a/apps/server/spec/etapi/get-note-revisions.spec.ts b/apps/server/spec/etapi/get-note-revisions.spec.ts new file mode 100644 index 000000000..acf2bccf5 --- /dev/null +++ b/apps/server/spec/etapi/get-note-revisions.spec.ts @@ -0,0 +1,77 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; + +describe("etapi/get-note-revisions", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + createdNoteId = await createNote(app, token); + + // Create a revision by updating the note content + await supertest(app) + .put(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic" }) + .set("Content-Type", "text/plain") + .send("Updated content for revision") + .expect(204); + + // Force create a revision + await supertest(app) + .post(`/etapi/notes/${createdNoteId}/revision`) + .auth(USER, token, { "type": "basic" }) + .expect(204); + }); + + it("gets revisions for a note", async () => { + const response = await supertest(app) + .get(`/etapi/notes/${createdNoteId}/revisions`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + + const revision = response.body[0]; + expect(revision).toHaveProperty("revisionId"); + expect(revision).toHaveProperty("noteId", createdNoteId); + expect(revision).toHaveProperty("type"); + expect(revision).toHaveProperty("mime"); + expect(revision).toHaveProperty("title"); + expect(revision).toHaveProperty("isProtected"); + expect(revision).toHaveProperty("blobId"); + expect(revision).toHaveProperty("utcDateCreated"); + }); + + it("returns empty array for note with no revisions", async () => { + // Create a new note without any revisions + const newNoteId = await createNote(app, token, "Brand new content"); + + const response = await supertest(app) + .get(`/etapi/notes/${newNoteId}/revisions`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + // New notes may or may not have revisions depending on settings + }); + + it("returns 404 for non-existent note", async () => { + const response = await supertest(app) + .get("/etapi/notes/nonexistentnote/revisions") + .auth(USER, token, { "type": "basic" }) + .expect(404); + + expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND"); + }); +}); diff --git a/apps/server/spec/etapi/get-revision.spec.ts b/apps/server/spec/etapi/get-revision.spec.ts new file mode 100644 index 000000000..641f5255a --- /dev/null +++ b/apps/server/spec/etapi/get-revision.spec.ts @@ -0,0 +1,71 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; +let revisionId: string; + +describe("etapi/get-revision", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + createdNoteId = await createNote(app, token, "Initial content"); + + // Update content to create a revision + await supertest(app) + .put(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic" }) + .set("Content-Type", "text/plain") + .send("Updated content") + .expect(204); + + // Force create a revision + await supertest(app) + .post(`/etapi/notes/${createdNoteId}/revision`) + .auth(USER, token, { "type": "basic" }) + .expect(204); + + // Get the revision ID + const revisionsResponse = await supertest(app) + .get(`/etapi/notes/${createdNoteId}/revisions`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(revisionsResponse.body.length).toBeGreaterThan(0); + revisionId = revisionsResponse.body[0].revisionId; + }); + + it("gets revision metadata by ID", async () => { + const response = await supertest(app) + .get(`/etapi/revisions/${revisionId}`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(response.body).toHaveProperty("revisionId", revisionId); + expect(response.body).toHaveProperty("noteId", createdNoteId); + expect(response.body).toHaveProperty("type", "text"); + expect(response.body).toHaveProperty("mime", "text/html"); + expect(response.body).toHaveProperty("title", "Hello"); + expect(response.body).toHaveProperty("isProtected", false); + expect(response.body).toHaveProperty("blobId"); + expect(response.body).toHaveProperty("utcDateCreated"); + expect(response.body).toHaveProperty("utcDateModified"); + }); + + it("returns 404 for non-existent revision", async () => { + const response = await supertest(app) + .get("/etapi/revisions/nonexistentrevision") + .auth(USER, token, { "type": "basic" }) + .expect(404); + + expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND"); + }); +}); diff --git a/apps/server/spec/etapi/note-history.spec.ts b/apps/server/spec/etapi/note-history.spec.ts new file mode 100644 index 000000000..7696c0051 --- /dev/null +++ b/apps/server/spec/etapi/note-history.spec.ts @@ -0,0 +1,94 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; + +describe("etapi/note-history", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + + // Create a note to ensure there's some history + createdNoteId = await createNote(app, token, "History test content"); + + // Create a revision to ensure history has entries + await supertest(app) + .post(`/etapi/notes/${createdNoteId}/revision`) + .auth(USER, token, { "type": "basic" }) + .expect(204); + }); + + it("gets recent changes history", async () => { + const response = await supertest(app) + .get("/etapi/notes/history") + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + + // Check that history entries have expected properties + const entry = response.body[0]; + expect(entry).toHaveProperty("noteId"); + expect(entry).toHaveProperty("title"); + expect(entry).toHaveProperty("utcDate"); + expect(entry).toHaveProperty("date"); + expect(entry).toHaveProperty("current_isDeleted"); + expect(entry).toHaveProperty("current_isProtected"); + }); + + it("filters history by ancestor note", async () => { + const response = await supertest(app) + .get("/etapi/notes/history?ancestorNoteId=root") + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + // All results should be descendants of root (which is everything) + }); + + it("returns empty array for non-existent ancestor", async () => { + const response = await supertest(app) + .get("/etapi/notes/history?ancestorNoteId=nonexistentancestor") + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + // Should be empty since no notes are descendants of a non-existent note + expect(response.body.length).toBe(0); + }); + + it("includes canBeUndeleted for deleted notes", async () => { + // Create and delete a note + const noteToDeleteId = await createNote(app, token, "Note to delete for history test"); + + await supertest(app) + .delete(`/etapi/notes/${noteToDeleteId}`) + .auth(USER, token, { "type": "basic" }) + .expect(204); + + // Check history - deleted note should appear with canBeUndeleted property + const response = await supertest(app) + .get("/etapi/notes/history") + .auth(USER, token, { "type": "basic" }) + .expect(200); + + const deletedEntry = response.body.find( + (entry: any) => entry.noteId === noteToDeleteId && entry.current_isDeleted === true + ); + + // Deleted entries should have canBeUndeleted property + if (deletedEntry) { + expect(deletedEntry).toHaveProperty("canBeUndeleted"); + } + }); +}); diff --git a/apps/server/spec/etapi/revision-content.spec.ts b/apps/server/spec/etapi/revision-content.spec.ts new file mode 100644 index 000000000..5d7d5e558 --- /dev/null +++ b/apps/server/spec/etapi/revision-content.spec.ts @@ -0,0 +1,64 @@ +import { Application } from "express"; +import { beforeAll, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { createNote, login } from "./utils.js"; +import config from "../../src/services/config.js"; + +let app: Application; +let token: string; + +const USER = "etapi"; +let createdNoteId: string; +let revisionId: string; + +describe("etapi/revision-content", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + createdNoteId = await createNote(app, token, "Initial revision content"); + + // Update content to ensure we have content in the revision + await supertest(app) + .put(`/etapi/notes/${createdNoteId}/content`) + .auth(USER, token, { "type": "basic" }) + .set("Content-Type", "text/plain") + .send("Content after first update") + .expect(204); + + // Force create a revision + await supertest(app) + .post(`/etapi/notes/${createdNoteId}/revision`) + .auth(USER, token, { "type": "basic" }) + .expect(204); + + // Get the revision ID + const revisionsResponse = await supertest(app) + .get(`/etapi/notes/${createdNoteId}/revisions`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(revisionsResponse.body.length).toBeGreaterThan(0); + revisionId = revisionsResponse.body[0].revisionId; + }); + + it("gets revision content", async () => { + const response = await supertest(app) + .get(`/etapi/revisions/${revisionId}/content`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(response.headers["content-type"]).toMatch(/text\/html/); + expect(response.text).toBeTruthy(); + }); + + it("returns 404 for non-existent revision content", async () => { + const response = await supertest(app) + .get("/etapi/revisions/nonexistentrevision/content") + .auth(USER, token, { "type": "basic" }) + .expect(404); + + expect(response.body.code).toStrictEqual("REVISION_NOT_FOUND"); + }); +}); diff --git a/apps/server/spec/etapi/undelete-note.spec.ts b/apps/server/spec/etapi/undelete-note.spec.ts new file mode 100644 index 000000000..236539f4a --- /dev/null +++ b/apps/server/spec/etapi/undelete-note.spec.ts @@ -0,0 +1,103 @@ +import { Application } from "express"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import supertest from "supertest"; +import { login } from "./utils.js"; +import config from "../../src/services/config.js"; +import { randomInt } from "crypto"; + +let app: Application; +let token: string; + +const USER = "etapi"; + +describe("etapi/undelete-note", () => { + beforeAll(async () => { + config.General.noAuthentication = false; + const buildApp = (await (import("../../src/app.js"))).default; + app = await buildApp(); + token = await login(app); + }); + + it("undeletes a deleted note", async () => { + // Create a note + const noteId = `testNote${randomInt(10000)}`; + await supertest(app) + .post("/etapi/create-note") + .auth(USER, token, { "type": "basic" }) + .send({ + "noteId": noteId, + "parentNoteId": "root", + "title": "Note to delete and restore", + "type": "text", + "content": "Content to restore" + }) + .expect(201); + + // Verify note exists + await supertest(app) + .get(`/etapi/notes/${noteId}`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + // Delete the note + await supertest(app) + .delete(`/etapi/notes/${noteId}`) + .auth(USER, token, { "type": "basic" }) + .expect(204); + + // Verify note is deleted (should return 404) + await supertest(app) + .get(`/etapi/notes/${noteId}`) + .auth(USER, token, { "type": "basic" }) + .expect(404); + + // Undelete the note + const response = await supertest(app) + .post(`/etapi/notes/${noteId}/undelete`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(response.body).toHaveProperty("success", true); + + // Verify note is restored + const restoredResponse = await supertest(app) + .get(`/etapi/notes/${noteId}`) + .auth(USER, token, { "type": "basic" }) + .expect(200); + + expect(restoredResponse.body.title).toStrictEqual("Note to delete and restore"); + }); + + it("returns 404 for non-existent note", async () => { + const response = await supertest(app) + .post("/etapi/notes/nonexistentnote/undelete") + .auth(USER, token, { "type": "basic" }) + .expect(404); + + expect(response.body.code).toStrictEqual("NOTE_NOT_FOUND"); + }); + + it("returns 400 when trying to undelete a non-deleted note", async () => { + // Create a note + const noteId = `testNote${randomInt(10000)}`; + await supertest(app) + .post("/etapi/create-note") + .auth(USER, token, { "type": "basic" }) + .send({ + "noteId": noteId, + "parentNoteId": "root", + "title": "Note not deleted", + "type": "text", + "content": "Content" + }) + .expect(201); + + // Try to undelete a note that isn't deleted + const response = await supertest(app) + .post(`/etapi/notes/${noteId}/undelete`) + .auth(USER, token, { "type": "basic" }) + .expect(400); + + expect(response.body.code).toStrictEqual("NOTE_NOT_DELETED"); + }); +}); diff --git a/apps/server/src/etapi/etapi_utils.ts b/apps/server/src/etapi/etapi_utils.ts index 131916257..9bafdf731 100644 --- a/apps/server/src/etapi/etapi_utils.ts +++ b/apps/server/src/etapi/etapi_utils.ts @@ -121,6 +121,16 @@ function getAndCheckAttribute(attributeId: string) { } } +function getAndCheckRevision(revisionId: string) { + const revision = becca.getRevision(revisionId); + + if (revision) { + return revision; + } else { + throw new EtapiError(404, "REVISION_NOT_FOUND", `Revision '${revisionId}' not found.`); + } +} + function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) { for (const key of Object.keys(source)) { if (!(key in allowedProperties)) { @@ -152,5 +162,6 @@ export default { getAndCheckNote, getAndCheckBranch, getAndCheckAttribute, - getAndCheckAttachment + getAndCheckAttachment, + getAndCheckRevision }; diff --git a/apps/server/src/etapi/mappers.ts b/apps/server/src/etapi/mappers.ts index 735e767c2..474812239 100644 --- a/apps/server/src/etapi/mappers.ts +++ b/apps/server/src/etapi/mappers.ts @@ -2,6 +2,7 @@ import type BAttachment from "../becca/entities/battachment.js"; import type BAttribute from "../becca/entities/battribute.js"; import type BBranch from "../becca/entities/bbranch.js"; import type BNote from "../becca/entities/bnote.js"; +import type BRevision from "../becca/entities/brevision.js"; function mapNoteToPojo(note: BNote) { return { @@ -64,9 +65,28 @@ function mapAttachmentToPojo(attachment: BAttachment) { }; } +function mapRevisionToPojo(revision: BRevision) { + return { + revisionId: revision.revisionId, + noteId: revision.noteId, + type: revision.type, + mime: revision.mime, + isProtected: revision.isProtected, + title: revision.title, + blobId: revision.blobId, + dateLastEdited: revision.dateLastEdited, + dateCreated: revision.dateCreated, + utcDateLastEdited: revision.utcDateLastEdited, + utcDateCreated: revision.utcDateCreated, + utcDateModified: revision.utcDateModified, + contentLength: revision.contentLength + }; +} + export default { mapNoteToPojo, mapBranchToPojo, mapAttributeToPojo, - mapAttachmentToPojo + mapAttachmentToPojo, + mapRevisionToPojo }; diff --git a/apps/server/src/etapi/revisions.ts b/apps/server/src/etapi/revisions.ts new file mode 100644 index 000000000..6451b18a1 --- /dev/null +++ b/apps/server/src/etapi/revisions.ts @@ -0,0 +1,205 @@ +import becca from "../becca/becca.js"; +import sql from "../services/sql.js"; +import eu from "./etapi_utils.js"; +import mappers from "./mappers.js"; +import noteService from "../services/notes.js"; +import TaskContext from "../services/task_context.js"; +import protectedSessionService from "../services/protected_session.js"; +import utils from "../services/utils.js"; +import type { Router } from "express"; +import type { NoteRow, RecentChangeRow } from "@triliumnext/commons"; + +function register(router: Router) { + // GET /etapi/notes/history - must be registered before /etapi/notes/:noteId routes + eu.route(router, "get", "/etapi/notes/history", (req, res, next) => { + const ancestorNoteId = (req.query.ancestorNoteId as string) || "root"; + + let recentChanges: RecentChangeRow[]; + + if (ancestorNoteId === "root") { + // Optimized path: no ancestor filtering needed, fetch directly from DB + recentChanges = sql.getRows(` + SELECT + notes.noteId, + notes.isDeleted AS current_isDeleted, + notes.deleteId AS current_deleteId, + notes.title AS current_title, + notes.isProtected AS current_isProtected, + revisions.title, + revisions.utcDateCreated AS utcDate, + revisions.dateCreated AS date + FROM revisions + JOIN notes USING(noteId) + UNION ALL + SELECT + notes.noteId, + notes.isDeleted AS current_isDeleted, + notes.deleteId AS current_deleteId, + notes.title AS current_title, + notes.isProtected AS current_isProtected, + notes.title, + notes.utcDateCreated AS utcDate, + notes.dateCreated AS date + FROM notes + UNION ALL + SELECT + notes.noteId, + notes.isDeleted AS current_isDeleted, + notes.deleteId AS current_deleteId, + notes.title AS current_title, + notes.isProtected AS current_isProtected, + notes.title, + notes.utcDateModified AS utcDate, + notes.dateModified AS date + FROM notes + WHERE notes.isDeleted = 1 + ORDER BY utcDate DESC + LIMIT 500`); + } else { + // Use recursive CTE to find all descendants, then filter at DB level + // This pushes filtering to the database for much better performance + recentChanges = sql.getRows(` + WITH RECURSIVE descendants(noteId) AS ( + SELECT ? + UNION + SELECT branches.noteId + FROM branches + JOIN descendants ON branches.parentNoteId = descendants.noteId + ) + SELECT + notes.noteId, + notes.isDeleted AS current_isDeleted, + notes.deleteId AS current_deleteId, + notes.title AS current_title, + notes.isProtected AS current_isProtected, + revisions.title, + revisions.utcDateCreated AS utcDate, + revisions.dateCreated AS date + FROM revisions + JOIN notes USING(noteId) + WHERE notes.noteId IN (SELECT noteId FROM descendants) + UNION ALL + SELECT + notes.noteId, + notes.isDeleted AS current_isDeleted, + notes.deleteId AS current_deleteId, + notes.title AS current_title, + notes.isProtected AS current_isProtected, + notes.title, + notes.utcDateCreated AS utcDate, + notes.dateCreated AS date + FROM notes + WHERE notes.noteId IN (SELECT noteId FROM descendants) + UNION ALL + SELECT + notes.noteId, + notes.isDeleted AS current_isDeleted, + notes.deleteId AS current_deleteId, + notes.title AS current_title, + notes.isProtected AS current_isProtected, + notes.title, + notes.utcDateModified AS utcDate, + notes.dateModified AS date + FROM notes + WHERE notes.isDeleted = 1 AND notes.noteId IN (SELECT noteId FROM descendants) + ORDER BY utcDate DESC + LIMIT 500`, [ancestorNoteId]); + } + + for (const change of recentChanges) { + if (change.current_isProtected) { + if (protectedSessionService.isProtectedSessionAvailable()) { + change.title = protectedSessionService.decryptString(change.title) || "[protected]"; + change.current_title = protectedSessionService.decryptString(change.current_title) || "[protected]"; + } else { + change.title = change.current_title = "[protected]"; + } + } + + if (change.current_isDeleted) { + const deleteId = change.current_deleteId; + + const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(change.noteId, deleteId); + + // note (and the subtree) can be undeleted if there's at least one undeleted parent (whose branch would be undeleted by this op) + change.canBeUndeleted = undeletedParentBranchIds.length > 0; + } + } + + res.json(recentChanges); + }); + + // GET /etapi/notes/:noteId/revisions - List all revisions for a note + eu.route(router, "get", "/etapi/notes/:noteId/revisions", (req, res, next) => { + const note = eu.getAndCheckNote(req.params.noteId); + + const revisions = becca.getRevisionsFromQuery( + `SELECT revisions.*, LENGTH(blobs.content) AS contentLength + FROM revisions + JOIN blobs USING (blobId) + WHERE noteId = ? + ORDER BY utcDateCreated DESC`, + [note.noteId] + ); + + res.json(revisions.map((revision) => mappers.mapRevisionToPojo(revision))); + }); + + // POST /etapi/notes/:noteId/undelete - Restore a deleted note + eu.route(router, "post", "/etapi/notes/:noteId/undelete", (req, res, next) => { + const { noteId } = req.params; + + const noteRow = sql.getRow("SELECT * FROM notes WHERE noteId = ?", [noteId]); + + if (!noteRow) { + throw new eu.EtapiError(404, "NOTE_NOT_FOUND", `Note '${noteId}' not found.`); + } + + if (!noteRow.isDeleted || !noteRow.deleteId) { + throw new eu.EtapiError(400, "NOTE_NOT_DELETED", `Note '${noteId}' is not deleted.`); + } + + const undeletedParentBranchIds = noteService.getUndeletedParentBranchIds(noteId, noteRow.deleteId); + + if (undeletedParentBranchIds.length === 0) { + throw new eu.EtapiError(400, "CANNOT_UNDELETE", `Cannot undelete note '${noteId}' - no undeleted parent found.`); + } + + const taskContext = new TaskContext("no-progress-reporting", "undeleteNotes", null); + noteService.undeleteNote(noteId, taskContext); + + res.json({ success: true }); + }); + + // GET /etapi/revisions/:revisionId - Get revision metadata + eu.route(router, "get", "/etapi/revisions/:revisionId", (req, res, next) => { + const revision = eu.getAndCheckRevision(req.params.revisionId); + + if (revision.isProtected) { + throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and cannot be read through ETAPI.`); + } + + res.json(mappers.mapRevisionToPojo(revision)); + }); + + // GET /etapi/revisions/:revisionId/content - Get revision content + eu.route(router, "get", "/etapi/revisions/:revisionId/content", (req, res, next) => { + const revision = eu.getAndCheckRevision(req.params.revisionId); + + if (revision.isProtected) { + throw new eu.EtapiError(400, "REVISION_IS_PROTECTED", `Revision '${req.params.revisionId}' is protected and content cannot be read through ETAPI.`); + } + + const filename = utils.formatDownloadTitle(revision.title, revision.type, revision.mime); + + res.setHeader("Content-Disposition", utils.getContentDisposition(filename)); + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Content-Type", revision.mime); + + res.send(revision.getContent()); + }); +} + +export default { + register +}; diff --git a/apps/server/src/routes/routes.ts b/apps/server/src/routes/routes.ts index 9e31d1bca..96c3ebd84 100644 --- a/apps/server/src/routes/routes.ts +++ b/apps/server/src/routes/routes.ts @@ -12,6 +12,7 @@ import etapiMetricsRoute from "../etapi/metrics.js"; import etapiNoteRoutes from "../etapi/notes.js"; import etapiSpecRoute from "../etapi/spec.js"; import etapiSpecialNoteRoutes from "../etapi/special_notes.js"; +import etapiRevisionsRoutes from "../etapi/revisions.js"; import auth from "../services/auth.js"; import openID from '../services/open_id.js'; import { isElectron } from "../services/utils.js"; @@ -361,6 +362,8 @@ function register(app: express.Application) { etapiAttachmentRoutes.register(router); etapiAttributeRoutes.register(router); etapiBranchRoutes.register(router); + // Register revisions routes BEFORE notes routes so /etapi/notes/history is matched before /etapi/notes/:noteId + etapiRevisionsRoutes.register(router); etapiNoteRoutes.register(router); etapiSpecialNoteRoutes.register(router); etapiSpecRoute.register(router);