Compare commits

..

4 Commits

Author SHA1 Message Date
perfectra1n
280697f2f7 Revert "feat(etapi): resolve suggestions for norms from gemini"
This reverts commit 0650be664d.
2026-01-21 16:37:02 -08:00
perfectra1n
0650be664d feat(etapi): resolve suggestions for norms from gemini 2026-01-21 16:33:42 -08:00
perfectra1n
60c61f553a feat(etapi): put filtering for revisions mainly in the db layer 2026-01-21 16:30:37 -08:00
perfectra1n
022c967781 feat(etapi): add revisions route and "undelete" route to etapi 2026-01-21 16:25:17 -08:00
18 changed files with 925 additions and 119 deletions

View File

@@ -1,11 +1,10 @@
import { MimeType } from "@triliumnext/commons";
import { type AutoHighlightResult, ensureMimeTypes, highlight, highlightAuto, type HighlightResult, loadTheme, type Theme,Themes } from "@triliumnext/highlightjs";
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
import { t } from "./i18n.js";
import { ensureMimeTypes, highlight, highlightAuto, loadTheme, Themes, type AutoHighlightResult, type HighlightResult, type Theme } from "@triliumnext/highlightjs";
import mime_types from "./mime_types.js";
import options from "./options.js";
import { t } from "./i18n.js";
import { copyText, copyTextWithToast } from "./clipboard_ext.js";
import { isShare } from "./utils.js";
import { MimeType } from "@triliumnext/commons";
let highlightingLoaded = false;
@@ -77,15 +76,13 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
}
export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
if (!mimeTypeHint && highlightingLoaded) {
if (highlightingLoaded) {
return;
}
// Load theme.
if (!highlightingLoaded) {
const currentThemeName = String(options.get("codeBlockTheme"));
await loadHighlightingTheme(currentThemeName);
}
const currentThemeName = String(options.get("codeBlockTheme"));
await loadHighlightingTheme(currentThemeName);
// Load mime types.
let mimeTypes: MimeType[];
@@ -97,7 +94,7 @@ export async function ensureMimeTypesForHighlighting(mimeTypeHint?: string) {
enabled: true,
mime: mimeTypeHint.replace("-", "/")
}
];
]
} else {
mimeTypes = mime_types.getMimeTypes();
}
@@ -127,9 +124,9 @@ export function isSyntaxHighlightEnabled() {
if (!isShare) {
const theme = options.get("codeBlockTheme");
return !!theme && theme !== "none";
} else {
return true;
}
return true;
}
/**

View File

@@ -217,7 +217,6 @@ function LabelInput({ inputId, ...props }: CellProps & { inputId: string }) {
id={inputId}
type={LABEL_MAPPINGS[definition.labelType ?? "text"]}
value={valueAttr.value}
checked={definition.labelType === "boolean" ? valueAttr.value === "true" : undefined}
placeholder={t("promoted_attributes.unset-field-placeholder")}
data-attribute-id={valueAttr.attributeId}
data-attribute-type={valueAttr.type}

View File

@@ -1,15 +1,12 @@
import "./Render.css";
import { useEffect, useRef, useState } from "preact/hooks";
import attributes from "../../services/attributes";
import { t } from "../../services/i18n";
import render from "../../services/render";
import Alert from "../react/Alert";
import { useTriliumEvent } from "../react/hooks";
import RawHtml from "../react/RawHtml";
import { refToJQuerySelector } from "../react/react_utils";
import { TypeWidgetProps } from "./type_widget";
import render from "../../services/render";
import { refToJQuerySelector } from "../react/react_utils";
import Alert from "../react/Alert";
import "./Render.css";
import { t } from "../../services/i18n";
import RawHtml from "../react/RawHtml";
import { useTriliumEvent } from "../react/hooks";
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
const contentRef = useRef<HTMLDivElement>(null);
@@ -34,13 +31,6 @@ export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
refresh();
});
// Refresh on attribute change.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().some(a => a.type === "relation" && a.name === "renderNote" && attributes.isAffecting(a, note))) {
refresh();
}
});
// Integration with search.
useTriliumEvent("executeWithContentElement", ({ resolve, ntxId: eventNtxId }) => {
if (eventNtxId !== ntxId) return;

View File

@@ -8,7 +8,7 @@ import { DEFAULT_GUTTER_SIZE } from "../../../services/resizer";
import utils, { isMobile } from "../../../services/utils";
import ActionButton, { ActionButtonProps } from "../../react/ActionButton";
import Admonition from "../../react/Admonition";
import { useNoteBlob, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks";
import { EditableCode, EditableCodeProps } from "../code/Code";
export interface SplitEditorProps extends EditableCodeProps {
@@ -30,22 +30,12 @@ export interface SplitEditorProps extends EditableCodeProps {
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default function SplitEditor(props: SplitEditorProps) {
const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly");
if (readOnly) {
return <ReadOnlyView {...props} />;
}
return <EditorWithSplit {...props} />;
}
function EditorWithSplit({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
export default function SplitEditor({ note, error, splitOptions, previewContent, previewButtons, className, editorBefore, forceOrientation, ...editorProps }: SplitEditorProps) {
const splitEditorOrientation = useSplitOrientation(forceOrientation);
const [ readOnly ] = useNoteLabelBoolean(note, "readOnly");
const containerRef = useRef<HTMLDivElement>(null);
const editor = (
const editor = (!readOnly &&
<div className="note-detail-split-editor-col">
{editorBefore}
<div className="note-detail-split-editor">
@@ -63,14 +53,19 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
</div>
);
const preview = <PreviewContainer
error={error}
previewContent={previewContent}
previewButtons={previewButtons}
/>;
const preview = (
<div className="note-detail-split-preview-col">
<div className={`note-detail-split-preview ${error ? "on-error" : ""}`}>
{previewContent}
</div>
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
{previewButtons}
</div>
</div>
);
useEffect(() => {
if (!utils.isDesktop() || !containerRef.current) return;
if (!utils.isDesktop() || !containerRef.current || readOnly) return;
const elements = Array.from(containerRef.current?.children) as HTMLElement[];
const splitInstance = Split(elements, {
rtl: glob.isRtl,
@@ -81,10 +76,10 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
});
return () => splitInstance.destroy();
}, [ splitEditorOrientation ]);
}, [ readOnly, splitEditorOrientation ]);
return (
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${className ?? ""}`}>
<div ref={containerRef} className={`note-detail-split note-detail-printable ${`split-${splitEditorOrientation}`} ${readOnly ? "split-read-only" : ""} ${className ?? ""}`}>
{splitEditorOrientation === "horizontal"
? <>{editor}{preview}</>
: <>{preview}{editor}</>}
@@ -92,43 +87,6 @@ function EditorWithSplit({ note, error, splitOptions, previewContent, previewBut
);
}
function ReadOnlyView({ ...props }: SplitEditorProps) {
const { note, onContentChanged } = props;
const content = useNoteBlob(note);
const onContentChangedRef = useRef(onContentChanged);
useEffect(() => {
onContentChangedRef.current = onContentChanged;
});
useEffect(() => {
onContentChangedRef.current?.(content?.content ?? "");
}, [ content ]);
return (
<div className={`note-detail-split note-detail-printable ${props.className} split-read-only`}>
<PreviewContainer {...props} />
</div>
);
}
function PreviewContainer({ error, previewContent, previewButtons }: {
error?: string | null;
previewContent: ComponentChildren;
previewButtons?: ComponentChildren;
}) {
return (
<div className="note-detail-split-preview-col">
<div className={`note-detail-split-preview ${error ? "on-error" : ""}`}>
{previewContent}
</div>
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group">
{previewButtons}
</div>
</div>
);
}
export function PreviewButton(props: Omit<ActionButtonProps, "titlePosition">) {
return <ActionButton
{...props}

View File

@@ -1,14 +1,13 @@
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import svgPanZoom from "svg-pan-zoom";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import utils from "../../../services/utils";
import { useElementSize, useTriliumEvent } from "../../react/hooks";
import { RawHtmlBlock } from "../../react/RawHtml";
import SplitEditor, { PreviewButton, SplitEditorProps } from "./SplitEditor";
import { RawHtmlBlock } from "../../react/RawHtml";
import server from "../../../services/server";
import svgPanZoom from "svg-pan-zoom";
import { RefObject } from "preact";
import { useElementSize, useTriliumEvent } from "../../react/hooks";
import utils from "../../../services/utils";
import toast from "../../../services/toast";
interface SvgSplitEditorProps extends Omit<SplitEditorProps, "previewContent"> {
/**
@@ -145,7 +144,7 @@ export default function SvgSplitEditor({ ntxId, note, attachmentName, renderSvg,
}
{...props}
/>
);
)
}
function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg: string | undefined) {
@@ -182,7 +181,7 @@ function useResizer(containerRef: RefObject<HTMLDivElement>, noteId: string, svg
lastPanZoom.current = {
pan: zoomInstance.getPan(),
zoom: zoomInstance.getZoom()
};
}
zoomRef.current = undefined;
zoomInstance.destroy();
};

View File

@@ -191,6 +191,7 @@ function ExperimentalOptions() {
values={filteredExperimentalFeatures}
keyProperty="id"
titleProperty="name"
descriptionProperty="description"
currentValue={enabledExperimentalFeatures} onChange={setEnabledExperimentalFeatures}
/>
</OptionsSection>

View File

@@ -1,14 +1,17 @@
import FormCheckbox from "../../../react/FormCheckbox";
interface CheckboxListProps<T> {
values: T[];
keyProperty: keyof T;
titleProperty?: keyof T;
disabledProperty?: keyof T;
descriptionProperty?: keyof T;
currentValue: string[];
onChange: (newValues: string[]) => void;
columnWidth?: string;
}
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
export default function CheckboxList<T>({ values, keyProperty, titleProperty, disabledProperty, descriptionProperty, currentValue, onChange, columnWidth }: CheckboxListProps<T>) {
function toggleValue(value: string) {
if (currentValue.includes(value)) {
// Already there, needs removing.
@@ -22,20 +25,17 @@ export default function CheckboxList<T>({ values, keyProperty, titleProperty, di
return (
<ul style={{ listStyleType: "none", marginBottom: 0, columnWidth: columnWidth ?? "400px" }}>
{values.map(value => (
<li>
<label className="tn-checkbox">
<input
type="checkbox"
className="form-check-input"
value={String(value[keyProperty])}
checked={currentValue.includes(String(value[keyProperty]))}
disabled={!!(disabledProperty && value[disabledProperty])}
onChange={e => toggleValue((e.target as HTMLInputElement).value)}
/>
{String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
</label>
<li key={String(value[keyProperty])}>
<FormCheckbox
label={String(value[titleProperty ?? keyProperty] ?? value[keyProperty])}
name={String(value[keyProperty])}
currentValue={currentValue.includes(String(value[keyProperty]))}
disabled={!!(disabledProperty && value[disabledProperty])}
hint={value && (descriptionProperty ? String(value[descriptionProperty]) : undefined)}
onChange={() => toggleValue(String(value[keyProperty]))}
/>
</li>
))}
</ul>
)
}
);
}

View File

@@ -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)

View 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");
});
});

View 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");
});
});

View 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");
}
});
});

View 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");
});
});

View 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");
});
});

View File

@@ -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
};

View File

@@ -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
};

View 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
};

View File

@@ -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);

View File

@@ -22,6 +22,7 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
continue;
}
registeredMimeTypes.add(mime);
const loader = syntaxDefinitions[mime];
if (!loader) {
unsupportedMimeTypes.add(mime);
@@ -30,7 +31,6 @@ export async function ensureMimeTypes(mimeTypes: MimeType[]) {
const language = (await loader()).default;
hljs.registerLanguage(mime, language);
registeredMimeTypes.add(mime);
}
}