mirror of
https://github.com/zadam/trilium.git
synced 2026-01-22 23:32:16 +01:00
Compare commits
29 Commits
week-note
...
feat/imple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
280697f2f7 | ||
|
|
0650be664d | ||
|
|
60c61f553a | ||
|
|
022c967781 | ||
|
|
227be184ac | ||
|
|
d677f65eeb | ||
|
|
92f86bcca2 | ||
|
|
2015068d9e | ||
|
|
8a280c2f9d | ||
|
|
34fd6f9502 | ||
|
|
02acb36e47 | ||
|
|
280c0e0348 | ||
|
|
8528f0d848 | ||
|
|
96b1efcfdc | ||
|
|
794e03b2cb | ||
|
|
a285c46b97 | ||
|
|
4da6294ef2 | ||
|
|
16ed9a7e8e | ||
|
|
798efbc22f | ||
|
|
60c789b6c7 | ||
|
|
f83d95136d | ||
|
|
f96ed0af26 | ||
|
|
1539664026 | ||
|
|
8aff775d0e | ||
|
|
94248eafe9 | ||
|
|
02335bba3f | ||
|
|
dad9578b83 | ||
|
|
c043788b09 | ||
|
|
e5bc416b46 |
@@ -9,7 +9,7 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.14.5",
|
||||
"archiver": "7.0.1",
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.5.1",
|
||||
"@preact/signals": "2.6.0",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@zumer/snapdom": "2.0.1",
|
||||
"@zumer/snapdom": "2.0.2",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
@@ -44,7 +44,7 @@
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.0",
|
||||
"globals": "17.0.0",
|
||||
"i18next": "25.7.4",
|
||||
"i18next": "25.8.0",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
@@ -78,9 +78,9 @@
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.3.3",
|
||||
"lightningcss": "1.30.2",
|
||||
"happy-dom": "20.3.4",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.1.4"
|
||||
"vite-plugin-static-copy": "3.1.5"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import shortcuts, { isIMEComposing,keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
import shortcuts, { isIMEComposing, keyMatches, matchesShortcut } from "./shortcuts.js";
|
||||
|
||||
// Mock utils module
|
||||
vi.mock("./utils.js", () => ({
|
||||
@@ -62,9 +62,10 @@ describe("shortcuts", () => {
|
||||
});
|
||||
|
||||
describe("keyMatches", () => {
|
||||
const createKeyboardEvent = (key: string, code?: string) => ({
|
||||
const createKeyboardEvent = (key: string, code?: string, extraProps: Partial<KeyboardEvent> = {}) => ({
|
||||
key,
|
||||
code: code || `Key${key.toUpperCase()}`
|
||||
code: code || `Key${key.toUpperCase()}`,
|
||||
...extraProps
|
||||
} as KeyboardEvent);
|
||||
|
||||
it("should match regular letter keys using key code", () => {
|
||||
@@ -102,17 +103,23 @@ describe("shortcuts", () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should match azerty keys", () => {
|
||||
const event = createKeyboardEvent("A", "KeyQ");
|
||||
expect(keyMatches(event, "a")).toBe(true);
|
||||
expect(keyMatches(event, "q")).toBe(false);
|
||||
});
|
||||
|
||||
it("should match letter keys using code when key is a special character (macOS Alt behavior)", () => {
|
||||
// On macOS, pressing Option/Alt + A produces 'å' as the key, but code is still 'KeyA'
|
||||
const macOSAltAEvent = createKeyboardEvent("å", "KeyA");
|
||||
const macOSAltAEvent = createKeyboardEvent("å", "KeyA", { altKey: true });
|
||||
expect(keyMatches(macOSAltAEvent, "a")).toBe(true);
|
||||
|
||||
// Option + H produces '˙'
|
||||
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH");
|
||||
const macOSAltHEvent = createKeyboardEvent("˙", "KeyH", { altKey: true });
|
||||
expect(keyMatches(macOSAltHEvent, "h")).toBe(true);
|
||||
|
||||
// Option + S produces 'ß'
|
||||
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS");
|
||||
const macOSAltSEvent = createKeyboardEvent("ß", "KeyS", { altKey: true });
|
||||
expect(keyMatches(macOSAltSEvent, "s")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -216,6 +223,15 @@ describe("shortcuts", () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("matches azerty", () => {
|
||||
const event = createKeyboardEvent({
|
||||
key: "a",
|
||||
code: "KeyQ",
|
||||
ctrlKey: true
|
||||
});
|
||||
expect(matchesShortcut(event, "Ctrl+A")).toBe(true);
|
||||
});
|
||||
|
||||
it("should match Alt+letter shortcuts on macOS where key is a special character", () => {
|
||||
// On macOS, pressing Option/Alt + A produces 'å' but code remains 'KeyA'
|
||||
const macOSAltAEvent = createKeyboardEvent({
|
||||
@@ -249,7 +265,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindGlobalShortcut("ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should not bind shortcuts when handler is null", () => {
|
||||
@@ -280,7 +296,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(mockJQueryEl, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockEl.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
|
||||
it("should fall back to document when element is empty", () => {
|
||||
@@ -290,7 +306,7 @@ describe("shortcuts", () => {
|
||||
const handler = vi.fn();
|
||||
shortcuts.bindElShortcut(emptyJQuery, "ctrl+a", handler, "test-namespace");
|
||||
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockElement.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,7 +317,7 @@ describe("shortcuts", () => {
|
||||
|
||||
shortcuts.removeGlobalShortcut("test-namespace");
|
||||
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function), true);
|
||||
expect(mockElement.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -110,9 +110,8 @@ function bindElShortcut($el: JQuery<ElementType | Element>, keyboardShortcut: st
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener in capture phase to intercept events before they reach
|
||||
// child elements like CodeMirror
|
||||
element.addEventListener('keydown', listener, true);
|
||||
// Add the event listener
|
||||
element.addEventListener('keydown', listener);
|
||||
|
||||
// Store the binding for later cleanup
|
||||
const binding: ShortcutBinding = {
|
||||
@@ -139,16 +138,15 @@ export function removeIndividualBinding(binding: ShortcutBinding) {
|
||||
if (activeBindingsInNamespace) {
|
||||
activeBindings.set(key, activeBindingsInNamespace.filter(aBinding => aBinding.handler === binding.handler));
|
||||
}
|
||||
// Remove listener with capture phase to match how it was added
|
||||
binding.element.removeEventListener("keydown", binding.listener, true);
|
||||
binding.element.removeEventListener("keydown", binding.listener);
|
||||
}
|
||||
|
||||
function removeNamespaceBindings(namespace: string) {
|
||||
const bindings = activeBindings.get(namespace);
|
||||
if (bindings) {
|
||||
// Remove all event listeners for this namespace
|
||||
bindings.forEach(binding => {
|
||||
// Remove listener with capture phase to match how it was added
|
||||
binding.element.removeEventListener('keydown', binding.listener, true);
|
||||
binding.element.removeEventListener('keydown', binding.listener);
|
||||
});
|
||||
activeBindings.delete(namespace);
|
||||
}
|
||||
@@ -217,9 +215,12 @@ export function keyMatches(e: KeyboardEvent, key: string): boolean {
|
||||
// For letter keys, use the physical key code for consistency
|
||||
// On macOS, Option/Alt key produces special characters, so we must use e.code
|
||||
if (key.length === 1 && key >= 'a' && key <= 'z') {
|
||||
// e.code is like "KeyA", "KeyB", etc.
|
||||
const expectedCode = `Key${key.toUpperCase()}`;
|
||||
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
|
||||
if (e.altKey) {
|
||||
// e.code is like "KeyA", "KeyB", etc.
|
||||
const expectedCode = `Key${key.toUpperCase()}`;
|
||||
return e.code === expectedCode || e.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
return e.key.toLowerCase() === key.toLowerCase();
|
||||
}
|
||||
|
||||
// For regular keys, check both key and code as fallback
|
||||
|
||||
@@ -93,7 +93,15 @@ export default defineConfig(() => ({
|
||||
print: join(__dirname, "src", "print.tsx")
|
||||
},
|
||||
output: {
|
||||
entryFileNames: "src/[name].js",
|
||||
entryFileNames: (chunk) => {
|
||||
// We enforce a hash in the main index file to avoid caching issues, this only works because we have the HTML entry point.
|
||||
if (chunk.name === "index") {
|
||||
return "src/[name]-[hash].js";
|
||||
}
|
||||
|
||||
// For EJS-rendered pages (e.g. login) we need to have a stable name.
|
||||
return "src/[name].js";
|
||||
},
|
||||
chunkFileNames: "src/[name]-[hash].js",
|
||||
assetFileNames: "src/[name]-[hash].[ext]",
|
||||
manualChunks: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.7.4",
|
||||
"i18next": "25.8.0",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -126,7 +126,7 @@
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"time2fa": "1.4.2",
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.7.1",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "7.3.1",
|
||||
"ws": "8.19.0",
|
||||
|
||||
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) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!(key in allowedProperties)) {
|
||||
@@ -152,5 +162,6 @@ export default {
|
||||
getAndCheckNote,
|
||||
getAndCheckBranch,
|
||||
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 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
|
||||
};
|
||||
|
||||
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 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);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "25.7.4",
|
||||
"i18next": "25.8.0",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"preact": "10.28.2",
|
||||
"preact-iso": "2.11.1",
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.5.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"happy-dom": "20.3.3",
|
||||
"happy-dom": "20.3.4",
|
||||
"http-server": "14.1.1",
|
||||
"jiti": "2.6.1",
|
||||
"js-yaml": "4.1.1",
|
||||
@@ -93,7 +93,7 @@
|
||||
"url": "https://github.com/TriliumNext/Trilium/issues"
|
||||
},
|
||||
"homepage": "https://triliumnotes.org",
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
@@ -115,7 +115,7 @@
|
||||
"on-headers@<1.1.0": ">=1.1.0",
|
||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.4",
|
||||
"form-data@>=3.0.0 <3.0.4": ">=3.0.4",
|
||||
"node-abi": "4.25.0"
|
||||
"node-abi": "4.26.0"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
"sqlite3"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.24",
|
||||
"@smithy/middleware-retry": "4.4.26",
|
||||
"@types/jquery": "3.5.33"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig } from "@codemirror/view";
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder, ViewPlugin, ViewUpdate, type EditorViewConfig, KeyBinding } from "@codemirror/view";
|
||||
import { defaultHighlightStyle, StreamLanguage, syntaxHighlighting, indentUnit, bracketMatching, foldGutter, codeFolding } from "@codemirror/language";
|
||||
import { Compartment, EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
@@ -12,6 +12,17 @@ import { createSearchHighlighter, SearchHighlighter, searchMatchHighlightTheme }
|
||||
|
||||
export { default as ColorThemes, type ThemeDefinition, getThemeById } from "./color_themes.js";
|
||||
|
||||
// Custom keymap to prevent Ctrl+Enter from inserting a newline
|
||||
// This allows the parent application to handle the shortcut (e.g., for "Run Active Note")
|
||||
const preventCtrlEnterKeymap: readonly KeyBinding[] = [
|
||||
{
|
||||
key: "Ctrl-Enter",
|
||||
mac: "Cmd-Enter",
|
||||
run: () => true, // Return true to mark event as handled, preventing default newline insertion
|
||||
preventDefault: true
|
||||
}
|
||||
];
|
||||
|
||||
type ContentChangedListener = () => void;
|
||||
|
||||
export interface EditorConfig {
|
||||
@@ -59,6 +70,7 @@ export default class CodeMirror extends EditorView {
|
||||
lineNumbers(),
|
||||
indentUnit.of(" ".repeat(4)),
|
||||
keymap.of([
|
||||
...preventCtrlEnterKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...smartIndentWithTab
|
||||
|
||||
424
pnpm-lock.yaml
generated
424
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user