mirror of
https://github.com/zadam/trilium.git
synced 2026-02-25 07:04:25 +01:00
Merge 280697f2f7ef363d6ca85a7bbbbfe2ff4cc83479 into d639de03c3a20b5263a40f1f96c3ee4d4478fb8e
This commit is contained in:
commit
4ccf017ba3
@ -337,6 +337,130 @@ paths:
|
|||||||
application/json; charset=utf-8:
|
application/json; charset=utf-8:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$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:
|
/branches:
|
||||||
post:
|
post:
|
||||||
description: >
|
description: >
|
||||||
@ -1186,3 +1310,93 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Human readable error, potentially with more details,
|
description: Human readable error, potentially with more details,
|
||||||
example: Note 'evnnmvHTCgIn' is protected and cannot be modified through ETAPI
|
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)
|
||||||
|
|||||||
77
apps/server/spec/etapi/get-note-revisions.spec.ts
Normal file
77
apps/server/spec/etapi/get-note-revisions.spec.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
71
apps/server/spec/etapi/get-revision.spec.ts
Normal file
71
apps/server/spec/etapi/get-revision.spec.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
94
apps/server/spec/etapi/note-history.spec.ts
Normal file
94
apps/server/spec/etapi/note-history.spec.ts
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
64
apps/server/spec/etapi/revision-content.spec.ts
Normal file
64
apps/server/spec/etapi/revision-content.spec.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
103
apps/server/spec/etapi/undelete-note.spec.ts
Normal file
103
apps/server/spec/etapi/undelete-note.spec.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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) {
|
function validateAndPatch(target: any, source: any, allowedProperties: ValidatorMap) {
|
||||||
for (const key of Object.keys(source)) {
|
for (const key of Object.keys(source)) {
|
||||||
if (!(key in allowedProperties)) {
|
if (!(key in allowedProperties)) {
|
||||||
@ -152,5 +162,6 @@ export default {
|
|||||||
getAndCheckNote,
|
getAndCheckNote,
|
||||||
getAndCheckBranch,
|
getAndCheckBranch,
|
||||||
getAndCheckAttribute,
|
getAndCheckAttribute,
|
||||||
getAndCheckAttachment
|
getAndCheckAttachment,
|
||||||
|
getAndCheckRevision
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type BAttachment from "../becca/entities/battachment.js";
|
|||||||
import type BAttribute from "../becca/entities/battribute.js";
|
import type BAttribute from "../becca/entities/battribute.js";
|
||||||
import type BBranch from "../becca/entities/bbranch.js";
|
import type BBranch from "../becca/entities/bbranch.js";
|
||||||
import type BNote from "../becca/entities/bnote.js";
|
import type BNote from "../becca/entities/bnote.js";
|
||||||
|
import type BRevision from "../becca/entities/brevision.js";
|
||||||
|
|
||||||
function mapNoteToPojo(note: BNote) {
|
function mapNoteToPojo(note: BNote) {
|
||||||
return {
|
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 {
|
export default {
|
||||||
mapNoteToPojo,
|
mapNoteToPojo,
|
||||||
mapBranchToPojo,
|
mapBranchToPojo,
|
||||||
mapAttributeToPojo,
|
mapAttributeToPojo,
|
||||||
mapAttachmentToPojo
|
mapAttachmentToPojo,
|
||||||
|
mapRevisionToPojo
|
||||||
};
|
};
|
||||||
|
|||||||
205
apps/server/src/etapi/revisions.ts
Normal file
205
apps/server/src/etapi/revisions.ts
Normal file
@ -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<RecentChangeRow>(`
|
||||||
|
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<RecentChangeRow>(`
|
||||||
|
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<NoteRow | null>("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
|
||||||
|
};
|
||||||
@ -12,6 +12,7 @@ import etapiMetricsRoute from "../etapi/metrics.js";
|
|||||||
import etapiNoteRoutes from "../etapi/notes.js";
|
import etapiNoteRoutes from "../etapi/notes.js";
|
||||||
import etapiSpecRoute from "../etapi/spec.js";
|
import etapiSpecRoute from "../etapi/spec.js";
|
||||||
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
|
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
|
||||||
|
import etapiRevisionsRoutes from "../etapi/revisions.js";
|
||||||
import auth from "../services/auth.js";
|
import auth from "../services/auth.js";
|
||||||
import openID from '../services/open_id.js';
|
import openID from '../services/open_id.js';
|
||||||
import { isElectron } from "../services/utils.js";
|
import { isElectron } from "../services/utils.js";
|
||||||
@ -361,6 +362,8 @@ function register(app: express.Application) {
|
|||||||
etapiAttachmentRoutes.register(router);
|
etapiAttachmentRoutes.register(router);
|
||||||
etapiAttributeRoutes.register(router);
|
etapiAttributeRoutes.register(router);
|
||||||
etapiBranchRoutes.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);
|
etapiNoteRoutes.register(router);
|
||||||
etapiSpecialNoteRoutes.register(router);
|
etapiSpecialNoteRoutes.register(router);
|
||||||
etapiSpecRoute.register(router);
|
etapiSpecRoute.register(router);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user