Merge remote-tracking branch 'origin/main' into standalone

This commit is contained in:
Elian Doran
2026-04-26 16:10:09 +03:00
149 changed files with 7248 additions and 2922 deletions
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(gh issue *)"
]
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ runs:
# Certificate setup
- name: Import Apple certificates
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v7
with:
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
@@ -30,7 +30,7 @@ runs:
- name: Install Installer certificate
if: inputs.os == 'macos'
uses: apple-actions/import-codesign-certs@v6
uses: apple-actions/import-codesign-certs@v7
with:
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
+11 -2
View File
@@ -118,14 +118,23 @@ class MyNoteWidget extends NoteContextAwareWidget {
**Important**: Widgets use jQuery (`this.$widget`) for DOM manipulation. Don't mix React patterns here.
### Reusable Preact Components
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
Common UI components are available in `apps/client/src/widgets/react/` — **always** reuse these instead of writing raw HTML elements or custom implementations:
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
- `ActionButton` - Consistent button styling with icon support
- `FormTextBox` - Text input with validation and controlled input handling
- `FormTextBox` - Text input with validation and controlled input handling; `FormTextBoxWithUnit` for inputs with a unit suffix (e.g. "mm", "px")
- `FormSelect` - Dropdown/combobox taking an object array as data
- `Slider` - Range slider with label
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
**Do not use Bootstrap utility classes** (e.g. `form-control-sm`, `form-select-sm`, `input-group`) on these components — they manage their own styling internally. If you need to adjust sizing or layout, use props provided by the component or CSS custom properties, not Bootstrap overrides.
#### Component Styling
- **Avoid inline styles** — do not use the `style` attribute/prop on JSX elements unless absolutely necessary (e.g. a truly dynamic, computed value that cannot be expressed in CSS). Static layout, sizing, spacing, and visual properties must go in CSS.
- **Per-component CSS files**: each component should have a matching `.css` file (e.g. `my_dialog.tsx` → `my_dialog.css`), imported at the top of the component file.
- **CSS nesting for scoping**: since CSS modules are not available, scope styles using a root class and native CSS nesting. For example, a dialog with `className="my-dialog"` should have its styles nested under `.modal.my-dialog { … }`.
- **Reuse existing components** instead of building custom markup — prefer `FormTextBox`, `FormTextBoxWithUnit`, `FormSelect`, `Slider`, `Button`, etc. over hand-rolled `<input>`, `<select>`, or `<button>` elements.
## Development Workflow
### Running & Testing
-44
View File
@@ -1,44 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+1 -1
View File
@@ -53,4 +53,4 @@ apps/*/coverage
scripts/translation/.language*.json
# AI
.claude/settings.local.json
.claude
+16 -2
View File
@@ -123,16 +123,30 @@ Frontend widgets in `apps/client/src/widgets/`:
**Widget lifecycle**: `doRenderBody()` for initial render, `refreshWithNote()` for note changes, `entitiesReloadedEvent({loadResults})` for entity updates. Uses jQuery — don't mix React patterns.
#### Reusable Preact Components
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating custom implementations:
Common UI components are available in `apps/client/src/widgets/react/` — **always** reuse these instead of writing raw HTML elements or custom implementations:
- `NoItems` - Empty state placeholder with icon and message (use for "no results", "too many items", error states)
- `ActionButton` - Consistent button styling with icon support
- `FormTextBox` - Text input with validation and controlled input handling
- `FormTextBox` - Text input with validation and controlled input handling; `FormTextBoxWithUnit` for inputs with a unit suffix (e.g. "mm", "px")
- `FormSelect` - Dropdown/combobox taking an object array as data
- `Slider` - Range slider with label
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
Fluent builder pattern: `.child()`, `.class()`, `.css()` chaining with position-based ordering.
**Do not use Bootstrap utility classes** (e.g. `form-control-sm`, `form-select-sm`, `input-group`) on these components — they manage their own styling internally. If you need to adjust sizing or layout, use props provided by the component or CSS custom properties, not Bootstrap overrides.
#### Component Styling
- **Avoid inline styles** — do not use the `style` attribute/prop on JSX elements unless absolutely necessary (e.g. a truly dynamic, computed value that cannot be expressed in CSS). Static layout, sizing, spacing, and visual properties must go in CSS.
- **Per-component CSS files**: each component should have a matching `.css` file (e.g. `my_dialog.tsx` → `my_dialog.css`), imported at the top of the component file.
- **CSS nesting for scoping**: since CSS modules are not available, scope styles using a root class and native CSS nesting. For example, a dialog with `className="my-dialog"` should have its styles nested under `.modal.my-dialog { … }`.
- **Reuse existing components** instead of building custom markup — prefer `FormTextBox`, `FormTextBoxWithUnit`, `FormSelect`, `Slider`, `Button`, etc. over hand-rolled `<input>`, `<select>`, or `<button>` elements.
#### API Architecture
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
- **WebSocket**: Real-time synchronization (`apps/server/src/services/ws.ts`)
### API Architecture
- **Internal API** (`apps/server/src/routes/api/`): REST endpoints, trusts frontend
+2 -2
View File
@@ -14,13 +14,13 @@
"keywords": [],
"author": "Elian Doran <[email protected]>",
"license": "AGPL-3.0-only",
"packageManager": "[email protected].0",
"packageManager": "[email protected].1",
"dependencies": {
"@triliumnext/core": "workspace:*",
"@triliumnext/server": "workspace:*"
},
"devDependencies": {
"@redocly/cli": "2.28.0",
"@redocly/cli": "2.29.1",
"archiver": "7.0.1",
"fs-extra": "11.3.4",
"js-yaml": "4.1.1",
+26
View File
@@ -0,0 +1,26 @@
{
"languageOptions": {
"directionality": {
"block": "top-to-bottom",
"inline": "left-to-right"
}
},
"rules": {
"property-layout-mappings": ["flow-relative", {
"ignoreProperties": [
"top", "bottom",
"width", "height",
"min-width", "min-height",
"max-width", "max-height",
"margin-top", "margin-bottom",
"padding-top", "padding-bottom",
"border-top", "border-top-width", "border-top-style", "border-top-color",
"border-bottom", "border-bottom-width", "border-bottom-style", "border-bottom-color",
"scroll-margin-top", "scroll-margin-bottom",
"scroll-padding-top", "scroll-padding-bottom",
"overflow-x", "overflow-y",
"contain-intrinsic-width", "contain-intrinsic-height"
]
}]
}
}
+11 -9
View File
@@ -13,10 +13,11 @@
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
"test": "vitest",
"coverage": "vitest --coverage",
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular",
"stylelint": "stylelint --allow-empty-input \"src/**/*.css\""
},
"dependencies": {
"@excalidraw/excalidraw": "0.18.0",
"@excalidraw/excalidraw": "0.18.1",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -49,12 +50,12 @@
"clsx": "2.1.1",
"color": "5.0.3",
"debounce": "3.0.0",
"dompurify": "3.4.0",
"dompurify": "3.4.1",
"draggabilly": "3.0.0",
"force-graph": "1.51.4",
"htmldiff-js": "1.0.5",
"i18next": "26.0.5",
"i18next-http-backend": "3.0.4",
"i18next": "26.0.6",
"i18next-http-backend": "3.0.5",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
@@ -62,9 +63,9 @@
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "18.0.1",
"marked": "18.0.2",
"mermaid": "11.14.0",
"mind-elixir": "5.10.0",
"mind-elixir": "5.11.0",
"panzoom": "9.4.4",
"preact": "10.29.1",
"react-i18next": "17.0.4",
@@ -83,11 +84,12 @@
"@types/leaflet": "1.9.21",
"@types/leaflet-gpx": "1.3.8",
"@types/mark.js": "8.11.12",
"@types/tabulator-tables": "6.3.1",
"@types/tabulator-tables": "6.3.2",
"copy-webpack-plugin": "14.0.0",
"happy-dom": "20.9.0",
"lightningcss": "1.32.0",
"script-loader": "0.7.2",
"vite-plugin-static-copy": "4.0.1"
"stylelint": "17.8.0",
"vite-plugin-static-copy": "4.1.0"
}
}
+11 -2
View File
@@ -19,6 +19,8 @@ import Component from "./component.js";
export interface SetNoteOpts {
triggerSwitchEvent?: unknown;
viewScope?: ViewScope;
/** If true, skip closing the currently active dialog. Used when opening a note into a stackable popup (e.g. quick-edit) that must not dismiss the dialog it was launched from. */
keepActiveDialog?: boolean;
}
export type GetTextEditorCallback = (editor: CKTextEditor) => void;
@@ -50,6 +52,10 @@ export interface NoteContextDataMap {
layers: PdfLayer[];
toggleLayer(layerId: string, visible: boolean): void;
};
pdfAnnotations: {
annotations: PdfAnnotationInfo[];
scrollToAnnotation(annotationId: string, pageNumber: number): void;
};
saveState: {
state: SaveState;
};
@@ -127,7 +133,9 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
await this.triggerEvent("beforeNoteSwitch", { noteContext: this });
closeActiveDialog();
if (!opts.keepActiveDialog) {
closeActiveDialog();
}
this.notePath = resolvedNotePath;
this.viewScope = opts.viewScope;
@@ -317,7 +325,8 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
}
// Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly).
if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
const isPdf = this.note.type === "file" && this.note.mime === "application/pdf";
if (!isPdf && !READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) {
return false;
}
+1 -1
View File
@@ -47,12 +47,12 @@ export function applyModals(rootContainer: RootContainer) {
.child(<ProtectedSessionPasswordDialog />)
.child(<RevisionsDialog />)
.child(<DeleteNotesDialog />)
.child(<PrintPreviewDialog />)
.child(<InfoDialog />)
.child(<ConfirmDialog />)
.child(<PromptDialog />)
.child(<IncorrectCpuArchDialog />)
.child(<PopupEditorDialog />)
.child(<PrintPreviewDialog />)
.child(<CallToActionDialog />)
.child(<ToastContainer />);
}
@@ -198,6 +198,7 @@ export async function buildTreeContextMenuItems(ctx: TreeContextMenuContext): Pr
{
title: hasSubtreeHidden ? t("tree-context-menu.show-subtree") : t("tree-context-menu.hide-subtree"),
uiIcon: "bx bx-show",
enabled: isNotRoot,
handler: async () => {
attributes.setBooleanWithInheritance(note, "subtreeHidden", !hasSubtreeHidden);
}
+17
View File
@@ -149,6 +149,23 @@ span[style] {
}
/* #endregion */
/* #region Spreadsheet */
.spreadsheet-table {
border-collapse: collapse;
width: 100%;
font-size: var(--print-font-size);
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.spreadsheet-table td {
border: 0.5pt solid #ccc;
padding: 2pt 4pt;
overflow: hidden;
text-overflow: ellipsis;
}
/* #endregion */
/* #region Page breaks */
.page-break {
page-break-after: always;
+28 -19
View File
@@ -1,3 +1,4 @@
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
import { render } from "preact";
import { useCallback, useLayoutEffect, useRef } from "preact/hooks";
@@ -94,28 +95,36 @@ function SingleNoteRenderer({ note, onReady }: RendererProps) {
useLayoutEffect(() => {
async function load() {
if (note.type === "text") {
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
}
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
const container = containerRef.current!;
container.replaceChildren(...$renderedContent);
// Wait for all images to load.
const images = Array.from(container.querySelectorAll("img"));
await Promise.all(
images.map(img => {
if (img.complete) return Promise.resolve();
return new Promise<void>(resolve => {
img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => resolve(), { once: true });
});
})
);
if (note.type === "spreadsheet") {
// Render spreadsheet as HTML tables instead of an image.
const blob = await note.getBlob();
const html = renderSpreadsheetToHtml(blob?.content ?? "");
container.innerHTML = html;
} else {
if (note.type === "text") {
await import("@triliumnext/ckeditor5/src/theme/ck-content.css");
}
const { $renderedContent } = await content_renderer.getRenderedContent(note, { noChildrenList: true });
container.replaceChildren(...$renderedContent);
// Initialize mermaid.
if (note.type === "text") {
await applyInlineMermaid(container);
// Wait for all images to load.
const images = Array.from(container.querySelectorAll("img"));
await Promise.all(
images.map(img => {
if (img.complete) return Promise.resolve();
return new Promise<void>(resolve => {
img.addEventListener("load", () => resolve(), { once: true });
img.addEventListener("error", () => resolve(), { once: true });
});
})
);
// Initialize mermaid.
if (note.type === "text") {
await applyInlineMermaid(container);
}
}
// Check custom CSS.
+1 -1
View File
@@ -256,7 +256,7 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
const url = `../../api/${entityType}/${entityId}/open`;
const $viewer = $(`<div style="height: 100%">`);
const PdfViewer = (await import("../widgets/type_widgets/file/PdfViewer")).default;
render(h(PdfViewer, {pdfUrl: url, editable: false}), $viewer.get(0)!);
render(h(PdfViewer, {pdfUrl: url, editable: false, toolbar: false}), $viewer.get(0)!);
$content.append($viewer);
-2
View File
@@ -75,8 +75,6 @@ function setupGlobs() {
requireCss(`api/notes/download/${appCssNoteId}`, false);
}
utils.initHelpButtons($(window));
$("body").on("click", "a.external", function () {
window.open($(this).attr("href"), "_blank");
@@ -9,6 +9,27 @@ import { isShare } from "./utils.js";
let highlightingLoaded = false;
// Highlight.js can spend tens of milliseconds per block (php/c# are especially slow).
// The Markdown live preview replaces the entire rendered DOM on every keystroke, so the
// same unchanged code blocks would otherwise be re-highlighted continuously. Cache the
// output keyed by (language, text) so repeat renders short-circuit to a plain innerHTML
// assignment. FIFO eviction keeps memory bounded without needing an LRU.
const HIGHLIGHT_CACHE_MAX = 256;
const highlightCache = new Map<string, string>();
function getCachedHighlight(language: string, text: string) {
return highlightCache.get(`${language}\x00${text}`);
}
function setCachedHighlight(language: string, text: string, value: string) {
const key = `${language}\x00${text}`;
if (highlightCache.size >= HIGHLIGHT_CACHE_MAX) {
const oldest = highlightCache.keys().next().value;
if (oldest !== undefined) highlightCache.delete(oldest);
}
highlightCache.set(key, value);
}
/**
* Identifies all the code blocks (as `pre code`) under the specified hierarchy and uses the highlight.js library to obtain the highlighted text which is then applied on to the code blocks.
* Additionally, adds a "Copy to clipboard" button.
@@ -83,6 +104,12 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
$codeBlock.parent().toggleClass("hljs");
const text = $codeBlock.text();
const cached = getCachedHighlight(normalizedMimeType, text);
if (cached !== undefined) {
$codeBlock.html(cached);
return;
}
let highlightedText: HighlightResult | AutoHighlightResult | null = null;
if (normalizedMimeType === mime_types.MIME_TYPE_AUTO && !isShare) {
await ensureMimeTypesForHighlighting();
@@ -97,6 +124,7 @@ export async function applySingleBlockSyntaxHighlight($codeBlock: JQuery<HTMLEle
}
if (highlightedText) {
setCachedHighlight(normalizedMimeType, text, highlightedText.value);
$codeBlock.html(highlightedText.value);
}
}
-41
View File
@@ -414,36 +414,6 @@ function initHelpDropdown($el: JQuery<HTMLElement>) {
// stop inside clicks from closing the menu
const $dropdownMenu = $el.find(".help-dropdown .dropdown-menu");
$dropdownMenu.on("click", (e) => e.stopPropagation());
// previous propagation stop will also block help buttons from being opened, so we need to re-init for this element
initHelpButtons($dropdownMenu);
}
const wikiBaseUrl = "https://triliumnext.github.io/Docs/Wiki/";
function openHelp($button: JQuery<HTMLElement>) {
if ($button.length === 0) {
return;
}
const helpPage = $button.attr("data-help-page");
if (helpPage) {
const url = wikiBaseUrl + helpPage;
window.open(url, "_blank");
}
}
async function openInAppHelp($button: JQuery<HTMLElement>) {
if ($button.length === 0) {
return;
}
const inAppHelpPage = $button.attr("data-in-app-help");
if (inAppHelpPage) {
openInAppHelpFromUrl(inAppHelpPage);
}
}
/**
@@ -488,15 +458,6 @@ export async function openInReusableSplit(targetNoteId: string, targetViewMode:
}
}
function initHelpButtons($el: JQuery<HTMLElement> | JQuery<Window>) {
// for some reason, the .on(event, listener, handler) does not work here (e.g. Options -> Sync -> Help button)
// so we do it manually
$el.on("click", (e) => {
openHelp($(e.target).closest("[data-help-page]"));
openInAppHelp($(e.target).closest("[data-in-app-help]"));
});
}
function filterAttributeName(name: string) {
return name.replace(/[^\p{L}\p{N}_:]/gu, "");
}
@@ -960,8 +921,6 @@ export default {
dynamicRequire,
timeLimit,
initHelpDropdown,
initHelpButtons,
openHelp,
filterAttributeName,
isValidAttributeName,
sleep,
@@ -278,9 +278,9 @@
--help-card-background: var(--card-background-color);
--help-card-shadow: 2px 2px 2px rgba(0, 0, 0, 0.2);
--help-card-heading-color: #959595;
--help-kbd-shortcut-color: white;
--help-kbd-shortcut-background: #676767;
--help-kbd-shortcut-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
--help-kbd-shortcut-color: #e0e0e0;
--help-kbd-shortcut-background: #4a4a4a;
--help-kbd-shortcut-border: #3a3a3a;
--help-code-color: inherit;
--help-code-background: #565656;
@@ -278,8 +278,8 @@
--help-card-shadow: 2px 2px 2px rgba(0, 0, 0, 0.1);
--help-card-heading-color: #797979;
--help-kbd-shortcut-color: #3f3f3f;
--help-kbd-shortcut-background: white;
--help-kbd-shortcut-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
--help-kbd-shortcut-background: #f5f5f5;
--help-kbd-shortcut-border: #c9c9c9;
--help-code-color: black;
--help-code-background: #d7d5d5;
@@ -373,15 +373,23 @@ div.tn-tool-dialog {
/* Keyboard shortcut */
.help-dialog .help-cards kbd,
.ck-content kbd {
box-shadow: var(--help-kbd-shortcut-shadow);
margin: 0 4px;
border: none;
border-radius: 4px;
padding: 2px 10px;
display: inline-block;
margin: 0 2px 2px !important;
border: 1px solid var(--help-kbd-shortcut-border);
border-radius: 5px;
padding: 2px 8px;
background: var(--help-kbd-shortcut-background);
box-shadow:
0 2px 0 var(--help-kbd-shortcut-border),
inset 0 -1px 0 var(--help-kbd-shortcut-border);
color: var(--help-kbd-shortcut-color);
font-weight: 500;
font-family: var(--main-font-family) !important;
font-size: 0.75em;
font-weight: normal;
line-height: 1.4;
letter-spacing: 0.5pt;
vertical-align: 1px;
white-space: nowrap;
}
.help-dialog .help-cards kbd:first-child {
@@ -4,7 +4,20 @@
"version_label": "版本:",
"version": "{{appVersion}}(数据库:{{dbVersion}},同步协议:{{syncVersion}})",
"build_info": "创建: {{buildDate}}, 修订: <buildRevision />",
"contributors_label": "贡献者:"
"contributors_label": "贡献者:",
"contributor_roles": {
"lead-dev": "首席开发者",
"original-dev": "原始开发者"
},
"role_brief_history": {
"lead-dev": "Elian Doran 于 2024 年创立了 TriliumNext,这是在 Zadam 从该项目退出后创建的一个社区分支。Zadam 后来将原始仓库转移给 TriliumNext 团队,将两个项目合并回一个。",
"original-dev": "2017年12月25日,Zadam发布了Trilium的第一个测试版(写作时只用一个“l”,不同于那种花)。由于对现有的笔记整理工具不满意,他构建了一个强大的自托管层次化知识库,该项目在GitHub上获得了超过22,000颗星。到了2024年,随着生活变得更加忙碌,他将该项目置于维护模式。"
},
"contributor_full_list": "查看整个社区",
"github_tooltip": "在 GitHub 上报告错误、提出功能建议或贡献代码",
"license_tooltip": "查看许可证",
"donate": "捐赠",
"donate_tooltip": "捐款支持这个项目"
},
"toast": {
"critical-error": {
@@ -2356,7 +2369,12 @@
"download_button": "下载",
"mime": "MIME 类型: ",
"file_size": "文件大小:",
"preview_not_available": "无法预览此类型的笔记。"
"preview_not_available": "无法预览此类型的笔记。",
"highlight_changes": "高亮变更",
"save_revision_now": "立即保存修订",
"save_named_revision": "保存已命名的修订...",
"snapshot_header": "笔记修订快照",
"snapshot_interval_value": "间隔:{{seconds}}秒"
},
"database": {
"title": "数据库"
@@ -442,8 +442,8 @@
"css_class": "value of this label is then added as CSS class to the node representing given note in the tree. This can be useful for advanced theming. Can be used in template notes.",
"icon_class": "value of this label is added as a CSS class to the icon on the tree which can help visually distinguish the notes in the tree. Example might be bx bx-home - icons are taken from boxicons. Can be used in template notes.",
"page_size": "number of items per page in note listing",
"custom_request_handler": "see <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Custom request handler</a>",
"custom_resource_provider": "see <a href=\"javascript:\" data-help-page=\"custom-request-handler.html\">Custom request handler</a>",
"custom_request_handler": "custom request handler",
"custom_resource_provider": "custom resource provider",
"widget": "marks this note as a custom widget which will be added to the Trilium component tree",
"workspace": "marks this note as a workspace which allows easy hoisting",
"workspace_icon_class": "defines box icon CSS class which will be used in tab when hoisted to this note",
@@ -1516,7 +1516,7 @@
"existing_backups": "Existing backups",
"date-and-time": "Date & time",
"path": "Path",
"database_backed_up_to": "Database has been backed up to {{backupFilePath}}",
"database_backed_up_to": "Database has been backed up to {{- backupFilePath}}",
"no_backup_yet": "no backup yet",
"download": "Download"
},
@@ -2248,6 +2248,7 @@
"processing_complete": "OCR processing complete.",
"processing_failed": "Failed to start OCR processing",
"text_filtered_low_confidence": "OCR detected text with {{confidence}}% confidence, but it was discarded because your minimum threshold is {{threshold}}%.",
"image_based_pdf_not_supported": "This PDF appears to be image-based (scanned). Text extraction from image-based PDFs is not yet supported.",
"open_media_settings": "Open Settings",
"view_extracted_text": "View extracted text (OCR)"
},
@@ -2438,7 +2439,9 @@
"pages_one": "{{count}} page",
"pages_other": "{{count}} pages",
"pages_alt": "Page {{pageNumber}}",
"pages_loading": "Loading..."
"pages_loading": "Loading...",
"annotations_one": "{{count}} annotation",
"annotations_other": "{{count}} annotations"
},
"platform_indicator": {
"available_on": "Available on {{platform}}"
@@ -2621,5 +2624,8 @@
"common": {
"save": "Save",
"cancel": "Cancel"
},
"spreadsheet": {
"read-only": "This note is read-only."
}
}
+291 -61
View File
@@ -710,7 +710,8 @@
"word_wrap_auto_description": "Seguir el ajuste global",
"word_wrap_on": "Encendido",
"word_wrap_off": "Apagado",
"save_named_revision": "Guardar revisión nombrada..."
"save_named_revision": "Guardar revisión nombrada...",
"word_wrap": "Ajuste de línea"
},
"onclick_button": {
"no_click_handler": "El widget de botón '{{componentId}}' no tiene un controlador de clics definido"
@@ -1141,12 +1142,12 @@
"fonts": {
"theme_defined": "Tema definido",
"fonts": "Fuentes",
"main_font": "Fuente principal",
"main_font": "Texto de la interfaz",
"font_family": "Familia de fuentes",
"size": "Tamaño",
"note_tree_font": "Fuente del árbol de notas",
"note_detail_font": "Fuente de detalle de nota",
"monospace_font": "Fuente Monospace (código)",
"note_tree_font": "Texto del árbol de notas",
"note_detail_font": "Texto del documento",
"monospace_font": "Texto monoespacio",
"not_all_fonts_available": "Es posible que no todas las fuentes enumeradas estén disponibles en su sistema",
"generic-fonts": "Fuentes genéricas",
"sans-serif-system-fonts": "Fuentes Sans-serif del sistema",
@@ -1181,20 +1182,20 @@
"edited_notes_message": "La pestaña de la cinta Notas editadas se abrirá automáticamente en las notas del día"
},
"theme": {
"title": "Tema",
"theme_label": "Tema",
"title": "Interfaz de usuario",
"theme_label": "Tema de aplicación",
"override_theme_fonts_label": "Sobreescribir fuentes de tema",
"triliumnext": "Trilium (Sigue el esquema de color del sistema)",
"triliumnext-light": "Trilium (Claro)",
"triliumnext-dark": "Trilium (Oscuro)",
"triliumnext": "Seguir el esquema de color del sistema",
"triliumnext-light": "Claro",
"triliumnext-dark": "Oscuro",
"layout": "Disposición",
"layout-vertical-title": "Vertical",
"layout-horizontal-title": "Horizontal",
"layout-vertical-description": "la barra del lanzador está en la izquierda (por defecto)",
"layout-vertical-description": "la barra de lanzamiento está en la izquierda (por defecto)",
"layout-horizontal-description": "la barra de lanzamiento está debajo de la barra de pestañas, la barra de pestañas ahora tiene ancho completo.",
"auto_theme": "Heredado (Sigue el esquema de colores del sistema)",
"light_theme": "Heredado (Claro)",
"dark_theme": "Heredado (Oscuro)",
"auto_theme": "Seguir el esquema de colores del sistema",
"light_theme": "Claro",
"dark_theme": "Oscuro",
"modern_themes": "Moderno",
"legacy_themes": "Heredado",
"custom_themes": "Personalizado"
@@ -1206,7 +1207,7 @@
"code_auto_read_only_size": {
"title": "Tamaño automático de solo lectura",
"description": "El tamaño de nota de solo lectura automático es el tamaño después del cual las notas se mostrarán en modo de solo lectura (por razones de rendimiento).",
"label": "Tamaño automático de solo lectura (notas de código)",
"label": "Tamaño automático de solo lectura",
"unit": "caracteres"
},
"code-editor-options": {
@@ -1231,8 +1232,8 @@
"images": {
"images_section_title": "Imágenes",
"download_images_automatically": "Descargar imágenes automáticamente",
"download_images_description": "El HTML pegado puede contener referencias a imágenes en línea; Trilium encontrará esas referencias y descargará las imágenes para que estén disponibles sin conexión.",
"enable_image_compression": "Habilitar la compresión de imágenes",
"download_images_description": "Descargar imágenes en línea referenciadas del HTML pegado para que estén disponibles sin conexión.",
"enable_image_compression": "Compresión de imágenes",
"max_image_dimensions": "Dimensiones máximas de imagen",
"max_image_dimensions_unit": "píxeles",
"jpeg_quality_description": "Se recomienda un rango 50-85. Valores más pequeños reducen el tamaño del archivo, valores más altos preservan los detalles.",
@@ -1250,30 +1251,37 @@
"batch_ocr_start": "Iniciar procesamiento por lotes",
"batch_ocr_starting": "Iniciando procesamiento por lotes...",
"batch_ocr_progress": "Procesando {{processed}} de {{total}} archivos...",
"batch_ocr_completed": "¡Procesamiento por lotes completado! Se procesaron {{processed}} archivos."
"batch_ocr_completed": "¡Procesamiento por lotes completado! Se procesaron {{processed}} archivos.",
"batch_ocr_error": "Error durante el procesamiento por lotes: {{error}}"
},
"attachment_erasure_timeout": {
"attachment_erasure_timeout": "Tiempo de espera para borrar archivos adjuntos",
"attachment_erasure_timeout": "Archivos adjuntos sin usar",
"erase_attachments_after": "Borrar archivos adjuntos después de",
"manual_erasing_description": "Activar el borrado manualmente, ignorando el tiempo de espera definido anteriormente.",
"erase_unused_attachments_now": "Borrar ahora los archivos adjuntos no utilizados en la nota",
"unused_attachments_erased": "Los archivos adjuntos no utilizados se han eliminado."
"erase_unused_attachments_now": "Borrar ahora los archivos adjuntos no utilizados",
"unused_attachments_erased": "Los archivos adjuntos no utilizados se han eliminado.",
"description": "Los archivos adjuntos que ya no son referenciados por ninguna nota se consideran no utilizados y se pueden borrar automáticamente después de un período de tiempo.",
"erase_attachments_after_description": "El tiempo antes del cual los archivos adjuntos no utilizados se borran permanentemente."
},
"network_connections": {
"network_connections_title": "Conexiones de red",
"check_for_updates": "Buscar actualizaciones automáticamente"
"network_connections_title": "Red",
"check_for_updates": "Buscar actualizaciones automáticamente",
"check_for_updates_description": "Checa por nuevas versiones en GitHub y muestra una notificación en el menú global cuando estén disponibles."
},
"note_erasure_timeout": {
"note_erasure_timeout_title": "Tiempo de espera de borrado de notas",
"note_erasure_timeout_title": "Notas eliminadas",
"erase_notes_after": "Borrar notas después de",
"manual_erasing_description": "Activar el borrado manualmente, ignorando el tiempo de espera definido anteriormente.",
"erase_deleted_notes_now": "Borrar notas eliminadas ahora",
"deleted_notes_erased": "Las notas eliminadas han sido borradas."
"deleted_notes_erased": "Las notas eliminadas han sido borradas.",
"description": "Las notas eliminadas primero sólo se marcan como eliminadas y se pueden recuperar de Notas recientes. Después de un período de tiempo, se borran permanentemente.",
"erase_notes_after_description": "El tiempo antes de que las notas eliminadas se borran permanentemente."
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "Intervalo de instantáneas de revisiones de notas",
"note_revisions_snapshot_description": "El intervalo de tiempo de la instantánea de revisión de nota es el tiempo después de lo cual se creará una nueva revisión para la nota. Ver <doc>wiki</doc> para obtener más información.",
"snapshot_time_interval_label": "Intervalo de la instantánea"
"snapshot_time_interval_label": "Intervalo de la instantánea",
"note_revisions_snapshot_description_short": "Tiempo después del cual se creará una nueva revisión de nota."
},
"revisions_snapshot_limit": {
"note_revisions_snapshot_limit_title": "Límite de respaldos de revisiones de nota",
@@ -1281,31 +1289,36 @@
"snapshot_number_limit_label": "Revisiones máximas",
"snapshot_number_limit_unit": "respaldos",
"erase_excess_revision_snapshots": "Eliminar el exceso de respaldos de revisiones ahora",
"erase_excess_revision_snapshots_prompt": "El exceso de respaldos de revisiones han sido eliminadas."
"erase_excess_revision_snapshots_prompt": "El exceso de respaldos de revisiones han sido eliminadas.",
"note_revisions_snapshot_limit_description_short": "Revisiones máximas por nota. Use -1 para ilimitado, 0 para desactivar.",
"erase_excess_revision_snapshots_description": "Eliminar revisiones que excedan el límite para todas las notas."
},
"search_engine": {
"title": "Motor de búsqueda",
"custom_search_engine_info": "Utilizado al buscar la web para el texto seleccionado. Si no está configurado, DuckDuckGo será utilizado.",
"predefined_templates_label": "Plantillas de motor de búsqueda predefinidas",
"predefined_templates_label": "Valores predeterminados",
"bing": "Bing",
"baidu": "Baidu",
"duckduckgo": "DuckDuckGo",
"google": "Google",
"custom_name_label": "Nombre del motor de búsqueda personalizado",
"custom_name_placeholder": "Personalizar el nombre del motor de búsqueda",
"custom_url_label": "La URL del motor de búsqueda personalizado debe incluir {keyword} como marcador de posición para el término de búsqueda.",
"custom_url_placeholder": "Personalizar la URL del motor de búsqueda",
"save_button": "Guardar"
"custom_name_label": "Nombre",
"custom_name_placeholder": "Nombre del motor de búsqueda",
"custom_url_label": "URL",
"custom_url_placeholder": "URL del motor de búsqueda",
"save_button": "Guardar",
"custom_url_description": "Utiliza {keyword} como marcador de posición para el término de búsqueda."
},
"tray": {
"title": "Bandeja de sistema",
"enable_tray": "Habilitar bandeja (es necesario reiniciar Trilium para que este cambio surta efecto)"
"enable_tray": "Icono de bandeja",
"enable_tray_description": "Trilium debe ser reiniciado para que este cambio surta efecto."
},
"heading_style": {
"title": "Estilo de título",
"title": "Estilo de títulos",
"plain": "Plano",
"underline": "Subrayar",
"markdown": "Estilo Markdown"
"markdown": "Estilo Markdown",
"description": "Estilo visual para encabezados en notas de texto."
},
"highlights_list": {
"title": "Lista de puntos destacados",
@@ -1329,14 +1342,16 @@
"text_auto_read_only_size": {
"title": "Tamaño para modo de solo lectura automático",
"description": "El tamaño de nota de solo lectura automático es el tamaño después del cual las notas se mostrarán en modo de solo lectura (por razones de rendimiento).",
"label": "Tamaño para modo de solo lectura automático (notas de texto)",
"label": "Tamaño para modo de solo lectura automático",
"unit": "caracteres"
},
"custom_date_time_format": {
"title": "Formato de fecha/hora personalizada",
"title": "Formato de fecha/hora",
"description": "Personalizar el formado de fecha y la hora insertada vía <shortcut /> o la barra de herramientas. Véa la <doc>documentación de Day.js</doc> para más tokens de formato disponibles.",
"format_string": "Dar formato a cadena",
"formatted_time": "Fecha/hora personalizada"
"formatted_time": "Fecha/hora personalizada",
"description_short": "Personalizar el formato de la fecha y el tiempo insertados a través de la barra de herramientas.",
"preview": "Vista previa: {{preview}}"
},
"i18n": {
"title": "Localización",
@@ -1346,10 +1361,10 @@
"monday": "Lunes",
"first-week-of-the-year": "Primer semana del año",
"first-week-contains-first-day": "Primer semana que contiene al primer día del año",
"first-week-contains-first-thursday": "Primer semana que contiene al primer jueves del año",
"first-week-contains-first-thursday": "Primer semana que contiene al primer jueves (ISO 8601)",
"first-week-has-minimum-days": "Primer semana que contiene un mínimo de días",
"min-days-in-first-week": "Días mínimos en la primer semana",
"first-week-warning": "Cambiar las opciones de primer semana puede causar duplicados con las Notas Semanales existentes y las Notas Semanales existentes no serán actualizadas respectivamente.",
"first-week-warning": "Cambiar esto puede causar duplicados con las Notas semanales existentes.",
"formatting-locale": "Fecha y formato de número",
"tuesday": "Martes",
"wednesday": "Miércoles",
@@ -1361,9 +1376,9 @@
"backup": {
"automatic_backup": "Copia de seguridad automática",
"automatic_backup_description": "Trilium puede realizar copias de seguridad de la base de datos automáticamente:",
"enable_daily_backup": "Habilitar copia de seguridad diaria",
"enable_weekly_backup": "Habilitar copia de seguridad semanal",
"enable_monthly_backup": "Habilitar copia de seguridad mensual",
"enable_daily_backup": "Copia de seguridad diaria",
"enable_weekly_backup": "Copia de seguridad semanal",
"enable_monthly_backup": "Copia de seguridad mensual",
"backup_recommendation": "Se recomienda mantener la copia de seguridad activada, pero esto puede ralentizar el inicio de la aplicación con bases de datos grandes y/o dispositivos de almacenamiento lentos.",
"backup_now": "Realizar copia de seguridad ahora",
"backup_database_now": "Realizar copia de seguridad de la base de datos ahora",
@@ -1372,7 +1387,8 @@
"path": "Ruta",
"database_backed_up_to": "Se ha realizado una copia de seguridad de la base de datos en {{backupFilePath}}",
"no_backup_yet": "no hay copia de seguridad todavía",
"download": "Descargar"
"download": "Descargar",
"title": "Respaldo"
},
"etapi": {
"title": "ETAPI",
@@ -1407,7 +1423,7 @@
"new_password": "Nueva contraseña",
"new_password_confirmation": "Confirmación de nueva contraseña",
"change_password": "Cambiar contraseña",
"protected_session_timeout": "Tiempo de espera de sesión protegida",
"protected_session_timeout": "Sesión protegida",
"protected_session_timeout_description": "El tiempo de espera de la sesión protegida es el período de tiempo después del cual la sesión protegida se borra de la memoria del navegador. Esto se mide desde la última interacción con notas protegidas. Ver",
"wiki": "wiki",
"for_more_info": "para más información.",
@@ -1418,7 +1434,11 @@
"set_password_heading": "Establecer contraseña",
"set_password": "Establecer contraseña",
"password_mismatch": "Las nuevas contraseñas no son las mismas.",
"password_changed_success": "La contraseña ha sido cambiada. Trilium se recargará después de presionar Aceptar."
"password_changed_success": "La contraseña ha sido cambiada. Trilium se recargará después de presionar Aceptar.",
"change_password_description": "Actualice su contraseña actual",
"reset_password": "Reiniciar contraseña",
"reset_password_description": "Perder el acceso permanente a las notas protegidas",
"cancel": "Cancelar"
},
"multi_factor_authentication": {
"title": "Autenticación Multi-Factor",
@@ -1472,7 +1492,13 @@
"description": "Estas opciones se aplican sólo para compilaciones de escritorio; los navegadores utilizarán su corrector ortográfico nativo.",
"enable": "Habilitar corrector ortográfico",
"language_code_label": "Código(s) de idioma",
"restart-required": "Los cambios en las opciones de corrección ortográfica entrarán en vigor después del reinicio de la aplicación."
"restart-required": "Los cambios en las opciones de corrección ortográfica entrarán en vigor después del reinicio de la aplicación.",
"custom_dictionary_title": "Diccionario personalizado",
"custom_dictionary_description": "Las palabras agregadas al diccionario se sincronizan en todos sus dispositivos.",
"custom_dictionary_edit": "Palabras personalizadas",
"custom_dictionary_edit_description": "Edita la lista de palabras que no deben ser marcadas por el corrector ortográfico. Los cambios serán visibles después de un reinicio.",
"custom_dictionary_open": "Editar diccionario",
"related_description": "Configurar idiomas del corrector ortográfico y diccionario personalizado."
},
"sync_2": {
"config_title": "Configuración de sincronización",
@@ -1484,7 +1510,10 @@
"test_title": "Prueba de sincronización",
"test_description": "Esto probará la conexión y el protocolo de enlace con el servidor de sincronización. Si el servidor de sincronización no está inicializado, esto lo configurará para sincronizarse con el documento local.",
"test_button": "Prueba de sincronización",
"handshake_failed": "Error en el protocolo de enlace del servidor de sincronización, error: {{message}}"
"handshake_failed": "Error en el protocolo de enlace del servidor de sincronización, error: {{message}}",
"server_address_description": "URL del servidor Trilium para sincronizar.",
"timeout_description": "Tiempo de espera antes de dar por perdida una conexión lenta.",
"proxy_description": "Deje en blanco para usar el proxy del sistema (sólo escritorio). Use \"noproxy\" para evitar todos los proxies."
},
"api_log": {
"close": "Cerrar"
@@ -1581,7 +1610,9 @@
"book": "Colección",
"new-feature": "Nuevo",
"collections": "Colecciones",
"spreadsheet": "Hoja de cálculo"
"spreadsheet": "Hoja de cálculo",
"markdown": "Markdown",
"llm-chat": "Chat de IA"
},
"protect_note": {
"toggle-on": "Proteger la nota",
@@ -1740,7 +1771,9 @@
"entrypoints": {
"note-revision-created": "Una revisión de nota ha sido creada.",
"note-executed": "Nota ejecutada.",
"sql-error": "Ocurrió un error al ejecutar la consulta SQL: {{message}}"
"sql-error": "Ocurrió un error al ejecutar la consulta SQL: {{message}}",
"save-named-revision-title": "Guardar revisión nombrada",
"save-named-revision-message": "Introduzca un nombre para esta revisión (deje vacío para dejar sin nombre):"
},
"branches": {
"cannot-move-notes-here": "No se pueden mover notas aquí.",
@@ -1786,7 +1819,10 @@
"theme_none": "Sin resaltado de sintaxis",
"theme_group_light": "Temas claros",
"theme_group_dark": "Temas oscuros",
"copy_title": "Copiar al portapapeles"
"copy_title": "Copiar al portapapeles",
"tab_width": "Ancho de tabulador",
"tab_width_unit": "espacios",
"click_to_copy": "Haga clic para copiar"
},
"classic_editor_toolbar": {
"title": "Formato"
@@ -1805,7 +1841,8 @@
"title": "Fijo",
"description": "las herramientas de edición aparecen en la pestaña de la cinta \"Formato\")."
},
"multiline-toolbar": "Mostrar la barra de herramientas en múltiples líneas si no cabe"
"multiline-toolbar": "Mostrar la barra de herramientas en múltiples líneas si no cabe",
"toolbar_style": "Estilo de barra de herramientas"
}
},
"electron_context_menu": {
@@ -2031,11 +2068,11 @@
},
"ui-performance": {
"title": "Rendimiento",
"enable-motion": "Habilitar transiciones y animaciones",
"enable-shadows": "Activar sombras",
"enable-backdrop-effects": "Habilitar efectos de fondo para menús, ventanas emergentes y paneles",
"enable-smooth-scroll": "Habilitar desplazamiento suave",
"app-restart-required": "(es necesario reiniciar la aplicación para que el cambio surta efecto)"
"enable-motion": "Transiciones y animaciones",
"enable-shadows": "Sombras",
"enable-backdrop-effects": "Efectos de fondo para menús, ventanas emergentes y paneles",
"enable-smooth-scroll": "Desplazamiento suave",
"app-restart-required": "Requiere reiniciar la aplicación"
},
"settings": {
"related_settings": "Configuración relacionada"
@@ -2045,7 +2082,9 @@
"related_code_notes": "Esquema de colores para notas de código",
"ui": "Interfaz de usuario",
"ui_old_layout": "Antiguo diseño",
"ui_new_layout": "Nuevo diseño"
"ui_new_layout": "Nuevo diseño",
"ui_layout_style": "Estilo de diseño",
"ui_layout_orientation": "Orientación de la barra de lanzamiento"
},
"units": {
"percentage": "%"
@@ -2099,7 +2138,19 @@
"note_info_title": "Ver información de la nota (p. e., fechas, tamaño de la nota)",
"attributes_title": "Atributos propios y atributos heredados",
"note_paths_title": "Rutas de nota",
"code_note_switcher": "Cambiar modo de idioma"
"code_note_switcher": "Cambiar modo de idioma",
"tab_width": "Ancho de tabulación: {{width}}",
"tab_width_title": "Cambiar ancho de tabulación",
"tab_width_spaces": "{{count}} espacios",
"tab_width_spaces_short": "Espacios: {{width}}",
"tab_width_tabs": "Tabulaciones: {{width}}",
"tab_width_use_default": "Usar predeterminado ({{width}})",
"tab_width_use_default_style": "Usar predeterminado ({{style}})",
"tab_width_display_header": "Visualización",
"tab_width_reindent_header": "Reindentar contenido a",
"tab_width_style_header": "Indentar usando",
"tab_width_style_spaces": "Espacios",
"tab_width_style_tabs": "Tabuladores"
},
"pdf": {
"attachments_one": "{{count}} adjunto",
@@ -2278,7 +2329,9 @@
"sample_xy": "XY",
"sample_venn": "Venn",
"sample_ishikawa": "Ishikawa",
"sample_treemap": "Mapa de árbol"
"sample_treemap": "Mapa de árbol",
"sample_treeview": "Vista de árbol",
"sample_wardley": "Mapa de Wardley"
},
"revisions": {
"note_revisions": "Revisiones de nota",
@@ -2341,5 +2394,182 @@
},
"database": {
"title": "Base de datos"
},
"revisions_snapshot": {
"title": "Revisiones de nota"
},
"search": {
"title": "Buscar",
"fuzzy_matching_label": "Tolerancia a errores tipográficos en la búsqueda",
"fuzzy_matching_description": "Afecta la búsqueda rápida y la búsqueda completa. Encuentra palabras similares cuando las coincidencias exactas son insuficientes.",
"autocomplete_fuzzy_label": "Tolerancia a errores tipográficos en autocompletado",
"autocomplete_fuzzy_description": "Afecta a los selectores de nota y salto a nota. Más lento pero tolera errores tipográficos."
},
"text_editor": {
"title": "Editor"
},
"llm_chat": {
"placeholder": "Escriba un mensaje...",
"send": "Enviar",
"sending": "Enviando...",
"empty_state": "Iniciar una conversación escribiendo un mensaje a continuación.",
"searching_web": "Buscando la web...",
"web_search": "Búsqueda web",
"note_tools": "Acceso a la nota",
"sources": "Fuentes",
"sources_summary": "{{count}} fuentes de {{sites}} sitios",
"extended_thinking": "Pensamiento extendido",
"legacy_models": "Modelos heredados",
"thinking": "Pensando...",
"thought_process": "Proceso de pensamiento",
"tool_calls": "{{count}} llamada(s) de herramienta",
"input": "Entrada",
"result": "Resultado",
"error": "Error",
"tool_error": "fallido",
"total_tokens": "{{total}} tokens",
"tokens": "tokens",
"context_used": "{{percentage}}% utilizado",
"note_context_enabled": "Haga clic para desactivar el contexto de la nota: {{title}}",
"note_context_disabled": "Haga clic para incluir la nota actual en contexto",
"no_provider_message": "Ningún proveedor de IA configurado. Añada uno para empezar a charlar.",
"add_provider": "Añadir proveedor de IA",
"stop": "Parar",
"tokens_detail": "{{prompt}} (entrada) + {{completion}} (respuesta)",
"tokens_used": "{{prompt}} (entrada) + {{completion}} (respuesta) = {{total}} tokens",
"tokens_used_with_cost": "{{prompt}} (entrada) + {{completion}} (respuesta) = {{total}} tokens (~${{cost}})",
"tokens_used_with_model": "{{model}}: {{prompt}} (entrada) + {{completion}} (respuesta) = {{total}} tokens",
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} (entrada) + {{completion}} (respuesta) = {{total}} tokens (~${{cost}})"
},
"sidebar_chat": {
"title": "Chat de IA",
"launcher_title": "Abrir chat de IA",
"new_chat": "Iniciar nuevo chat",
"save_chat": "Guardar chat a notas",
"empty_state": "Iniciar una conversación",
"history": "Historial de chat",
"recent_chats": "Chats recientes",
"no_chats": "No hay chats anteriores"
},
"link": {
"failed_to_open": "Fallo al abrir enlace '{{- href}}': {{- message}}"
},
"display_mode": {
"source": "Vista de fuente",
"split": "Vista dividida",
"preview": "Vista previa"
},
"ocr": {
"extracted_text": "Texto extraído (OCR)",
"extracted_text_title": "Texto extraído (OCR)",
"loading_text": "Cargando texto OCR...",
"no_text_available": "No hay texto OCR disponible",
"no_text_explanation": "Esta nota no ha sido procesada para la extracción de texto OCR o no se encontró ningún texto.",
"failed_to_load": "Fallo al cargar texto OCR",
"process_now": "Proceso OCR",
"processing": "Procesando...",
"processing_started": "Se ha iniciado el procesamiento de OCR. Por favor, espere un momento y refresque.",
"processing_complete": "Procesamiento de OCR completo.",
"processing_failed": "Fallo al iniciar procesamiento de OCR",
"text_filtered_low_confidence": "Texto OCR detectado con {{confidence}}% de confianza, pero fue descartado porque su umbral mínimo es {{threshold}}%.",
"open_media_settings": "Abrir ajustes",
"view_extracted_text": "Ver texto extraído (OCR)"
},
"launcher_button_context_menu": {
"remove_from_launch_bar": "Quitar de la barra de inicio"
},
"print_preview": {
"title": "Vista previa de impresión",
"close": "Cerrar",
"save": "Guardar como PDF",
"print": "Imprimir",
"export_pdf": "Exportar como PDF",
"system_print": "Imprimir usando el diálogo del sistema",
"destination": "Destino",
"destination_pdf": "Guardar como PDF",
"destination_printers": "Impresoras",
"destination_default": "Predeterminado",
"orientation": "Orientación",
"portrait": "Vertical",
"landscape": "Horizontal",
"page_size": "Tamaño de página",
"scale": "Escala",
"margins": "Márgenes",
"render_error": "No se puede generar el PDF con la configuración actual. Comprueba los márgenes y la escala.",
"margins_default": "Predeterminados",
"margins_none": "Sin márgenes",
"margins_minimum": "Mínimos",
"margins_custom": "Personalizados",
"margin_top": "Superior",
"margin_right": "Derecho",
"margin_bottom": "Inferior",
"margin_left": "Izquierdo",
"page_ranges": "Páginas",
"page_ranges_hint": "Déjelo vacío para imprimir todas las páginas.",
"page_ranges_invalid": "Formato inválido. Use por ejemplo: 1-5, 8, 11-13.",
"page_ranges_placeholder": "Ej.: 1-5, 8, 11-13"
},
"mind-map": {
"addChild": "Añadir hijo",
"addParent": "Añadir padre",
"addSibling": "Añadir hermano",
"removeNode": "Eliminar nodo",
"focus": "Modo enfoque",
"cancelFocus": "Salir del modo enfoque",
"moveUp": "Mover arriba",
"moveDown": "Mover abajo",
"link": "Enlace",
"linkBidirectional": "Enlace bidireccional",
"clickTips": "Haz clic en el nodo de destino",
"summary": "Resumen"
},
"llm": {
"settings_title": "IA / LLM",
"settings_description": "Configure integraciones de inteligencia artificial y modelos de lenguaje.",
"feature_not_enabled": "Active la función experimental LLM en Ajustes → Avanzado → Funciones experimentales para usar integraciones de IA.",
"add_provider": "Añadir proveedor",
"add_provider_title": "Añadir proveedor de IA",
"configured_providers": "Proveedores configurados",
"no_providers_configured": "Aún no hay proveedores configurados.",
"provider_name": "Nombre",
"provider_type": "Proveedor",
"actions": "Acciones",
"delete_provider": "Eliminar",
"delete_provider_confirmation": "¿Está seguro de que desea eliminar el proveedor \"{{name}}\"?",
"api_key": "Clave API",
"api_key_placeholder": "Introduzca su clave API",
"cancel": "Cancelar",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "Servidor MCP",
"mcp_enabled_description": "Exponga un endpoint MCP (Model Context Protocol) para que asistentes de IA (como Claude Code o GitHub Copilot) puedan leer y modificar sus notas. El endpoint solo es accesible desde localhost.",
"mcp_endpoint_title": "URL del endpoint",
"mcp_endpoint_description": "Añada esta URL a la configuración MCP de su asistente de IA",
"tools": {
"search_notes": "Buscar notas",
"get_note": "Obtener nota",
"get_note_content": "Obtener contenido de la nota",
"update_note_content": "Actualizar contenido de la nota",
"append_to_note": "Añadir al final de la nota",
"create_note": "Crear nota",
"get_attributes": "Obtener atributos",
"get_attribute": "Obtener atributo",
"set_attribute": "Establecer atributo",
"delete_attribute": "Eliminar atributo",
"get_child_notes": "Obtener notas hijas",
"get_subtree": "Obtener subárbol",
"load_skill": "Cargar habilidad",
"web_search": "Buscar en la web",
"note_in_parent": "<Note/> en <Parent/>",
"get_attachment": "Obtener adjunto",
"get_attachment_content": "Leer contenido del adjunto",
"rename_note": "Renombrar nota",
"delete_note": "Eliminar nota",
"move_note": "Mover nota",
"clone_note": "Clonar nota"
}
},
"common": {
"save": "Guardar",
"cancel": "Cancelar"
}
}
@@ -31,7 +31,24 @@
"search_notes": "Cuardaigh nótaí"
},
"about": {
"data_directory": "Eolaire sonraí:"
"data_directory": "Eolaire sonraí:",
"version_label": "Leagan:",
"version": "{{appVersion}} (bunachar sonraí: {{dbVersion}}, prótacal sioncrónaithe: {{syncVersion}})",
"build_info": "Tógáil: {{buildDate}}, athbhreithniú: <buildRevision />",
"contributors_label": "Rannpháirtithe:",
"contributor_roles": {
"lead-dev": "príomhfhorbróir",
"original-dev": "forbróir bunaidh"
},
"role_brief_history": {
"lead-dev": "Bhunaigh Elian Doran TriliumNext sa bhliain 2024, forc pobail a cruthaíodh tar éis do Zadam éirí as an tionscadal. Ina dhiaidh sin, aistrigh Zadam an stór bunaidh chuig foireann TriliumNext, ag cumasc an dá thionscadal ar ais in aon tionscadal amháin.",
"original-dev": "Ar an 25 Nollaig 2017, d’eisigh Zadam an chéad béite de Trilium (scríofa le \"l\" aonair, murab ionann agus an bláth). Gan a bheith sásta leis na heagraithe nótaí a bhí ann cheana féin, thóg sé bonn eolais ordlathach cumhachtach féin-óstáilte a bhailigh breis is 22,000 réalta GitHub. Sa bhliain 2024, de réir mar a bhí an saol ag éirí níos gnóthaí, chuir sé an tionscadal i mód cothabhála."
},
"contributor_full_list": "Féach ar an bpobal ar fad",
"github_tooltip": "Tuairiscigh fabhtanna, mol gnéithe, nó cuir le chéile ar GitHub",
"license_tooltip": "Féach ar an gceadúnas",
"donate": "Tabhair Síntiús",
"donate_tooltip": "Tabhair síntiús chun tacú leis an tionscadal seo"
},
"toast": {
"critical-error": {
+273 -93
View File
@@ -219,12 +219,12 @@
"access_info": "Per accedere alle informazioni di debug, eseguire la query e fare clic su \"Mostra registro backend\" nell'angolo in alto a sinistra."
},
"database_anonymization": {
"light_anonymization": "Anonimizzazione parziale",
"light_anonymization": "Anonimizzazione leggera",
"title": "Anonimizzazione dei database",
"full_anonymization": "Anonimizzazione completa",
"full_anonymization_description": "Questa azione creerà una nuova copia del database e la renderà anonima (rimuovendo tutto il contenuto delle note e lasciando solo la struttura e alcuni metadati non sensibili) per poterla condividere online a scopo di debug senza timore di divulgare i tuoi dati personali.",
"full_anonymization_description": "Crea una copia del database da cui vengono rimossi tutti i contenuti delle note, lasciando solo la struttura e i metadati non sensibili. È sicuro da condividere online durante la risoluzione dei problemi.",
"save_fully_anonymized_database": "Salva database completamente anonimizzato",
"light_anonymization_description": "Questa azione creerà una nuova copia del database e lo anonimizzerà in parzialmente — in particolare, solo il contenuto delle note sarà rimosso, ma i titoli e gli attributi rimarranno. Inoltre, note con script personalizzati JS di frontend/backend e widget personalizzati lasciando rimarranno. Ciò mette a disposizione più contesto per il debug dei problemi.",
"light_anonymization_description": "Crea una copia in cui il contenuto delle note viene rimosso, mentre i titoli, gli attributi e gli script/widget personalizzati rimangono invariati. Fornisce maggiori informazioni di contesto per il debug.",
"choose_anonymization": "Puoi decidere da solo se fornire un database completamente o parzialmente anonimizzato. Anche un database completamente anonimizzato è molto utile, sebbene in alcuni casi i database parzialmente anonimizzati possono accelerare il processo di identificazione dei bug e la loro correzione.",
"no_anonymized_database_yet": "Ancora nessun database anonimizzato.",
"save_lightly_anonymized_database": "Salva database leggermente anonimizzato",
@@ -233,7 +233,8 @@
"existing_anonymized_databases": "Database anonimizzati esistenti",
"creating_fully_anonymized_database": "Creazione di un database completamente anonimizzato...",
"creating_lightly_anonymized_database": "Creazione di un database leggermente anonimizzato...",
"error_creating_anonymized_database": "Impossibile creare un database anonimo, controllare i log del backend per i dettagli"
"error_creating_anonymized_database": "Impossibile creare un database anonimo, controllare i log del backend per i dettagli",
"description": "Crea una copia anonimizzata del tuo database da condividere con gli sviluppatori durante la risoluzione dei problemi, senza divulgare dati personali."
},
"cpu_arch_warning": {
"title": "Per favore scarica la versione ARM64",
@@ -246,12 +247,12 @@
},
"editorfeatures": {
"title": "Caratteristiche",
"emoji_completion_enabled": "Abilita il completamento automatico delle Emoji",
"note_completion_enabled": "Abilita il completamento automatico delle note",
"emoji_completion_description": "Se abilitata, è possibile inserire facilmente gli emoji nel testo digitando `:`, seguito dal nome dell'emoji.",
"note_completion_description": "Se abilitato, è possibile creare collegamenti alle note digitando `@` seguito dal titolo di una nota.",
"slash_commands_enabled": "Abilita i comandi slash",
"slash_commands_description": "Se abilitato, i comandi di modifica come l'inserimento di interruzioni di riga o intestazioni possono essere attivati digitando `/`."
"emoji_completion_enabled": "Completamento automatico delle emoji",
"note_completion_enabled": "Suggerimenti automatici",
"emoji_completion_description": "È possibile inserire facilmente gli emoji nel testo digitando `:`, seguito dal nome dell'emoji.",
"note_completion_description": "È possibile creare collegamenti alle note digitando `@` seguito dal titolo di una nota.",
"slash_commands_enabled": "Comandi Slash",
"slash_commands_description": "È possibile attivare o disattivare comandi di modifica come l'inserimento di interruzioni di riga o di intestazioni digitando `/`."
},
"table_view": {
"new-row": "Nuova riga",
@@ -363,7 +364,7 @@
},
"editing": {
"editor_type": {
"multiline-toolbar": "Mostra la barra degli strumenti su più linee se non entra.",
"multiline-toolbar": "Visualizza la barra degli strumenti su più righe se non entra",
"label": "Barra degli strumenti di formattazione",
"floating": {
"title": "Galleggiante",
@@ -372,7 +373,8 @@
"fixed": {
"title": "Fisso",
"description": "gli strumenti di modifica vengono visualizzati nella scheda \"Formattazione\" della barra multifunzione."
}
},
"toolbar_style": "Stile della barra degli strumenti"
}
},
"edit_button": {
@@ -470,8 +472,8 @@
"column-already-exists": "Questa colonna esiste già nella bacheca."
},
"backup": {
"enable_weekly_backup": "Abilita le archiviazioni settimanali",
"enable_monthly_backup": "Abilita le archiviazioni mensili",
"enable_weekly_backup": "Backup settimanali",
"enable_monthly_backup": "Backup mensili",
"backup_recommendation": "Si raccomanda di mantenere attive le archiviazioni, sebbene ciò possa rendere l'avvio dell'applicazione lento con database grandi e/o dispositivi di archiviazione lenti.",
"backup_now": "Archivia adesso",
"backup_database_now": "Archivia il database adesso",
@@ -479,11 +481,12 @@
"date-and-time": "Data e ora",
"path": "Percorso",
"database_backed_up_to": "Il database è stato archiviato in {{backupFilePath}}",
"enable_daily_backup": "Abilita i backup giornalieri",
"enable_daily_backup": "Backup giornalieri",
"no_backup_yet": "Ancora nessuna archiviazione",
"automatic_backup": "Backup Automatico",
"automatic_backup_description": "Trilium può eseguire automaticamente il backup del database:",
"download": "Scarica"
"download": "Scarica",
"title": "Backup"
},
"backend_log": {
"refresh": "Aggiorna"
@@ -492,14 +495,18 @@
"find_and_fix_button": "Trova e correggi i problemi di coerenza",
"finding_and_fixing_message": "In cerca e correzione dei problemi di coerenza...",
"issues_fixed_message": "Qualsiasi problema di coerenza che possa essere stato trovato ora è corretto.",
"title": "Controlli di coerenza"
"title": "Controlli di coerenza",
"find_and_fix_label": "Individuare e risolvere i problemi di coerenza",
"find_and_fix_description": "Esegue una scansione e risolve automaticamente eventuali problemi di coerenza dei dati nel database."
},
"database_integrity_check": {
"check_button": "Controllo dell'integrità del database",
"checking_integrity": "Controllo dell'integrità del database in corso...",
"title": "Controllo di Integrità del database",
"integrity_check_failed": "Controllo di integrità fallito: {{results}}",
"integrity_check_succeeded": "Controllo di integrità riuscito: nessun problema riscontrato."
"integrity_check_succeeded": "Controllo di integrità riuscito: nessun problema riscontrato.",
"check_integrity_label": "Verifica l'integrità del database",
"check_integrity_description": "Verifica che il database non sia danneggiato a livello di SQLite."
},
"sync": {
"title": "Sincronizza",
@@ -509,34 +516,41 @@
"full_sync_triggered": "Sincronizzazione completa attivata",
"filling_entity_changes": "Il riempimento delle righe modifica l'entità...",
"sync_rows_filled_successfully": "Sincronizzazione righe compilate correttamente",
"finished-successfully": "Sincronizzazione completata con successo."
"finished-successfully": "Sincronizzazione completata con successo.",
"force_full_sync_label": "Esegui sincronizzazione completa",
"force_full_sync_description": "Avvia una sincronizzazione completa con il server di sincronizzazione, ricaricando tutte le modifiche.",
"fill_entity_changes_label": "Riempimento modifiche entità",
"fill_entity_changes_description": "Ricostruisci i record delle modifiche alle entità. Utilizza questa opzione se la sincronizzazione non ha rilevato alcune modifiche."
},
"sync_2": {
"config_title": "Configurazione per la Sincronizzazione",
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
"test_title": "Test di sincronizzazione",
"timeout": "Timeout per la sincronizzazione",
"config_title": "Server di sincronizzazione",
"proxy_label": "Server proxy",
"test_title": "Verifica connessione",
"timeout": "Timeout della connessione",
"save": "Salva",
"help": "Aiuto",
"server_address": "Indirizzo dell'istanza del server",
"test_description": "Questo testerà la connessione e l'handshake con il server di sincronizzazione. Se il server di sincronizzazione non è inizializzato, questo lo imposterà per la sincronizzazione con il documento locale.",
"server_address": "Indirizzo del server",
"test_description": "Verifica la connessione al server di sincronizzazione. Se non è stata ancora inizializzata, questa operazione avvierà la sincronizzazione.",
"test_button": "Test di sincronizzazione",
"handshake_failed": "Handshake del server di sincronizzazione non riuscito, errore: {{message}}",
"timeout_description": "Quanto tempo aspettare prima di rinunciare con una connessione lenta. Aumenta questo intervallo se hai una rete instabile."
"timeout_description": "Tempo di attesa prima di rinunciare su una connessione lenta.",
"server_address_description": "URL del server Trilium con cui effettuare la sincronizzazione.",
"proxy_description": "Lasciare vuoto per utilizzare il proxy di sistema (solo versione desktop). Utilizzare \"noproxy\" per bypassare tutti i proxy."
},
"search_engine": {
"save_button": "Salva",
"title": "Motore di ricerca",
"custom_search_engine_info": "Il motore di ricerca personalizzato richiede l'impostazione sia di un nome che di un URL. Se uno di questi non viene impostato, DuckDuckGo verrà utilizzato come motore di ricerca predefinito.",
"predefined_templates_label": "Modelli predefiniti per motori di ricerca",
"custom_search_engine_info": "Da utilizzare per effettuare ricerche sul web in base al testo selezionato. Se non viene configurato, verrà utilizzato DuckDuckGo.",
"predefined_templates_label": "Impostazioni predefinite",
"bing": "Bing",
"baidu": "Baidu",
"duckduckgo": "DuckDuckGo",
"google": "Google",
"custom_name_label": "Nome del motore di ricerca personalizzato",
"custom_name_placeholder": "Personalizza il nome del motore di ricerca",
"custom_url_label": "L'URL del motore di ricerca personalizzato deve includere {keyword} come segnaposto per il termine di ricerca.",
"custom_url_placeholder": "Personalizza indirizzo URL del motore di ricerca"
"custom_name_label": "Nome",
"custom_name_placeholder": "Nome del motore di ricerca",
"custom_url_label": "URL",
"custom_url_placeholder": "URL del motore di ricerca",
"custom_url_description": "Utilizza {keyword} come segnaposto per il termine di ricerca."
},
"sql_table_schemas": {
"tables": "Tabelle"
@@ -567,13 +581,15 @@
},
"tray": {
"title": "Vassoio di Sistema",
"enable_tray": "Abilita il vassoio (Trilium necessita di essere riavviato affinché la modifica abbia effetto)"
"enable_tray": "Icona nella barra delle applicazioni",
"enable_tray_description": "Per rendere effettiva questa modifica, è necessario riavviare Trilium."
},
"heading_style": {
"title": "Stile dell'Intestazione",
"title": "Stile dell'intestazione",
"plain": "Semplice",
"underline": "Sottolineato",
"markdown": "Stile Markdown"
"markdown": "Stile Markdown",
"description": "Stile visivo per i titoli nelle note di testo."
},
"highlights_list": {
"title": "Elenco dei punti salienti",
@@ -664,10 +680,10 @@
"sunday": "Domenica",
"first-week-of-the-year": "Prima settimana dell'anno",
"first-week-contains-first-day": "La prima settimana contiene il primo giorno dell'anno",
"first-week-contains-first-thursday": "La prima settimana contiene il primo giovedì dell'anno",
"first-week-contains-first-thursday": "La prima settimana comprende il primo giovedì (ISO 8601)",
"first-week-has-minimum-days": "La prima settimana ha giorni ridotti",
"min-days-in-first-week": "Giorni minimi nella prima settimana",
"first-week-warning": "La modifica delle opzioni della prima settimana può causare duplicati con le note settimanali esistenti e queste ultime non verranno aggiornate di conseguenza.",
"first-week-warning": "Modificare questa impostazione potrebbe causare la creazione di duplicati rispetto alle Note settimanali esistenti.",
"formatting-locale": "Formato data e numero",
"formatting-locale-auto": "In base alla lingua dell'applicazione",
"title": "Localizzazione",
@@ -907,7 +923,9 @@
"print_page_size": "Quando si esporta in PDF, modifica le dimensioni della pagina. Valori supportati: <code>A0</code>, <code>A1</code>, <code>A2</code>, <code>A3</code>, <code>A4</code>, <code>A5</code>, <code>A6</code>, <code>Legal</code>, <code>Letter</code>, <code>Tabloid</code>, <code>Ledger</code>.",
"color_type": "Colore",
"share_root": "segna la nota che viene servita su /share root.",
"textarea": "Testo su più righe"
"textarea": "Testo su più righe",
"print_scale": "Durante l'esportazione in PDF, modifica la scala del contenuto visualizzato. I valori vanno da 0,1 (10%) a 2 (200%); il valore predefinito è 1 (100%).",
"print_margins": "Quando si esporta in PDF, imposta i margini della pagina. Utilizza <code>predefinito</code>, <code>nessuno</code>, <code>minimo</code> o valori personalizzati come <code>superiore, destro, inferiore, sinistro</code> in millimetri."
},
"attribute_editor": {
"help_text_body1": "Per aggiungere un'etichetta, basta digitare ad esempio <code>#rock</code> oppure, se si desidera aggiungere anche un valore, ad esempio <code>#year = 2020</code>",
@@ -1139,7 +1157,13 @@
"export_as_image_png": "PNG (raster)",
"export_as_image_svg": "SVG (vector)",
"note_map": "Mappa",
"view_ocr_text": "Visualizza il testo OCR"
"view_ocr_text": "Visualizza il testo OCR",
"word_wrap": "Rientro automatico",
"word_wrap_auto": "Auto",
"word_wrap_auto_description": "Segui l'impostazione globale",
"word_wrap_on": "On",
"word_wrap_off": "Off",
"save_named_revision": "Salva revisione con nome..."
},
"onclick_button": {
"no_click_handler": "Il widget pulsante '{{componentId}}' non ha un gestore di clic definito"
@@ -1441,18 +1465,20 @@
"description": "Questa operazione ricostruirà il database, generando in genere un file di dimensioni inferiori. In realtà, nessun dato verrà modificato.",
"button_text": "Pulizia del database",
"vacuuming_database": "Aspirazione del database...",
"database_vacuumed": "Il database è stato svuotato"
"database_vacuumed": "Il database è stato svuotato",
"vacuum_label": "Database Vacuum",
"vacuum_description": "Ricostruisci il database per ridurre le dimensioni dei file. I dati non subiranno alcuna modifica."
},
"fonts": {
"theme_defined": "Tema definito",
"fonts": "Caratteri",
"main_font": "Carattere principale",
"main_font": "Testo dell'interfaccia",
"font_family": "Famiglia di caratteri",
"size": "Misurare",
"note_tree_font": "Carattere dell'albero delle note",
"note_detail_font": "Nota Dettaglio Carattere",
"monospace_font": "Monospace (codice) Font",
"not_all_fonts_available": "Non tutti i font elencati potrebbero essere disponibili sul tuo sistema.",
"note_tree_font": "Testo dell'albero Note",
"note_detail_font": "Testo del documento",
"monospace_font": "Testo monospazio",
"not_all_fonts_available": "Non tutti i caratteri elencati potrebbero essere disponibili sul tuo sistema",
"generic-fonts": "Caratteri generici",
"sans-serif-system-fonts": "Caratteri di sistema sans-serif",
"serif-system-fonts": "Caratteri di sistema Serif",
@@ -1461,7 +1487,12 @@
"serif": "Serif",
"sans-serif": "Sans Serif",
"monospace": "Monospaziatura",
"system-default": "Predefinito del sistema"
"system-default": "Predefinito del sistema",
"custom_fonts": "Utilizza caratteri personalizzati",
"preview": "Anteprima",
"monospace_font_description": "Utilizzato per note di codice e blocchi di codice",
"size_relative_to_general": "La dimensione è relativa alla dimensione generale del carattere",
"apply_changes": "Aggiorna la pagina per applicare le modifiche"
},
"max_content_width": {
"title": "Larghezza del contenuto",
@@ -1481,28 +1512,31 @@
"edited_notes_message": "La scheda della barra multifunzione Note modificate si aprirà automaticamente nelle note giornaliere"
},
"theme": {
"title": "Tema dell'applicazione",
"theme_label": "Tema",
"title": "Interfaccia utente",
"theme_label": "Tema dell'applicazione",
"override_theme_fonts_label": "Ignora i font del tema",
"auto_theme": "Legacy (seguire lo schema dei colori del sistema)",
"light_theme": "Eredità (Luce)",
"dark_theme": "Eredità (Oscuro)",
"triliumnext": "Trilium (seguire lo schema dei colori del sistema)",
"triliumnext-light": "Trilium (Luce)",
"triliumnext-dark": "Trilium (scuro)",
"auto_theme": "Segui la combinazione di colori del sistema",
"light_theme": "Chiara",
"dark_theme": "Scura",
"triliumnext": "Segui la combinazione di colori del sistema",
"triliumnext-light": "Chiara",
"triliumnext-dark": "Scura",
"layout": "Disposizione",
"layout-vertical-title": "Verticale",
"layout-horizontal-title": "Orizzontale",
"layout-vertical-description": "la barra di avvio è a sinistra (impostazione predefinita)",
"layout-horizontal-description": "la barra di avvio si trova sotto la barra delle schede, che ora è a larghezza intera."
"layout-horizontal-description": "la barra di avvio si trova sotto la barra delle schede, che ora è a larghezza intera.",
"modern_themes": "Moderna",
"legacy_themes": "Legacy",
"custom_themes": "Personalizzata"
},
"ui-performance": {
"title": "Prestazione",
"enable-motion": "Abilita transizioni e animazioni",
"enable-shadows": "Abilita le ombre",
"enable-backdrop-effects": "Abilita gli effetti di sfondo per menu, popup e pannelli",
"enable-smooth-scroll": "Abilita lo scorrimento fluido",
"app-restart-required": "(per rendere effettiva la modifica è necessario riavviare l'applicazione)"
"enable-motion": "Transizioni e animazioni",
"enable-shadows": "Ombre",
"enable-backdrop-effects": "Effetti di sfondo per menu, finestre a comparsa e pannelli",
"enable-smooth-scroll": "Scorrimento fluido",
"app-restart-required": "È necessario riavviare l'app"
},
"zoom_factor": {
"title": "Fattore di zoom (solo versione desktop)",
@@ -1511,11 +1545,13 @@
"code_auto_read_only_size": {
"title": "Dimensione automatica di sola lettura",
"description": "La dimensione automatica delle note in sola lettura è la dimensione dopo la quale le note verranno visualizzate in modalità di sola lettura (per motivi di prestazioni).",
"label": "Dimensione automatica di sola lettura (note sul codice)",
"label": "Dimensione automatica in sola lettura",
"unit": "caratteri"
},
"code-editor-options": {
"title": "Redattore"
"title": "Redattore",
"tab_width": "Larghezza della scheda",
"tab_width_unit": "spazi"
},
"code_mime_types": {
"title": "Tipi MIME disponibili nel menu a discesa",
@@ -1557,47 +1593,57 @@
"batch_ocr_error": "Errore durante l'elaborazione in batch: {{error}}"
},
"attachment_erasure_timeout": {
"attachment_erasure_timeout": "Timeout cancellazione allegato",
"erase_attachments_after": "Cancella gli allegati non utilizzati dopo:",
"manual_erasing_description": "È anche possibile attivare la cancellazione manualmente (senza considerare il timeout definito sopra):",
"erase_unused_attachments_now": "Cancella subito le note degli allegati non utilizzati",
"unused_attachments_erased": "Gli allegati non utilizzati sono stati cancellati."
"attachment_erasure_timeout": "Allegati non utilizzati",
"erase_attachments_after": "Cancella gli allegati non utilizzati dopo",
"manual_erasing_description": "Avvia la cancellazione manuale, ignorando il timeout sopra indicato.",
"erase_unused_attachments_now": "Elimina subito gli allegati non utilizzati",
"unused_attachments_erased": "Gli allegati non utilizzati sono stati cancellati.",
"description": "Gli allegati a cui non fa più riferimento nessuna nota vengono considerati inutilizzati e possono essere cancellati automaticamente dopo un certo periodo di tempo.",
"erase_attachments_after_description": "Tempo che intercorre prima che gli allegati inutilizzati vengano cancellati definitivamente."
},
"network_connections": {
"network_connections_title": "Connessioni di rete",
"check_for_updates": "Controlla automaticamente gli aggiornamenti"
"network_connections_title": "Rete",
"check_for_updates": "Controlla automaticamente gli aggiornamenti",
"check_for_updates_description": "Verifica la presenza di nuove versioni su GitHub e, se disponibili, visualizza una notifica nel menu globale."
},
"note_erasure_timeout": {
"note_erasure_timeout_title": "Timeout cancellazione nota",
"erase_notes_after": "Cancella le note dopo:",
"manual_erasing_description": "È anche possibile attivare la cancellazione manualmente (senza considerare il timeout definito sopra):",
"note_erasure_timeout_title": "Note eliminate",
"erase_notes_after": "Cancella le note dopo",
"manual_erasing_description": "Avvia la cancellazione manuale, ignorando il timeout sopra indicato.",
"erase_deleted_notes_now": "Cancella subito le note eliminate",
"deleted_notes_erased": "Le note eliminate sono state eliminate."
"deleted_notes_erased": "Le note eliminate sono state eliminate.",
"description": "Le note eliminate vengono inizialmente contrassegnate solo come eliminate e possono essere recuperate dalla sezione \"Note recenti\". Dopo un certo periodo di tempo, vengono cancellate definitivamente.",
"erase_notes_after_description": "Tempo che intercorre prima che le note eliminate vengano cancellate definitivamente."
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "Nota Intervallo di istantanea della revisione",
"note_revisions_snapshot_description": "L'intervallo di snapshot della revisione della nota è il tempo dopo il quale verrà creata una nuova revisione per la nota. Per maggiori informazioni, consultare <doc>wiki</doc>.",
"snapshot_time_interval_label": "Nota intervallo di tempo per l'istantanea della revisione:"
"snapshot_time_interval_label": "Intervallo tra gli snapshot",
"note_revisions_snapshot_description_short": "Intervallo di tempo trascorso il quale verrà creata una nuova versione della nota."
},
"revisions_snapshot_limit": {
"note_revisions_snapshot_limit_title": "Nota Limite di Snapshot di Revisione",
"note_revisions_snapshot_limit_description": "Il limite del numero di snapshot di revisione della nota si riferisce al numero massimo di revisioni che possono essere salvate per ciascuna nota. Dove -1 significa nessun limite, 0 significa eliminare tutte le revisioni. È possibile impostare il numero massimo di revisioni per una singola nota tramite l'etichetta #versioningLimit.",
"snapshot_number_limit_label": "Nota limite del numero di istantanee di revisione:",
"snapshot_number_limit_label": "Numero massimo di revisioni",
"snapshot_number_limit_unit": "istantanee",
"erase_excess_revision_snapshots": "Cancella subito gli snapshot di revisione in eccesso",
"erase_excess_revision_snapshots_prompt": "Gli snapshot di revisione in eccesso sono stati cancellati."
"erase_excess_revision_snapshots_prompt": "Gli snapshot di revisione in eccesso sono stati cancellati.",
"note_revisions_snapshot_limit_description_short": "Numero massimo di revisioni per nota. Impostare -1 per numero illimitato, 0 per disabilitare.",
"erase_excess_revision_snapshots_description": "Elimina le revisioni che superano il limite per tutte le note."
},
"text_auto_read_only_size": {
"title": "Dimensione automatica di sola lettura",
"description": "La dimensione automatica delle note in sola lettura è la dimensione dopo la quale le note verranno visualizzate in modalità di sola lettura (per motivi di prestazioni).",
"label": "Dimensione automatica di sola lettura (note di testo)",
"label": "Dimensione automatica in sola lettura",
"unit": "caratteri"
},
"custom_date_time_format": {
"title": "Formato data/ora personalizzato",
"title": "Formato data/ora",
"description": "Personalizza il formato della data e dell'ora inserite tramite <shortcut /> o la barra degli strumenti. Consulta la <doc>documentazione Day.js</doc> per i token di formato disponibili.",
"format_string": "Stringa di formato:",
"formatted_time": "Data/ora formattata:"
"format_string": "Stringa di formattazione",
"formatted_time": "Data/ora formattata",
"description_short": "Personalizza il formato della data e dell'ora inserite tramite la barra degli strumenti.",
"preview": "Anteprima: {{preview}}"
},
"options_widget": {
"options_status": "Stato delle opzioni",
@@ -1611,18 +1657,22 @@
"new_password": "Nuova password",
"new_password_confirmation": "Conferma della nuova password",
"change_password": "Cambiare la password",
"protected_session_timeout": "Timeout della sessione protetta",
"protected_session_timeout_description": "Il timeout della sessione protetta è un periodo di tempo dopo il quale la sessione protetta viene cancellata dalla memoria del browser. Questo periodo viene calcolato a partire dall'ultima interazione con le note protette. Vedi",
"protected_session_timeout": "Sessione protetta",
"protected_session_timeout_description": "Tempo di inattività prima che la sessione venga cancellata dalla memoria del browser. Vedi",
"wiki": "wiki",
"for_more_info": "per maggiori informazioni.",
"protected_session_timeout_label": "Timeout della sessione protetta:",
"protected_session_timeout_label": "Chiudi automaticamente la sessione dopo",
"reset_confirmation": "Reimpostando la password perderai per sempre l'accesso a tutte le tue note protette. Vuoi davvero reimpostare la password?",
"reset_success_message": "La password è stata resettata. Imposta una nuova password",
"change_password_heading": "Cambiare la password",
"set_password_heading": "Imposta password",
"set_password": "Imposta password",
"password_mismatch": "Le nuove password non sono tutte uguali.",
"password_changed_success": "La password è stata modificata. Trilium verrà ricaricato dopo aver premuto OK."
"password_changed_success": "La password è stata modificata. Trilium verrà ricaricato dopo aver premuto OK.",
"change_password_description": "Aggiorna la tua password attuale",
"reset_password": "Reimposta password",
"reset_password_description": "Perdere definitivamente l'accesso alle note protette",
"cancel": "Annulla"
},
"multi_factor_authentication": {
"title": "Autenticazione a più fattori",
@@ -1728,7 +1778,8 @@
"collections": "Collezioni",
"ai-chat": "Chat con IA",
"spreadsheet": "Foglio di calcolo",
"llm-chat": "Chat con IA"
"llm-chat": "Chat con IA",
"markdown": "Markdown"
},
"protect_note": {
"toggle-on": "Proteggi la nota",
@@ -1783,7 +1834,7 @@
"note_detail": {
"could_not_find_typewidget": "Impossibile trovare typeWidget per il tipo '{{type}}'",
"printing": "Stampa in corso...",
"printing_pdf": "Esportazione in PDF in corso...",
"printing_pdf": "Preparazione dell'anteprima di stampa...",
"print_report_title": "Stampa rapporto",
"print_report_collection_content_one": "{{count}} la note nella raccolta non può essere stampata perché non è supportata o è protetta.",
"print_report_collection_content_many": "{{count}} le note nella raccolta non possono essere stampate perché non sono supportate o sono protette.",
@@ -1843,7 +1894,9 @@
"entrypoints": {
"note-revision-created": "La revisione della nota è stata creata.",
"note-executed": "Nota eseguita.",
"sql-error": "Si è verificato un errore durante l'esecuzione della query SQL: {{message}}"
"sql-error": "Si è verificato un errore durante l'esecuzione della query SQL: {{message}}",
"save-named-revision-title": "Salva revisione con nome",
"save-named-revision-message": "Inserisci un nome per questa revisione (lascia vuoto se non vuoi assegnarne uno):"
},
"branches": {
"cannot-move-notes-here": "Impossibile spostare le note qui.",
@@ -1890,7 +1943,9 @@
"theme_group_light": "Temi chiari",
"theme_group_dark": "Temi scuri",
"copy_title": "Copia negli appunti",
"click_to_copy": "Clicca per copiare"
"click_to_copy": "Clicca per copiare",
"tab_width": "Larghezza della scheda",
"tab_width_unit": "spazi"
},
"classic_editor_toolbar": {
"title": "Formattazione"
@@ -1919,7 +1974,7 @@
"days": "Giorni"
},
"share": {
"title": "Impostazioni di condivisione",
"title": "Condividi",
"redirect_bare_domain": "Reindirizza il dominio nudo alla pagina di condivisione",
"redirect_bare_domain_description": "Reindirizza gli utenti anonimi alla pagina Condividi invece di mostrare l'accesso",
"show_login_link": "Mostra il collegamento di accesso nel tema Condividi",
@@ -2016,7 +2071,7 @@
"background_effects_title": "Gli effetti di sfondo sono ora stabili",
"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",
"dismiss": "Ignora",
"new_layout_title": "Nuovo layout",
"new_layout_message": "Abbiamo introdotto un layout modernizzato per Trilium. La barra multifunzione è stata rimossa e integrata perfettamente nell'interfaccia principale, con una nuova barra di stato e sezioni espandibili (come gli attributi promossi) che assumono le funzioni chiave.\n\nIl nuovo layout è abilitato di default e può essere temporaneamente disabilitato tramite Opzioni → Aspetto.",
"new_layout_button": "Maggiori informazioni"
@@ -2029,7 +2084,9 @@
"related_code_notes": "Schema di colori per le note del codice",
"ui": "Interfaccia utente",
"ui_old_layout": "Vecchio layout",
"ui_new_layout": "Nuovo layout"
"ui_new_layout": "Nuovo layout",
"ui_layout_style": "Stile di impaginazione",
"ui_layout_orientation": "Orientamento della barra di avvio"
},
"units": {
"percentage": "%"
@@ -2132,7 +2189,19 @@
"note_paths_many": "{{count}} percorsi",
"note_paths_other": "{{count}} percorsi",
"note_paths_title": "Nota percorsi",
"code_note_switcher": "Cambia modalità lingua"
"code_note_switcher": "Cambia modalità lingua",
"tab_width": "Larghezza della scheda: {{width}}",
"tab_width_title": "Modifica la larghezza della scheda",
"tab_width_spaces": "{{count}} spazi",
"tab_width_spaces_short": "Spazi: {{width}}",
"tab_width_tabs": "Schede: {{width}}",
"tab_width_use_default": "Usa le impostazioni predefinite ({{width}})",
"tab_width_use_default_style": "Usa lo stile predefinito ({{style}})",
"tab_width_display_header": "Larghezza dello schermo",
"tab_width_reindent_header": "Riorganizzare il contenuto in modo che",
"tab_width_style_header": "Rientrare utilizzando",
"tab_width_style_spaces": "Spazi",
"tab_width_style_tabs": "Schede"
},
"attributes_panel": {
"title": "Attributi delle note"
@@ -2387,6 +2456,117 @@
"autocomplete_fuzzy_description": "Influisce sui comandi \"Vai alla nota\" e sui selettori di note. È più lento, ma tollera gli errori di digitazione."
},
"revisions": {
"note_revisions": "Note sulle revisioni"
"note_revisions": "Note sulle revisioni",
"delete_all_revisions": "Elimina tutte le revisioni di questa nota",
"delete_all_button": "Elimina tutte le revisioni",
"help_title": "Guida alle revisioni delle note",
"confirm_delete_all": "Vuoi eliminare tutte le revisioni di questa nota?",
"no_revisions": "Non ci sono ancora revisioni per questa nota...",
"restore_button": "Ripristina",
"highlight_changes": "Evidenzia le modifiche",
"diff_on": "Mostra le differenze",
"diff_off": "Mostra contenuto",
"diff_on_hint": "Clicca qui per visualizzare le differenze nel testo della nota",
"diff_off_hint": "Clicca qui per visualizzare il contenuto della nota",
"diff_not_available": "La funzione \"Diff\" non è disponibile.",
"confirm_restore": "Vuoi ripristinare questa revisione? In questo modo il titolo e il contenuto attuali della nota verranno sovrascritti con quelli di questa revisione.",
"delete_button": "Eliminazione",
"confirm_delete": "Vuoi eliminare questa revisione?",
"revisions_deleted": "Le revisioni delle note sono state eliminate.",
"revision_restored": "La revisione della nota è stata ripristinata.",
"revision_deleted": "La revisione della nota è stata eliminata.",
"snapshot_interval": "Nota: intervallo di aggiornamento dell'istantanea: {{seconds}} s.",
"maximum_revisions": "Limite delle istantanee delle revisioni delle note: {{number}}.",
"save_revision_now": "Salva subito una revisione",
"save_named_revision": "Salva revisione con nome...",
"snapshot_header": "Istantanea della revisione",
"snapshot_interval_value": "Intervallo: {{seconds}}s",
"snapshot_limit_value": "Limite: {{number}}",
"settings": "Impostazioni di revisione delle note",
"menu_tooltip": "Opzioni di revisione",
"download_button": "Scarica",
"mime": "MIME: ",
"file_size": "Dimensione del file:",
"preview_not_available": "L'anteprima non è disponibile per questo tipo di nota.",
"save_revision": "Salva revisione",
"save_revision_tooltip": "Salvare manualmente un'istantanea della nota corrente",
"description_placeholder": "Assegna un nome a questa revisione",
"revision_saved": "La modifica alla nota è stata salvata.",
"edit_description": "Modifica nome",
"description_updated": "Il nome della revisione è stato aggiornato.",
"source_auto": "Salvataggio automatico",
"source_manual": "Salvataggio manuale",
"source_etapi": "ETAPI",
"source_llm": "LLM",
"source_restore": "Ripristina",
"source_unknown": "Snapshot",
"date_today": "Oggi",
"date_yesterday": "Ieri",
"date_this_week": "Questa settimana",
"date_this_month": "Questo mese",
"source_description_auto": "Salvato automaticamente dal sistema a intervalli regolari",
"source_description_manual": "Salvato manualmente dall'utente",
"source_description_etapi": "Creato tramite l'API esterna di Trilium",
"source_description_llm": "Creato dall'assistente AI",
"source_description_restore": "Salvato prima di ripristinare una revisione precedente",
"source_description_unknown": "Fonte non disponibile"
},
"auto_link_attribute_list": {
"title": "Attributi di sistema"
},
"database": {
"title": "Database"
},
"revisions_snapshot": {
"title": "Note sulle revisioni"
},
"text_editor": {
"title": "Redattore"
},
"launcher_button_context_menu": {
"remove_from_launch_bar": "Rimuovi dalla barra di avvio"
},
"link": {
"failed_to_open": "Impossibile aprire il link '{{- href}}': {{- message}}"
},
"display_mode": {
"source": "Visualizza sorgente",
"split": "Visualizzazione divisa",
"preview": "Anteprima"
},
"print_preview": {
"title": "Anteprima di stampa",
"close": "Chiudi",
"save": "Salva come PDF",
"print": "Stampa",
"export_pdf": "Esporta in PDF",
"system_print": "Stampa tramite la finestra di dialogo di sistema",
"destination": "Destinazione",
"destination_pdf": "Salva come PDF",
"destination_printers": "Stampanti",
"destination_default": "Predefinito",
"orientation": "Orientamento",
"portrait": "Verticale",
"landscape": "Orizzontale",
"page_size": "Dimensione della pagina",
"scale": "Scala",
"margins": "Margini",
"render_error": "Impossibile generare il PDF con le impostazioni attuali. Controlla i margini e la scala.",
"margins_default": "Predefinito",
"margins_none": "Nessuno",
"margins_minimum": "Minimo",
"margins_custom": "Personalizzato",
"margin_top": "Superiore",
"margin_right": "Destra",
"margin_bottom": "Inferiore",
"margin_left": "Sinistra",
"page_ranges": "Pagine",
"page_ranges_hint": "Lasciare vuoto per stampare tutte le pagine.",
"page_ranges_invalid": "Formato non valido. Utilizza ad esempio: 1-5, 8, 11-13.",
"page_ranges_placeholder": "ad es. 1-5, 8, 11-13"
},
"common": {
"save": "Salva",
"cancel": "Annulla"
}
}
+163 -71
View File
@@ -1,6 +1,23 @@
{
"about": {
"data_directory": "資料目錄:"
"data_directory": "資料目錄:",
"version_label": "版本:",
"version": "{{appVersion}}(資料庫:{{dbVersion}},同步協定:{{syncVersion}})",
"build_info": "建置日期:{{buildDate}},修訂版本:<buildRevision />",
"contributors_label": "貢獻者:",
"contributor_roles": {
"lead-dev": "首席開發者",
"original-dev": "原始開發者"
},
"role_brief_history": {
"lead-dev": "Elian Doran 於 2024 年創立了 TriliumNext,這是 Zadam 退出該專案後所建立的社群分支。後來,Zadam 將原始儲存庫移交給 TriliumNext 團隊,將兩個專案重新合併為一。",
"original-dev": "2017年12月25日,Zadam 發布了 Trilium(與花名不同,此處僅寫一個「l」)的首個測試版。由於對現有的筆記管理工具感到不滿,他開發了一個功能強大的自託管式分層知識庫,該專案在 GitHub 上獲得了超過 22,000 顆星。2024 年,隨著生活日益繁忙,他將該專案轉為維護模式。"
},
"contributor_full_list": "查看整個社群",
"github_tooltip": "在 GitHub 上回報錯誤、提出功能建議或貢獻程式碼",
"license_tooltip": "查看許可證",
"donate": "捐款",
"donate_tooltip": "捐款支持此專案"
},
"toast": {
"critical-error": {
@@ -422,7 +439,9 @@
"print_landscape": "匯出為 PDF 時,將頁面方向更改為橫向而非縱向。",
"print_page_size": "在匯出 PDF 時更改頁面大小。支援的值:<code>A0</code>、<code>A1</code>、<code>A2</code>、<code>A3</code>、<code>A4</code>、<code>A5</code>、<code>A6</code>、<code>Legal</code>、<code>Letter</code>、<code>Tabloid</code>、<code>Ledger</code>。",
"color_type": "顏色",
"textarea": "多行文字"
"textarea": "多行文字",
"print_scale": "匯出為 PDF 時,調整渲染內容的縮放比例。數值範圍為 0.1(10%)至 2(200%),預設值為 1(100%)。",
"print_margins": "在匯出為 PDF 時,設定頁面邊距。請使用 <code>default</code>、<code>none</code>、<code>minimum</code>,或自訂值,或以 <code>top,right,bottom,left</code> 的形式以毫米為單位指定。"
},
"attribute_editor": {
"help_text_body1": "要新增標籤,只需輸入例如 <code>#rock</code> 或者如果您還想新增值,則例如 <code>#year = 2020</code>",
@@ -682,7 +701,13 @@
"export_as_image_png": "PNG (點陣)",
"export_as_image_svg": "SVG (向量)",
"note_map": "筆記地圖",
"view_ocr_text": "顯示 OCR 文字"
"view_ocr_text": "顯示 OCR 文字",
"word_wrap": "自動換行",
"word_wrap_auto": "自動",
"word_wrap_auto_description": "遵循全域設定",
"word_wrap_on": "開啟",
"word_wrap_off": "關閉",
"save_named_revision": "儲存命名修訂…"
},
"onclick_button": {
"no_click_handler": "按鈕元件'{{componentId}}'沒有定義點擊時的處理方式"
@@ -1051,15 +1076,17 @@
"title": "檢查一致性",
"find_and_fix_button": "尋找並修復一致性問題",
"finding_and_fixing_message": "正在尋找並修復一致性問題…",
"issues_fixed_message": "一致性問題應該已被修復。"
"issues_fixed_message": "一致性問題應該已被修復。",
"find_and_fix_label": "尋找並修復一致性問題",
"find_and_fix_description": "掃描並自動修復資料庫中的任何資料一致性問題。"
},
"database_anonymization": {
"title": "資料庫匿名化",
"full_anonymization": "完全匿名化",
"full_anonymization_description": "此操作將新增一個新的資料庫副本並進行匿名化處理(刪除所有筆記內容,僅保留結構和一些非敏感元數據),用來分享至網上做除錯而無需擔心洩漏您的個人資料。",
"full_anonymization_description": "建立資料庫的副本,並移除所有筆記內容,僅保留結構與非敏感的元資料。在除錯時,此副本可安全地用於線上分享。",
"save_fully_anonymized_database": "儲存完全匿名化的資料庫",
"light_anonymization": "輕度匿名化",
"light_anonymization_description": "此操作將新增一個新的資料庫副本,並對其進行輕度匿名化處理——僅刪除所有筆記的內容,但保留標題和屬性。此外,自訂 JS 前端 / 後端腳本筆記和自訂元件將保留。這提供了更多上下文以除錯問題。",
"light_anonymization_description": "建立一份已移除筆記內容的副本,但標題、屬性以及自訂腳本/小工具仍會保留。此舉可為除錯提供更多背景資訊。",
"choose_anonymization": "您可以自行決定是提供完全匿名化還是輕度匿名化的資料庫。即使是完全匿名化的資料庫也非常有用,但在某些情況下,輕度匿名化的資料庫可以加快錯誤識別和修復的過程。",
"save_lightly_anonymized_database": "儲存輕度匿名化的資料庫",
"existing_anonymized_databases": "現有的匿名化資料庫",
@@ -1068,14 +1095,17 @@
"error_creating_anonymized_database": "無法新增匿名化資料庫,請檢查後端日誌以取得詳細資訊",
"successfully_created_fully_anonymized_database": "成功新增完全匿名化的資料庫,路徑為 {{anonymizedFilePath}}",
"successfully_created_lightly_anonymized_database": "成功新增輕度匿名化的資料庫,路徑為 {{anonymizedFilePath}}",
"no_anonymized_database_yet": "尚無匿名化資料庫。"
"no_anonymized_database_yet": "尚無匿名化資料庫。",
"description": "建立資料庫的匿名化副本,以便在除錯時與開發人員分享,同時避免洩露個人資料。"
},
"database_integrity_check": {
"title": "資料庫完整性檢查",
"check_button": "檢查資料庫完整性",
"checking_integrity": "正在檢查資料庫完整性…",
"integrity_check_succeeded": "完整性檢查成功——未發現問題。",
"integrity_check_failed": "完整性檢查失敗: {{results}}"
"integrity_check_failed": "完整性檢查失敗: {{results}}",
"check_integrity_label": "檢查資料庫完整性",
"check_integrity_description": "請確認資料庫在 SQLite 層級上是否已損毀。"
},
"sync": {
"title": "同步",
@@ -1085,25 +1115,31 @@
"filling_entity_changes": "正在填充實體變更列…",
"sync_rows_filled_successfully": "同步列填充成功",
"finished-successfully": "已完成同步。",
"failed": "同步失敗:{{message}}"
"failed": "同步失敗:{{message}}",
"force_full_sync_label": "強制全量同步",
"force_full_sync_description": "觸發與同步伺服器的完整同步,重新上傳所有變更。",
"fill_entity_changes_label": "填入實體變更",
"fill_entity_changes_description": "重建實體變更記錄。若同步過程遺漏了某些變更,請使用此功能。"
},
"vacuum_database": {
"title": "清理資料庫",
"description": "這會重建資料庫,通常會減少佔用空間,不會刪除數據。",
"button_text": "清理資料庫",
"vacuuming_database": "正在清理資料庫…",
"database_vacuumed": "已清理資料庫"
"database_vacuumed": "已清理資料庫",
"vacuum_label": "清理資料庫",
"vacuum_description": "重建資料庫以縮小檔案大小。資料內容不會有任何變更。"
},
"fonts": {
"theme_defined": "跟隨主題",
"fonts": "字型",
"main_font": "主字型",
"main_font": "界面字型",
"font_family": "字型",
"size": "大小",
"note_tree_font": "筆記樹字型",
"note_detail_font": "筆記內容字型",
"monospace_font": "等寬(程式碼)字型",
"not_all_fonts_available": "並非所有列出的字型都在您的系統上可用。",
"note_detail_font": "文件字型",
"monospace_font": "等寬字型",
"not_all_fonts_available": "並非所有列出的字型都在您的系統上可用",
"generic-fonts": "通用字型",
"sans-serif-system-fonts": "無襯線系統字型",
"serif-system-fonts": "襯線系統字型",
@@ -1112,7 +1148,12 @@
"serif": "襯線",
"sans-serif": "無襯線",
"monospace": "等寬",
"system-default": "系統預設"
"system-default": "系統預設",
"custom_fonts": "使用自訂字型",
"preview": "預覽",
"monospace_font_description": "用於程式碼註解和程式碼區塊",
"size_relative_to_general": "字體大小取決於整體字體大小",
"apply_changes": "重新載入以套用變更"
},
"max_content_width": {
"title": "內容寬度",
@@ -1132,20 +1173,23 @@
"edited_notes_message": "日記筆記中自動打開編輯過的筆記功能區分頁"
},
"theme": {
"title": "主題",
"theme_label": "主題",
"title": "使用者介面",
"theme_label": "應用程式主題",
"override_theme_fonts_label": "更改主題字型",
"layout": "佈局",
"layout-vertical-title": "垂直",
"layout-horizontal-title": "水平",
"layout-vertical-description": "啟動欄位於左側(預設)",
"layout-horizontal-description": "啟動欄位於分頁欄下方,分頁欄現在是全寬的。",
"auto_theme": "傳統(遵循系統配色方案)",
"light_theme": "傳統(淺色)",
"dark_theme": "傳統(深色)",
"triliumnext": "Trilium(遵循系統配色方案)",
"triliumnext-light": "Trilium(淺色)",
"triliumnext-dark": "Trilium(深色)"
"auto_theme": "遵循系統配色方案",
"light_theme": "淺色",
"dark_theme": "深色",
"triliumnext": "遵循系統配色方案",
"triliumnext-light": "淺色",
"triliumnext-dark": "深色",
"modern_themes": "現代",
"legacy_themes": "傳統",
"custom_themes": "自訂"
},
"zoom_factor": {
"title": "縮放係數(僅桌面版有效)",
@@ -1154,7 +1198,7 @@
"code_auto_read_only_size": {
"title": "自動唯讀大小",
"description": "自動唯讀大小是指筆記超過設定的大小後自動設定為唯讀模式(為性能考慮)。",
"label": "自動唯讀大小(程式碼筆記)",
"label": "自動唯讀大小",
"unit": "字元"
},
"code_mime_types": {
@@ -1197,54 +1241,64 @@
"batch_ocr_error": "批次處理期間發生錯誤:{{error}}"
},
"attachment_erasure_timeout": {
"attachment_erasure_timeout": "附件清理超時",
"manual_erasing_description": "您還可以手動觸發清理(而不考慮上述定義的超時時間):",
"erase_unused_attachments_now": "立即清理未使用的附件筆記",
"attachment_erasure_timeout": "未使用的附件",
"manual_erasing_description": "手動觸發清理,不考慮超時。",
"erase_unused_attachments_now": "立即清理未使用的附件",
"unused_attachments_erased": "已刪除未使用的附件。",
"erase_attachments_after": "在此時間後刪除未使用的附件:"
"erase_attachments_after": "在此時間後刪除未使用的附件",
"description": "任何不再被任何筆記引用的附件,將被視為未使用,並可在一段時間後自動刪除。",
"erase_attachments_after_description": "未使用的附件在永久刪除前的保留時間。"
},
"network_connections": {
"network_connections_title": "網絡連接",
"check_for_updates": "自動檢查更新"
"network_connections_title": "網絡",
"check_for_updates": "自動檢查更新",
"check_for_updates_description": "於 GitHub 檢查是否有新版本,並在有新版本時於全域選單中顯示通知。"
},
"note_erasure_timeout": {
"note_erasure_timeout_title": "筆記清理超時",
"manual_erasing_description": "您還可以手動觸發清理(而不考慮上述定義的超時時間):",
"note_erasure_timeout_title": "已刪除的筆記",
"manual_erasing_description": "手動觸發清理,不考慮超時。",
"erase_deleted_notes_now": "立即清理已刪除的筆記",
"deleted_notes_erased": "已清理已刪除的筆記。",
"erase_notes_after": "在此時間後刪除筆記:"
"erase_notes_after": "在此時間後刪除筆記",
"description": "刪除的筆記最初僅標記為已刪除,可從「最近的筆記」中還原。經過一段時間後,這些筆記將會被永久刪除。",
"erase_notes_after_description": "被刪除的筆記在永久清除前保留的時間。"
},
"revisions_snapshot_limit": {
"note_revisions_snapshot_limit_title": "筆記歷史快照限制",
"note_revisions_snapshot_limit_description": "筆記歷史快照數限制指的是每個筆記可以儲存的最大歷史記錄數量。其中 -1 表示沒有限制,0 表示刪除所有歷史記錄。您可以透過 #versioningLimit 標籤設定單個筆記的最大歷史記錄數量。",
"snapshot_number_limit_label": "筆記歷史快照數量限制:",
"snapshot_number_limit_label": "快照數量限制",
"erase_excess_revision_snapshots": "立即刪除多餘的歷史快照",
"erase_excess_revision_snapshots_prompt": "多餘的歷史快照已被刪除。",
"snapshot_number_limit_unit": "快照"
"snapshot_number_limit_unit": "快照",
"note_revisions_snapshot_limit_description_short": "每則筆記的最大修訂次數。輸入 -1 表示無限制,輸入 0 則停用此功能。",
"erase_excess_revision_snapshots_description": "刪除所有筆記中超過數量限制的修訂版本。"
},
"search_engine": {
"title": "搜尋引擎",
"custom_search_engine_info": "自訂搜尋引擎需要設定名稱和 URL。如果這兩者之一未設定,將預設使用 DuckDuckGo 作為搜尋引擎。",
"predefined_templates_label": "預定義搜尋引擎模板",
"custom_search_engine_info": "用於在網路上搜尋選取的文字。若未設定,將使用 DuckDuckGo。",
"predefined_templates_label": "模板",
"bing": "Bing",
"baidu": "百度",
"duckduckgo": "DuckDuckGo",
"google": "Google",
"custom_name_label": "自訂搜尋引擎名稱",
"custom_name_placeholder": "自訂搜尋引擎名稱",
"custom_url_label": "自訂搜尋引擎 URL 應包含 {keyword} 作為搜尋詞的佔位符。",
"custom_url_placeholder": "自訂搜尋引擎 URL",
"save_button": "儲存"
"custom_name_label": "名稱",
"custom_name_placeholder": "搜尋引擎名稱",
"custom_url_label": "網址",
"custom_url_placeholder": "搜尋引擎 URL",
"save_button": "儲存",
"custom_url_description": "使用 {keyword} 作為搜尋詞的佔位符。"
},
"tray": {
"title": "系統匣",
"enable_tray": "啟用系統匣圖示(需要重啟生效)"
"enable_tray": "系統匣圖示",
"enable_tray_description": "需重新啟動 Trilium 才能使此變更生效。"
},
"heading_style": {
"title": "標題風格",
"plain": "純文字",
"underline": "下劃線",
"markdown": "Markdown 風格"
"markdown": "Markdown 風格",
"description": "文字筆記中標題的視覺樣式。"
},
"highlights_list": {
"title": "高亮列表",
@@ -1268,7 +1322,7 @@
"text_auto_read_only_size": {
"title": "自動唯讀大小",
"description": "自動唯讀筆記大小是超過該大小後,筆記將以唯讀模式顯示(出於性能考慮)。",
"label": "自動唯讀大小(文字筆記)",
"label": "自動唯讀大小",
"unit": "字元"
},
"i18n": {
@@ -1279,10 +1333,10 @@
"monday": "星期一",
"first-week-of-the-year": "年度第一週",
"first-week-contains-first-day": "年度第一週包含第一天",
"first-week-contains-first-thursday": "年度第一週包含第一個週四",
"first-week-contains-first-thursday": "第一週包含第一個週四 (ISO 8601)",
"first-week-has-minimum-days": "第一週有最少天數",
"min-days-in-first-week": "第一週的最少天數",
"first-week-warning": "變更第一週選項可能導致與現有的「週筆記」重複,現有的「週筆記」將不會相應更新。",
"first-week-warning": "變更此選項可能導致與現有的「週筆記」重複。",
"formatting-locale": "日期和數字格式",
"tuesday": "週二",
"wednesday": "週三",
@@ -1294,9 +1348,9 @@
"backup": {
"automatic_backup": "自動備份",
"automatic_backup_description": "Trilium 可以自動備份資料庫:",
"enable_daily_backup": "啟用每日備份",
"enable_weekly_backup": "啟用每週備份",
"enable_monthly_backup": "啟用每月備份",
"enable_daily_backup": "每日備份",
"enable_weekly_backup": "每週備份",
"enable_monthly_backup": "每月備份",
"backup_recommendation": "建議打開備份功能,但這可能會使有大型資料庫和 / 或慢速儲存設備的應用程式啟動變慢。",
"backup_now": "立即備份",
"backup_database_now": "立即備份資料庫",
@@ -1305,7 +1359,8 @@
"path": "路徑",
"database_backed_up_to": "資料庫已備份至 {{backupFilePath}}",
"no_backup_yet": "尚無備份",
"download": "下載"
"download": "下載",
"title": "備份"
},
"etapi": {
"title": "ETAPI",
@@ -1340,8 +1395,8 @@
"new_password": "新密碼",
"new_password_confirmation": "新密碼確認",
"change_password": "更改密碼",
"protected_session_timeout": "受保護的作業階段超時",
"protected_session_timeout_description": "受保護的作業階段超時是一個時間段,超時後受保護的作業階段會從瀏覽器記憶體中清除。這是從最後一次存取受保護的筆記時開始計時的。更多資訊請見",
"protected_session_timeout": "受保護的作業階段",
"protected_session_timeout_description": "瀏覽器記憶體中的會話在多長時間未活動後會被清除。更多資訊請見",
"wiki": "維基",
"for_more_info": "更多資訊。",
"reset_confirmation": "重設密碼後將不再能查看所有目前受保護的筆記。您真的要重設密碼嗎?",
@@ -1351,7 +1406,11 @@
"set_password": "設定密碼",
"password_mismatch": "新密碼不一致。",
"password_changed_success": "密碼已更改。按 OK 後 Trilium 將重新載入。",
"protected_session_timeout_label": "受保護的作業階段超時:"
"protected_session_timeout_label": "自動結束作業階段時間",
"change_password_description": "更新您目前的密碼",
"reset_password": "重設密碼",
"reset_password_description": "將永久無法存取受保護的筆記",
"cancel": "取消"
},
"shortcuts": {
"keyboard_shortcuts": "快捷鍵",
@@ -1381,17 +1440,19 @@
"related_description": "設定拼寫檢查語言及自訂字典。"
},
"sync_2": {
"config_title": "同步設定",
"config_title": "同步伺服器",
"server_address": "伺服器地址",
"timeout": "同步超時(單位:毫秒)",
"proxy_label": "同步代理伺服器(可選)",
"timeout": "連線超時",
"proxy_label": "代理伺服器",
"save": "儲存",
"help": "說明",
"test_title": "同步測試",
"test_title": "測試連線",
"test_description": "測試和同步伺服器之間的連接。如果同步伺服器沒有初始化,這會將本地文件同步至同步伺服器上。",
"test_button": "測試同步",
"handshake_failed": "同步伺服器握手失敗,錯誤:{{message}}",
"timeout_description": "在放棄慢速同步連線前應等待多久。若網路不穩定,請延長等待時間。"
"timeout_description": "在放棄慢速連線前應等待多久。",
"server_address_description": "要同步的 Trilium 伺服器網址。",
"proxy_description": "若要使用系統代理伺服器,請留空(僅限桌面版)。若要繞過所有代理伺服器,請輸入「noproxy」。"
},
"api_log": {
"close": "關閉"
@@ -1645,7 +1706,8 @@
"entrypoints": {
"note-revision-created": "已新增筆記修訂。",
"note-executed": "已執行筆記。",
"sql-error": "執行 SQL 查詢時發生錯誤:{{message}}"
"sql-error": "執行 SQL 查詢時發生錯誤:{{message}}",
"save-named-revision-title": "儲存命名修訂"
},
"branches": {
"cannot-move-notes-here": "無法將筆記移動至這裡。",
@@ -1739,18 +1801,23 @@
"button_exit": "退出禪模式"
},
"code-editor-options": {
"title": "編輯器"
"title": "編輯器",
"tab_width": "制表符寬度",
"tab_width_unit": "空格"
},
"revisions_snapshot_interval": {
"note_revisions_snapshot_interval_title": "筆記歷史快照間隔",
"note_revisions_snapshot_description": "筆記歷史快照間隔是建立新筆記修訂的時間。如需詳細資訊,請參閱 <doc>wiki</doc>。",
"snapshot_time_interval_label": "筆記歷史快照時間間隔:"
"snapshot_time_interval_label": "快照時間間隔",
"note_revisions_snapshot_description_short": "經過這段時間後,系統將建立新的筆記修訂版本。"
},
"custom_date_time_format": {
"title": "自訂日期 / 時間格式",
"title": "日期 / 時間格式",
"description": "透過 <shortcut /> 或工具列自訂插入日期和時間的格式。有關可用的格式及符號,請參閱 <doc>Day.js docs</doc>。",
"format_string": "格式化字串:",
"formatted_time": "格式化日期 / 時間:"
"format_string": "格式化字串",
"formatted_time": "格式化日期 / 時間",
"description_short": "透過工具列自訂插入的日期與時間格式。",
"preview": "預覽:{{preview}}"
},
"multi_factor_authentication": {
"title": "多重身份驗證(MFA)",
@@ -1999,11 +2066,11 @@
},
"ui-performance": {
"title": "效能",
"enable-motion": "啟用轉場與動畫",
"enable-shadows": "啟用陰影",
"enable-backdrop-effects": "啟用選單、彈出視窗和面板的背景特效",
"enable-smooth-scroll": "啟用平滑滾動",
"app-restart-required": "(需要重啟程式以套用更改)"
"enable-motion": "轉場與動畫",
"enable-shadows": "陰影",
"enable-backdrop-effects": "選單、彈出視窗和面板的背景特效",
"enable-smooth-scroll": "平滑滾動",
"app-restart-required": "需要重啟程式"
},
"pagination": {
"total_notes": "{{count}} 筆記",
@@ -2388,6 +2455,31 @@
"date_today": "今天",
"date_yesterday": "昨天",
"date_this_week": "這週",
"date_this_month": "這個月"
"date_this_month": "這個月",
"source_description_auto": "由系統定期自動儲存",
"source_description_manual": "由使用者手動儲存",
"source_description_etapi": "透過外部 Trilium API 建立",
"source_description_llm": "由 AI 助理所創建",
"source_description_restore": "在還原至先前版本之前已儲存",
"source_description_unknown": "來源不可用"
},
"auto_link_attribute_list": {
"title": "系統屬性"
},
"database": {
"title": "資料庫"
},
"revisions_snapshot": {
"title": "筆記歷史版本"
},
"search": {
"title": "搜尋",
"fuzzy_matching_label": "搜尋中的拼寫容錯",
"fuzzy_matching_description": "適用於快速搜尋和完整搜尋。當精確匹配結果不足時,會搜尋相似的詞彙。",
"autocomplete_fuzzy_label": "自動完成功能中的拼寫容錯",
"autocomplete_fuzzy_description": "影響「跳轉至筆記」及「選取筆記」的功能。速度較慢,但能容忍輸入錯誤。"
},
"text_editor": {
"title": "編輯器"
}
}
@@ -409,7 +409,7 @@
"calendar_root": "بۇ بەلگە كۈندىلىك خاتىرىلەرنىڭ يىلتىزىنى بەلگىلەيدۇ. پەقەت بىرلا خاتىرىگە مۇشۇ بەلگىنى قويۇش كېرەك.",
"archived": "مۇشۇ بەلگە قويۇلغان خاتىرىلەر ئىزدەش نەتىجىسىدە (شۇنداقلا «تېز ئۆتۈش»، «ئۇلانما قوشۇش» كۆزنىكى قاتارلىقلاردا) كۆرۈنمەيدۇ.",
"exclude_from_export": "خاتىرىلەر (ۋە ئۇنىڭ تارماق دەرەخلىرى) ھېچقانداق ئېكسپورت قىلىش مەشغۇلاتىغا كىرگۈزۈلمەيدۇ",
"run": "قوليازما script قايسى ھادىسىلەردە ئىجرا قىلىنىدىغانلىقىنى بەلگىلەيدۇ. تۆۋەندىكى قىممەتلەرنى تاللاشقا بولىدۇ:\n\n<ul>\n<li><strong>frontendStartup</strong> - Trilium ئالدى يۈزى قوزغالغاندا (ياكى يېڭىلانغاندا) ئىجرا قىلىنىدۇ، لېكىن كۆچمە تېلېفوننى ئۆز ئىچىگە ئالمايدۇ.</li>\n<li><strong>mobileStartup</strong> - Trilium ئالدى يۈزى كۆچمە تېلېفوندا قوزغالغاندا (ياكى يېڭىلانغاندا) ئىجرا قىلىنىدۇ.</li>\n<li><strong>backendStartup</strong> - Trilium ئارقا سۇپىسى قوزغالغاندا ئىجرا قىلىنىدۇ.</li>\n<li><strong>hourly</strong> - ھەر سائەتتە بىر قېتىم ئىجرا قىلىنىدۇ. سىز <code>runAtHour</code> قوشۇمچە بەلگىسىنى ئىشلىتىپ، كونكرېت قايسى سائەتتە ئىجرا قىلىنىدىغانلىقىنى بەلگىلىسىڭىز بولىدۇ.</li>\n<li><strong>daily</strong> - ھەر كۈنى بىر قېتىم ئىجرا قىلىنىدۇ.</li>\n</ul>",
"run": "قوليازما script قايسى ھادىسىلەردە ئىجرا قىلىنىدىغانلىقىنى بەلگىلەيدۇ. تۆۋەندىكى قىممەتلەرنى تاللاشقا بولىدۇ:\n<ul>\n<li><strong>frontendStartup</strong> - Trilium ئالدى يۈزى قوزغالغاندا (ياكى يېڭىلانغاندا) ئىجرا قىلىنىدۇ، لېكىن كۆچمە تېلېفوننى ئۆز ئىچىگە ئالمايدۇ.</li>\n<li><strong>mobileStartup</strong> - Trilium ئالدى يۈزى كۆچمە تېلېفوندا قوزغالغاندا (ياكى يېڭىلانغاندا) ئىجرا قىلىنىدۇ.</li>\n<li><strong>backendStartup</strong> - Trilium ئارقا سۇپىسى قوزغالغاندا ئىجرا قىلىنىدۇ.</li>\n<li><strong>hourly</strong> - ھەر سائەتتە بىر قېتىم ئىجرا قىلىنىدۇ. سىز <code>runAtHour</code> قوشۇمچە بەلگىسىنى ئىشلىتىپ، كونكرېت قايسى سائەتتە ئىجرا قىلىنىدىغانلىقىنى بەلگىلىسىڭىز بولىدۇ.</li>\n<li><strong>daily</strong> - ھەر كۈنى بىر قېتىم ئىجرا قىلىنىدۇ.</li>\n</ul>",
"run_on_instance": "قايسى Trilium نۇسخىسىدا (instance) ئىجرا قىلىنىدىغانلىقىنى بەلگىلەيدۇ. ئەسلىدىكى تەڭشەكتە بارلىق نۇسخىلاردا ئىجرا قىلىنىدۇ.",
"run_at_hour": "قايسى سائەتتە ئىجرا قىلىنىدىغانلىقىنى بەلگىلەيدۇ. بۇنى <code>#run=hourly</code> بىلەن بىرلىكتە ئىشلىتىش كېرەك. بىر كۈندە كۆپ قېتىم ئىجرا قىلىش ئۈچۈن، بۇ بەلگىنى بىر نەچچە قېتىم تەكرار بەلگىلەشكە بولىدۇ.",
"disable_inclusion": "بۇ بەلگە قويۇلغان قوليازمىلار ئاساسىي قوليازما (parent script) ئىجرا قىلىنغاندا ئۇنىڭغا كىرگۈزۈلمەيدۇ.",
@@ -882,6 +882,288 @@
"note_map": {
"open_full": "تولۇق يېيىش",
"collapse": "نورمال چوڭلۇقتا يىغىش",
"title": "خاتىرە خەرىتىسى"
"title": "خاتىرە خەرىتىسى",
"fix-nodes": "خاتىرە نۇقتىلىرى",
"link-distance": "ئۇلانما ئارىلىقى",
"too-many-notes": "بۇ تارماق دەرەخ {{count}} خاتىرىنى ئۆز ئىچىگە ئالغان بولۇپ، خاتىرە خەرىتىسىدە كۆرسىتىش چەكلىمىسى {{max}} دىن ئېشىپ كەتتى."
},
"note_paths": {
"title": "خاتىرە يوللىرى",
"clone_button": "خاتىرىنى يېڭى ئورۇنغا كۆپەيتىش...",
"intro_placed": "بۇ خاتىرە تۆۋەندىكى يوللارغا قويۇلغان:",
"intro_not_placed": "بۇ خاتىرە تېخى خاتىرە دەرىخىگە قويۇلمىغان.",
"outside_hoisted": "بۇ يول يۇقىرى كۆتۈرۈلگەن خاتىرە دائىرىسىنىڭ سىرتىدا، كۆرۈنۈشنى ئەسلىگە كەلتۈرۈشىڭىزگە توغرا كېلىدۇ.",
"archived": "ئارخىپلاندى",
"search": "ئىزدەش"
},
"note_properties": {
"this_note_was_originally_taken_from": "بۇ خاتىرىنىڭ ئەسلى مەنبەسى:",
"info": "ئۇچۇر"
},
"owned_attribute_list": {
"owned_attributes": "ئۆزىگە تەۋە خاسلىقلار"
},
"promoted_attributes": {
"promoted_attributes": "ئۆستۈرۈلگەن خاسلىقلار",
"unset-field-placeholder": "بېكىتىلمىگەن",
"open_external_link": "سىرتقى ئۇلانمىنى ئېچىش",
"unknown_label_type": "نامەلۇم بەلگە تىپى '{{type}}'",
"unknown_attribute_type": "نامەلۇم خاسلىق تىپى '{{type}}'",
"add_new_attribute": "يېڭى خاسلىق قوشۇش",
"remove_this_attribute": "خاسلىقنى ئۆچۈرۈش",
"remove_color": "رەڭ بەلگىسىنى ئۆچۈرۈش"
},
"script_executor": {
"query": "سۈرۈشتۈرۈش",
"script": "Script",
"execute_query": "سۈرۈشتۈرۈشنى ئىجرا قىلىش",
"execute_script": "Scriptنى ئىجرا قىلىش"
},
"search_definition": {
"add_search_option": "ئىزدەش تاللانمىسى قوشۇش:",
"search_string": "ئىزدەش تېكىستى",
"search_script": "ئىزدەش scriptتى",
"fast_search": "تېز ئىزدەش",
"fast_search_description": "تېز ئىزدەش تاللانمىسى پۈتۈن تېكىستلىق ئىزدەشنى چەكلەيدۇ، بۇ چوڭ تىپتىكى ساندانلاردا ئىزدەش سۈرئىتىنى ئاشۇرۇشى مۇمكىن.",
"include_archived": "ئارخىپلارنى ئۆز ئىچىگە ئېلىش",
"include_archived_notes_description": "ئارخىپلانغان خاتىرىلەر سۈكۈتتىكى ھالەتتە ئىزدەش نەتىجىسىدىن چىقىرىۋېتىلىدۇ، بۇ تاللانما ئارقىلىق ئۇلارنى نەتىجىگە قوشقىلى بولىدۇ.",
"order_by": "تەرتىپلەش ئۆلچىمى",
"limit": "چەكلەش",
"limit_description": "نەتىجە سانىنى چەكلەش",
"action": "مەشغۇلات",
"option": "تاللانما",
"search_button": "ئىزدەش",
"search_execute": "ئىزدەش ۋە مەشغۇلاتلارنى ئىجرا قىلىش",
"save_to_note": "خاتىرىگە ساقلاش",
"search_parameters": "ئىزدەش شەرتلىرى",
"unknown_search_option": "نامەلۇم ئىزدەش تاللانمىسى {{searchOptionName}}",
"search_note_saved": "ئىزدەش خاتىرىسى {{- notePathTitle}} غا ساقلاندى",
"actions_executed": "مەشغۇلاتلار ئىجرا قىلىندى.",
"view_options": "كۆرۈنۈش تاللانمىلىرى:"
},
"similar_notes": {
"title": "ئوخشاش خاتىرىلەر",
"no_similar_notes_found": "ئوخشاش خاتىرىلەر تېپىلمىدى."
},
"abstract_search_option": {
"remove_this_search_option": "بۇ ئىزدەش تاللانمىسىنى چىقىرىۋېتىش"
},
"ancestor": {
"placeholder": "خاتىرىنى نامى بويىچە ئىزدەش",
"depth_label": "چوڭقۇرلۇق",
"depth_doesnt_matter": "خالىغان",
"depth_eq": "دەل {{count}}",
"direct_children": "بىۋاسىتە تارماق خاتىرىلەر",
"depth_gt": "{{count}} دىن كۆپ",
"depth_lt": "{{count}} دىن ئاز"
},
"fast_search": {
"fast_search": "تېز ئىزدەش",
"description": "تېز ئىزدەش تاللانمىسى پۈتۈن تېكىستلىق ئىزدەشنى چەكلەيدۇ، بۇ چوڭ تىپتىكى ساندانلاردا ئىزدەش سۈرئىتىنى ئاشۇرۇشى مۇمكىن."
},
"include_archived_notes": {
"include_archived_notes": "ئارخىپلانغان خاتىرىلەرنى قوشۇش"
},
"limit": {
"limit": "چەكلەش",
"take_first_x_results": "پەقەت ئالدىنقى X نەتىجىنىلا ئېلىش."
},
"order_by": {
"order_by": "تەرتىپلەش ئۆلچىمى",
"title": "تىمىسى",
"date_created": "قۇرۇلغان ۋاقتى",
"date_modified": "ئاخىرقى قېتىم ئۆزگەرتىلگەن ۋاقتى",
"content_size": "خاتىرە مەزمۇن چوڭلۇقى",
"content_and_attachments_size": "قوشۇمچە ھۆججەتلەرنى ئۆز ئىچىگە ئالغان خاتىرە مەزمۇن چوڭلۇقى",
"content_and_attachments_and_revisions_size": "قوشۇمچە ھۆججەت ۋە تۈزىتىش نۇسخىلىرىنى ئۆز ئىچىگە ئالغان خاتىرە مەزمۇن چوڭلۇقى",
"revision_count": "تۈزىتىش نۇسخىلىرىنىڭ سانى",
"children_count": "تارماق خاتىرىلەر سانى",
"parent_count": "كۆپەيتىلگەن نۇسخىلار سانى",
"owned_label_count": "بەلگە سانى",
"owned_relation_count": "مۇناسىۋەت سانى",
"target_relation_count": "مۇشۇ خاتىرىنى نىشان قىلغان مۇناسىۋەت سانى",
"random": "تەرتىپسىز",
"asc": "ئۆرلەيدىغان",
"desc": "تۆۋەنلەيدىغان"
},
"search_script": {
"title": "ئىزدەش scriptتى:",
"placeholder": "خاتىرىنى نامى بويىچە ئىزدەش",
"example_title": "بۇ مىسالغا قاراڭ:",
"note": "دىققەت: ئىزدەش scriptتى بىلەن ئىزدەش تېكىستىنى بىرگە ئىشلىتىشكە بولمايدۇ."
},
"search_string": {
"title_column": "ئىزدەش تېكىستى:",
"placeholder": "پۈتۈن تېكىستلىق ئاچقۇچلۇق سۆزلەر، #بەلگە = قىممەت...",
"search_syntax": "ئىزدەش گرامماتىكىسى",
"also_see": "يەنە كۆرۈڭ",
"complete_help": "ئىزدەش گرامماتىكىسى ھەققىدە تولۇق ياردەم",
"full_text_search": "پۈتۈن تېكىستلىق ئىزدەش ئۈچۈن خالىغان تېكىستنى كىرگۈزۈڭ",
"label_abc": "abc بەلگىسى بار خاتىرىلەرنى قايتۇرىدۇ",
"label_year": "«year» بەلگىسىنىڭ قىممىتى 2019 بولغان خاتىرىلەرگە ماس كېلىدۇ",
"label_rock_pop": "بىرلا ۋاقىتتا «rock» ھەم «pop» بەلگىسى بار خاتىرىلەرگە ماس كېلىدۇ",
"label_rock_or_pop": "بەلگىلەرنىڭ پەقەت بىرىلا بولۇشى شەرت",
"label_year_comparison": "سانلىق قىممەت سېلىشتۇرۇش (يەنە >، >=، < قاتارلىقلار).",
"label_date_created": "ئۆتكەن بىر ئاي ئىچىدە قۇرۇلغان خاتىرىلەر",
"error": "ئىزدەش خاتالىقى: {{error}}",
"search_prefix": "ئىزدەش:"
},
"attachment_detail": {
"open_help_page": "قوشۇمچە ھۆججەت ياردەم بېتىنى ئېچىش",
"owning_note": "تەۋە خاتىرە: ",
"you_can_also_open": "، سىز يەنە ... نى ئاچسىڭىزمۇ بولىدۇ. ",
"list_of_all_attachments": "بارلىق قوشۇمچە ھۆججەتلەر تىزىملىكى",
"attachment_deleted": "بۇ قوشۇمچە ھۆججەت ئۆچۈرۈۋېتىلدى."
},
"attachment_list": {
"open_help_page": "قوشۇمچە ھۆججەت ياردەم بېتىنى ئېچىش",
"owning_note": "تەۋە خاتىرە: ",
"upload_attachments": "قوشۇمچە ھۆججەتلەرنى يۈكلەش",
"no_attachments": "بۇ خاتىرىنىڭ قوشۇمچە ھۆججىتى يوق."
},
"book": {
"no_children_help": "بۇ توپلامنىڭ تارماق خاتىرىسى يوق، شۇڭا كۆرسىتىدىغان مەزمۇن يوق.",
"drag_locked_title": "تەھرىرلەش چەكلەنگەن",
"drag_locked_message": "توپلام تەھرىرلەش قۇلۇپلانغان، شۇڭا سۆرىگىلى بولمايدۇ."
},
"editable_code": {
"placeholder": "كود خاتىرىسىنىڭ مەزمۇنىنى بۇ يەرگە يېزىڭ..."
},
"editable_text": {
"placeholder": "خاتىرىڭىزنىڭ مەزمۇنىنى بۇ يەرگە يېزىڭ...",
"editor_crashed_content": "مەزمۇنىڭىز مۇۋەپپەقىيەتلىك ئەسلىگە كەلتۈرۈلدى، ئەمما ئەڭ يېڭى ئۆزگىرىشلەرنىڭ بىر قىسمى ساقلانماي قالغان بولۇشى مۇمكىن.",
"editor_crashed_details_button": "تېخىمۇ كۆپ تەپسىلاتلارنى كۆرۈش...",
"editor_crashed_details_intro": "ئەگەر بۇ كاشىلا كۆپ قېتىم كۆرۈلسە، تۆۋەندىكى ئۇچۇرلارنى كۆچۈرۈپ GitHub دا مەلۇم قىلىشنى ئويلىشىپ كۆرۈڭ.",
"editor_crashed_details_title": "تېخنىكىلىق ئۇچۇرلار",
"auto-detect-language": "ئاپتوماتىك تەكشۈرۈلگەن"
},
"empty": {
"open_note_instruction": "تۆۋەندىكى كىرگۈزۈش رامكىسىغا خاتىرە ماۋزۇسىنى يېزىش ئارقىلىق ياكى دەرەخسىمان تىزىملىكتىن بىر خاتىرىنى تاللاپ ئېچىڭ.",
"search_placeholder": "خاتىرىنى نامى بويىچە ئىزدەش"
},
"file": {
"file_preview_not_available": "بۇ ھۆججەت فورماتىنى ئالدىن كۆرگىلى بولمايدۇ."
},
"media": {
"play": "قويۇش (بوشلۇق)",
"pause": "توختىتىش (بوشلۇق)",
"back-10s": "10 سېكۇنت ئارقىغا (سول يۆنىلىش كۇنۇپكىسى)",
"forward-30s": "30 سېكۇنت ئالدىغا",
"mute": "ئۈنسىز (M)",
"unmute": "ئاۋازنى ئېچىش (M)",
"playback-speed": "قويۇش سۈرئىتى",
"loop": "تەكرار قويۇش",
"disable-loop": "تەكرارقويۇشنى چەكلەش",
"rotate": "ئايلاندۇرۇش",
"fullscreen": "پۈتۈن ئېكران (F)",
"exit-fullscreen": "پۈتۈن ئېكراندىن چېكىنىش",
"zoom-to-fit": "تولدۇرۇپ چوڭايتىش",
"zoom-reset": "تولدۇرۇپ چوڭايتىشنى ئەسلىگە كەلتۈرۈش"
},
"protected_session": {
"enter_password_instruction": "قوغدالغان خاتىرىنى كۆرسىتىش ئۈچۈن مەخپىي نومۇرنى كىرگۈزۈشىڭىز كېرەك:",
"wrong_password": "مەخپىي نومۇر خاتا.",
"protecting-finished-successfully": "قوغداش مۇۋەپپەقىيەتلىك تاماملاندى.",
"unprotecting-finished-successfully": "قوغداشنى بىكار قىلىش تاماملاندى.",
"protecting-in-progress": "قوغداش ئېلىپ بېرىلىۋاتىدۇ: {{count}}",
"unprotecting-in-progress-count": "قوغداشنى بىكار قىلىش ئېلىپ بېرىلىۋاتىدۇ: {{count}}",
"protecting-title": "قوغدىلىش ھالىتى",
"unprotecting-title": "قوغداشنى بىكار قىلىش ھالىتى"
},
"relation_map": {
"remove_note": "خاتىرىنى ئۆچۈرۈش",
"edit_title": "تېمىنى تەھرىرلەش",
"rename_note": "خاتىرە نامىنى ئۆزگەرتىش",
"enter_new_title": "يېڭى خاتىرە تېمىسىنى كىرگۈزۈڭ:",
"rename_relation": "مۇناسىۋەت نامىنى ئۆزگەرتىش",
"remove_relation": "مۇناسىۋەتنى ئۆچۈرۈش",
"confirm_remove_relation": "بۇ مۇناسىۋەتنى ئۆچۈرۈشنى جەزملەشتۈرەمسىز؟",
"specify_new_relation_name": "يېڭى مۇناسىۋەت نامىنى بەلگىلەڭ (ھەرپ-سان، ئىككى چېكىت ۋە ئاستى سىزىق ئىشلىتىشكە بولىدۇ):",
"connection_exists": "بۇ خاتىرىلەر ئارىسىدا '{{name}}' ناملىق باغلىنىش ئاللىبۇرۇن مەۋجۇت.",
"start_dragging_relations": "مۇناسىۋەتلەرنى بۇ يەردىن سۆرەپ، باشقا بىر خاتىرىنىڭ ئۈستىگە قويۇپ بېرىڭ.",
"note_not_found": "{{noteId}} كىملىكلىك خاتىرە تېپىلمىدى!",
"cannot_match_transform": "تەڭشەش ئۇسۇلىنى ماسلاشتۇرغىلى بولمىدى: {{transform}}",
"note_already_in_diagram": "\"{{title}}\" ناملىق خاتىرە دىئاگراممىدا ئاللىبۇرۇن مەۋجۇت.",
"enter_title_of_new_note": "يېڭى خاتىرىنىڭ تېمىسىنى كىرگۈزۈڭ",
"default_new_note_title": "يېڭى خاتىرە"
},
"render": {
"setup_title": "بۇ خاتىرە ئىچىدە خاسلاشتۇرۇلغان HTML ياكى Preact JSX نى كۆرسىتىش"
},
"web_view_setup": {
"title": "تور بەتنىڭ نەق مەيدان كۆرۈنۈشىنى بىۋاسىتە Trilium غا قۇرۇش",
"url_placeholder": "تور بەت ئادرېسىنى كىرگۈزۈڭ ياكى چاپلاڭ، مەسىلەن: https://triliumnotes.org",
"create_button": "تور كۆرۈنۈشى قۇرۇش",
"invalid_url_title": "ئىناۋەتسىز ئادرېس",
"invalid_url_message": "ئىناۋەتلىك تور ئادرېسى كىرگۈزۈڭ، مەسىلەن: https://triliumnotes.org.",
"disabled_button_enable": "تور كۆرۈنۈشىنى قوزغىتىش"
},
"consistency_checks": {
"title": "ئىزچىللىقنى تەكشۈرۈش",
"find_and_fix_label": "ئىزچىللىق مەسىلىلىرىنى تېپىش ۋە ئوڭشاش",
"find_and_fix_button": "ئىزچىللىق مەسىلىلىرىنى تېپىش ۋە ئوڭشاش",
"finding_and_fixing_message": "ئىزچىللىق مەسىلىلىرىنى تېپىۋاتىدۇ ۋە ئوڭشاۋاتىدۇ...",
"issues_fixed_message": "بايقالغان بارلىق ئىزچىللىق مەسىلىلىرى ئوڭشالدى."
},
"database_anonymization": {
"title": "سانداننى نامسىزلاشتۇرۇش",
"full_anonymization": "تولۇق نامسىزلاشتۇرۇش"
},
"database_integrity_check": {
"check_integrity_label": "سانداننىڭ پۈتۈنلۈكىنى تەكشۈرۈش"
},
"sync": {
"title": "ماسقەدەملەش",
"force_full_sync_label": "مەجبۇرىي تولۇق ماسقەدەملەش",
"force_full_sync_description": "ماسقەدەملەش مۇلازىمىتېرى بىلەن تولۇق ماسقەدەملەشنى قوزغىتىپ، بارلىق ئۆزگىرىشلەرنى قايتا يۈكلەيدۇ.",
"force_full_sync_button": "مەجبۇرىي تولۇق ماسقەدەملەش",
"fill_entity_changes_label": "ئۆزگىرىشلىرىنى تولدۇرۇش",
"full_sync_triggered": "تولۇق ماسقەدەملەش قوزغىتىلدى",
"filling_entity_changes": "ئۆزگىرىش قۇرلىرى تولدۇرۇلۇۋاتىدۇ...",
"finished-successfully": "ماسقەدەملەش مۇۋەپپەقىيەتلىك تاماملاندى.",
"failed": "ماسقەدەملەش مەغلۇپ بولدى: {{message}}"
},
"database": {
"title": "ساندان"
},
"vacuum_database": {
"title": "سانداننى تازىلاش",
"vacuum_label": "سانداننى تازىلاش",
"vacuum_description": "سانداننى قايتا قۇرۇپ ھۆججەت چوڭلۇقىنى كىچىكلىتىدۇ. سانلىق مەلۇماتلاردا ھېچقانداق ئۆزگىرىش بولمايدۇ.",
"description": "بۇ سانداننى قايتا قۇرىدۇ، ئادەتتە بوشلۇق ئىگىلىنىشىنى ئازايتىدۇ، سانلىق مەلۇماتلار ئۆچۈرۈلمەيدۇ.",
"button_text": "سانداننى تازىلاش",
"vacuuming_database": "ساندان تازىلىنىۋاتىدۇ...",
"database_vacuumed": "ساندان تازىلاندى"
},
"experimental_features": {
"title": "تەجرىبە تاللانمىلار",
"disclaimer": "بۇ تاللانمىلار تەجرىبە باسقۇچىدا، سىستېمىنىڭ مۇقىمسىزلىقىنى كەلتۈرۈپ چىقىرىشى مۇمكىن. ئېھتىيات بىلەن ئىشلىتىڭ.",
"llm_name": "AI / LLM پاراڭ"
},
"fonts": {
"fonts": "خەت نۇسخىسى",
"custom_fonts": "ئىختىيارى خەت نۇسخىسىنى ئىشلىتىش",
"font_family": "خەت نۇسخىسى تۈرى",
"size": "چوڭلۇقى",
"note_tree_font": "خاتىرە دەرىخى خەت نۇسخىسى",
"note_detail_font": "ھۆججەت خەت نۇسخىسى",
"monospace_font": "تەڭ كەڭلىكتىكى خەت نۇسخىسى",
"monospace_font_description": "كود خاتىرىلىرى ۋە كود بۆلەكلىرى ئۈچۈن ئىشلىتىلىدۇ",
"size_relative_to_general": "چوڭلۇقى ئادەتتىكى خەت چوڭلۇقىغا نىسبەتەن بەلگىلىنىدۇ",
"not_all_fonts_available": "تىزىملىكتىكى بارلىق خەت نۇسخىلىرى سىستېمىڭىزدا بولماسلىقى مۇمكىن",
"generic-fonts": "ئومۇمىي خەت نۇسخىلىرى"
},
"max_content_width": {
"centerContent": "مەزمۇننى ئوتتۇرىغا توغرىلاش"
},
"theme": {
"theme_label": "ئەپ تېمىسى",
"auto_theme": "سىستېما رەڭ لايىھىسىگە ماسلىشىش",
"triliumnext": "سىستېما رەڭ لايىھىسىگە ماسلىشىش",
"triliumnext-light": "ئۈچۈك رەڭ",
"triliumnext-dark": "قېنىق رەڭ",
"custom_themes": "ئىختىيارى",
"layout-vertical-title": "تىك",
"layout-horizontal-title": "گورىزونتال"
}
}
+18
View File
@@ -107,6 +107,23 @@ interface PdfViewerLayersMessage {
toggleLayer?: (layerId: string, visible: boolean) => void;
}
interface PdfAnnotationInfo {
id: string;
type: string;
contents: string;
highlightedText: string;
author: string;
pageNumber: number;
color: string | null;
creationDate: string | null;
modificationDate: string | null;
}
interface PdfViewerAnnotationsMessage {
type: "pdfjs-viewer-annotations";
annotations: PdfAnnotationInfo[];
}
type PdfMessageEvent = MessageEvent<
PdfDocumentModifiedMessage
| PdfSaveViewHistoryMessage
@@ -117,5 +134,6 @@ type PdfMessageEvent = MessageEvent<
| PdfViewerThumbnailMessage
| PdfViewerAttachmentsMessage
| PdfViewerLayersMessage
| PdfViewerAnnotationsMessage
| PdfDocumentBlobResultMessage
>;
+5 -22
View File
@@ -147,19 +147,11 @@ export default function NoteDetail() {
toast.closePersistent("printing");
handlePrintReport(printReport);
};
const onPreviewResult = (_e: any, { buffer, notePath }: { buffer: Uint8Array; notePath: string }) => {
toast.closePersistent("printing");
if (note) {
appContext.triggerCommand("showPrintPreview", { pdfBuffer: buffer, note, notePath });
}
};
ipcRenderer.on("print-progress", onPrintProgress);
ipcRenderer.on("print-done", onPrintDone);
ipcRenderer.on("export-as-pdf-preview-result", onPreviewResult);
return () => {
ipcRenderer.off("print-progress", onPrintProgress);
ipcRenderer.off("print-done", onPrintDone);
ipcRenderer.off("export-as-pdf-preview-result", onPreviewResult);
};
}, [note]);
@@ -182,20 +174,11 @@ export default function NoteDetail() {
useTriliumEvent("printActiveNote", () => {
if (!noteContext?.isActive() || !note) return;
if (isElectron()) {
// On Electron, open the print preview dialog. Actual print/PDF actions
// are triggered from the dialog's footer buttons.
showToast("exporting_pdf");
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("export-as-pdf-preview", {
title: note.title,
notePath: noteContext.notePath,
pageSize: note.getAttributeValue("label", "printPageSize") ?? "Letter",
landscape: note.hasAttribute("label", "printLandscape"),
scale: parseFloat(note.getAttributeValue("label", "printScale") ?? "1") || 1,
margins: note.getAttributeValue("label", "printMargins") ?? "default",
pageRanges: ""
});
// PDF printing is handled by the PDF viewer's own print mechanism.
if (note.type === "file" && note.mime === "application/pdf") return;
if (isElectron() && noteContext.notePath) {
appContext.triggerCommand("showPrintPreview", { note, notePath: noteContext.notePath });
return;
}
@@ -11,7 +11,7 @@ import promotedAttributeDefinitionParser from "../../services/promoted_attribute
import server from "../../services/server.js";
import shortcutService from "../../services/shortcuts.js";
import SpacedUpdate from "../../services/spaced_update.js";
import utils from "../../services/utils.js";
import utils, { openInAppHelpFromUrl } from "../../services/utils.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
@@ -206,7 +206,14 @@ const ATTR_TITLES: Record<string, string> = {
"relation-definition": t("attribute_detail.relation_definition")
};
const ATTR_HELP: Record<string, Record<string, string>> = {
interface AttrHelpEntry {
description: string;
helpPage?: string;
}
type AttrHelpMap = Record<string, Record<string, string | AttrHelpEntry>>;
const ATTR_HELP: AttrHelpMap = {
label: {
disableVersioning: t("attribute_detail.disable_versioning"),
calendarRoot: t("attribute_detail.calendar_root"),
@@ -229,8 +236,8 @@ const ATTR_HELP: Record<string, Record<string, string>> = {
cssClass: t("attribute_detail.css_class"),
iconClass: t("attribute_detail.icon_class"),
pageSize: t("attribute_detail.page_size"),
customRequestHandler: t("attribute_detail.custom_request_handler"),
customResourceProvider: t("attribute_detail.custom_resource_provider"),
customRequestHandler: { description: t("attribute_detail.custom_request_handler"), helpPage: "J5Ex1ZrMbyJ6" },
customResourceProvider: { description: t("attribute_detail.custom_resource_provider"), helpPage: "J5Ex1ZrMbyJ6" },
widget: t("attribute_detail.widget"),
workspace: t("attribute_detail.workspace"),
workspaceIconClass: t("attribute_detail.workspace_icon_class"),
@@ -655,9 +662,21 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
const attrName = String(this.$inputName.val());
if (this.attrType && this.attrType in ATTR_HELP && attrName && attrName in ATTR_HELP[this.attrType]) {
const entry = ATTR_HELP[this.attrType][attrName];
const description = typeof entry === "string" ? entry : entry.description;
const helpPage = typeof entry === "string" ? undefined : entry.helpPage;
const $td = $("<td colspan=2>").append($("<strong>").text(attrName)).append(" - ").append(description);
if (helpPage) {
const $helpButton = $(`<button class="icon-action bx bx-help-circle" type="button" style="margin-left: 5px; vertical-align: middle;" />`);
$helpButton.on("click", () => openInAppHelpFromUrl(helpPage));
$td.append($helpButton);
}
this.$attrHelp
.empty()
.append($("<td colspan=2>").append($("<strong>").text(attrName)).append(" - ").append(ATTR_HELP[this.attrType][attrName]))
.append($td)
.show();
} else {
this.$attrHelp.empty().hide();
@@ -23,7 +23,7 @@ body.mobile .scrolling-container {
--content-margin-inline: 8px;
}
.note-split.type-code:not(.mime-text-x-sqlite) {
.note-split.type-code:not(.mime-text-x-sqlite, .mime-text-x-markdown) {
&> .scrolling-container {
background-color: var(--code-background-color);
--scrollbar-background-color: var(--main-background-color);
@@ -1,7 +1,14 @@
/** Reduce the z-index of modals so that ckeditor popups are properly shown on top of it. */
body.popup-editor-open > .modal-backdrop { z-index: 998; }
body.popup-editor-open .popup-editor-dialog { z-index: 999; }
body.popup-editor-open .ck-clipboard-drop-target-line { z-index: 1000; }
body.popup-editor-open:not(.popup-editor-stacked) > .modal-backdrop { z-index: 998; }
body.popup-editor-open:not(.popup-editor-stacked) .popup-editor-dialog { z-index: 999; }
body.popup-editor-open:not(.popup-editor-stacked) .ck-clipboard-drop-target-line { z-index: 1000; }
/** When stacked on top of another Bootstrap modal (e.g. opened from a dialog's help button),
raise the popup and its own backdrop above the underlying modal (Bootstrap defaults are
backdrop 1050, dialog 1055 — no native stacking increment). */
body.popup-editor-stacked .popup-editor-dialog { z-index: 1100; }
body.popup-editor-stacked .ck-clipboard-drop-target-line { z-index: 1101; }
.modal-backdrop.popup-editor-backdrop { z-index: 1099; }
body.desktop .modal.popup-editor-dialog .modal-dialog {
max-width: 75vw;
@@ -30,6 +30,7 @@ const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function PopupEditor() {
const [ shown, setShown ] = useState(false);
const [ stacked, setStacked ] = useState(false);
const parentComponent = useContext(ParentComponent);
const [ noteContext, setNoteContext ] = useState(new NoteContext("_popup-editor"));
const isMobile = utils.isMobile();
@@ -40,6 +41,7 @@ export default function PopupEditor() {
useTriliumEvent("openInPopup", async ({ noteIdOrPath }) => {
const noteContext = new NoteContext("_popup-editor");
setStacked(!!document.querySelector(".modal.show"));
const noteId = tree.getNoteIdAndParentIdFromUrl(noteIdOrPath);
if (!noteId.noteId) return;
@@ -51,7 +53,8 @@ export default function PopupEditor() {
viewScope: {
// Override auto-readonly notes to be editable, but respect user's choice to have a read-only note.
readOnlyTemporarilyDisabled: !hasUserSetNoteReadOnly
}
},
keepActiveDialog: true
});
// Events triggered at note context level (e.g. the save indicator) would not work since the note context has no parent component. Propagate events to parent component so that they can be handled properly.
@@ -63,7 +66,20 @@ export default function PopupEditor() {
// Add a global class to be able to handle issues with z-index due to rendering in a popup.
useEffect(() => {
document.body.classList.toggle("popup-editor-open", shown);
}, [shown]);
document.body.classList.toggle("popup-editor-stacked", shown && stacked);
}, [shown, stacked]);
// When stacked on top of another modal, raise this popup's own backdrop above
// the underlying modal. Bootstrap does not auto-increment z-index for stacked
// modals, and the appended `.modal-backdrop` is not individually addressable.
useEffect(() => {
if (!shown || !stacked) return;
const backdrops = document.querySelectorAll(".modal-backdrop");
const popupBackdrop = backdrops[backdrops.length - 1] as HTMLElement | undefined;
if (!popupBackdrop) return;
popupBackdrop.classList.add("popup-editor-backdrop");
return () => popupBackdrop.classList.remove("popup-editor-backdrop");
}, [shown, stacked]);
return (
<NoteContextContext.Provider value={noteContext}>
@@ -17,6 +17,9 @@
.delete-notes-dialog .preview-list li {
padding: 6px 16px;
border-bottom: 1px solid var(--main-border-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.delete-notes-dialog .preview-list li:last-child {
@@ -1,7 +1,10 @@
import "./delete_notes.css";
import type { DeleteNotesPreview } from "@triliumnext/commons";
import { CSSProperties } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import type React from "react";
import { List, type RowComponentProps } from "react-window";
import froca from "../../services/froca.js";
import { t } from "../../services/i18n.js";
@@ -173,18 +176,42 @@ function DeleteAllClonesOption({ cloneInfo, deleteAllClones, setDeleteAllClones
);
}
const ROW_HEIGHT = 36;
const VIRTUALIZE_THRESHOLD = 100;
const MAX_LIST_HEIGHT = 400;
function DeletedNoteRow({ index, style, noteIds }: RowComponentProps<{ noteIds: string[] }>) {
return (
<li style={style as CSSProperties} key={noteIds[index]}>
<NoteLink notePath={noteIds[index]} showNotePath showNoteIcon />
</li>
) as React.ReactElement;
}
function DeletedNotes({ noteIdsToBeDeleted }: { noteIdsToBeDeleted: DeleteNotesPreview["noteIdsToBeDeleted"] }) {
return (
<Card heading={t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}>
<CardSection noPadding={noteIdsToBeDeleted.length > 0}>
{noteIdsToBeDeleted.length ? (
<ul className="preview-list">
{noteIdsToBeDeleted.map((noteId) => (
<li key={noteId}>
<NoteLink notePath={noteId} showNotePath showNoteIcon />
</li>
))}
</ul>
noteIdsToBeDeleted.length > VIRTUALIZE_THRESHOLD ? (
<List
className="preview-list"
tagName="ul"
rowComponent={DeletedNoteRow}
rowCount={noteIdsToBeDeleted.length}
rowHeight={ROW_HEIGHT}
rowProps={{ noteIds: noteIdsToBeDeleted }}
style={{ maxHeight: MAX_LIST_HEIGHT }}
/>
) : (
<ul className="preview-list">
{noteIdsToBeDeleted.map((noteId) => (
<li key={noteId}>
<NoteLink notePath={noteId} showNotePath showNoteIcon />
</li>
))}
</ul>
)
) : (
<span className="muted-text">{t("delete_notes.no_note_to_delete")}</span>
)}
@@ -0,0 +1,54 @@
.modal.print-preview-dialog {
.modal-body {
height: 78vh;
padding: 0;
display: flex;
}
.print-preview-settings {
padding: 16px;
min-width: 250px;
overflow-y: auto;
}
.print-preview-page-ranges {
width: 140px;
}
.print-preview-pane {
flex: 1;
position: relative;
}
.print-preview-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
background-color: var(--modal-bg-color, rgba(255, 255, 255, 0.8));
.bx-loader-circle {
font-size: 2rem;
}
}
.margin-editor {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 0;
}
.margin-editor-row {
display: flex;
gap: 24px;
align-items: center;
}
.margin-spinner {
width: 130px;
}
}
@@ -1,3 +1,5 @@
import "./print_preview.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
@@ -7,6 +9,8 @@ import { dynamicRequire, isElectron } from "../../services/utils";
import Button, { ButtonGroup } from "../react/Button";
import Dropdown from "../react/Dropdown";
import { FormListHeader, FormListItem } from "../react/FormList";
import FormSelect from "../react/FormSelect";
import FormTextBox, { FormTextBoxWithUnit } from "../react/FormTextBox";
import { useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import Slider from "../react/Slider";
@@ -71,7 +75,6 @@ function isValidPageRanges(value: string): boolean {
}
export interface PrintPreviewData {
pdfBuffer: Uint8Array;
note: FNote;
notePath: string;
}
@@ -92,7 +95,7 @@ export default function PrintPreviewDialog() {
const bufferRef = useRef<Uint8Array>();
const notePathRef = useRef("");
const pdfUrlRef = useRef<string>();
const generationRef = useRef(0);
const [landscape, setLandscape] = useNoteLabelBoolean(note, "printLandscape");
const [pageSize, setPageSize] = useNoteLabelWithDefault(note, "printPageSize", "Letter");
@@ -110,8 +113,6 @@ export default function PrintPreviewDialog() {
const [printers, setPrinters] = useState<PrinterInfo[]>([]);
const [destination, setDestination] = useState<string>(DESTINATION_PDF);
const skipNextRegenRef = useRef(false);
useEffect(() => {
if (!shown || !isElectron()) return;
const { ipcRenderer } = dynamicRequire("electron");
@@ -137,15 +138,11 @@ export default function PrintPreviewDialog() {
}, []);
useTriliumEvent("showPrintPreview", (data: PrintPreviewData) => {
// When the dialog is already open, it manages its own regeneration via
// a persistent IPC listener. Ignore duplicate events from NoteDetail's
// listener to avoid overwriting the preview with stale data.
if (shown) return;
skipNextRegenRef.current = true;
setNote(data.note);
notePathRef.current = data.notePath;
updatePreview(data.pdfBuffer);
setLoading(true);
setShown(true);
});
@@ -157,8 +154,6 @@ export default function PrintPreviewDialog() {
const { ipcRenderer } = dynamicRequire("electron");
const onResult = (_e: any, { buffer, error }: { buffer?: Uint8Array; error?: string }) => {
if (generationRef.current <= 0) return;
toast.closePersistent("printing");
if (error) {
setLoading(false);
@@ -188,7 +183,6 @@ export default function PrintPreviewDialog() {
const regeneratePreview = useCallback((opts: PreviewOpts) => {
if (!isElectron()) return;
++generationRef.current;
setLoading(true);
const { ipcRenderer } = dynamicRequire("electron");
ipcRenderer.send("export-as-pdf-preview", {
@@ -201,20 +195,23 @@ export default function PrintPreviewDialog() {
});
}, []);
const isFirstGenerationRef = useRef(true);
useEffect(() => {
if (!shown || !pageRangesValid) return;
if (skipNextRegenRef.current) {
skipNextRegenRef.current = false;
return;
}
const delay = isFirstGenerationRef.current ? 0 : 800;
isFirstGenerationRef.current = false;
const handle = setTimeout(() => {
regeneratePreview({ landscape, pageSize, scale, margins: marginsStr, pageRanges: pageRanges.trim() });
}, 400);
}, delay);
return () => clearTimeout(handle);
}, [shown, landscape, pageSize, scale, marginsStr, pageRanges, pageRangesValid, regeneratePreview]);
function handleClose() {
setShown(false);
isFirstGenerationRef.current = true;
toast.closePersistent("print-preview-error");
if (pdfUrlRef.current) {
URL.revokeObjectURL(pdfUrlRef.current);
@@ -278,7 +275,7 @@ export default function PrintPreviewDialog() {
size="xl"
show={shown}
onHidden={handleClose}
bodyStyle={{ height: "78vh", padding: 0, display: "flex" }}
stackable
footerAlignment="between"
footer={
<>
@@ -305,7 +302,7 @@ export default function PrintPreviewDialog() {
</>
}
>
<div style={{ padding: "16px", minWidth: "250px", overflowY: "auto" }}>
<div class="print-preview-settings">
<OptionsSection>
<OptionsRow name="destination" label={t("print_preview.destination")}>
<Dropdown
@@ -356,16 +353,14 @@ export default function PrintPreviewDialog() {
</OptionsRow>
<OptionsRow name="pageSize" label={t("print_preview.page_size")}>
<select
class="form-select form-select-sm"
value={pageSize}
onChange={(e) => setPageSize((e.target as HTMLSelectElement).value)}
<FormSelect
values={PAGE_SIZES.map((size) => ({ key: size, title: size }))}
keyProperty="key"
titleProperty="title"
currentValue={pageSize}
onChange={setPageSize}
disabled={loading}
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
/>
</OptionsRow>
<OptionsRow name="scale" label={t("print_preview.scale")} description={`${Math.round(scale * 100)}%`}>
@@ -379,17 +374,19 @@ export default function PrintPreviewDialog() {
</OptionsRow>
<OptionsRow name="margins" label={t("print_preview.margins")}>
<select
class="form-select form-select-sm"
value={marginPreset}
onChange={(e) => setMarginsStr(serializeMargins((e.target as HTMLSelectElement).value as MarginPreset | "custom", customMargins))}
<FormSelect
values={[
{ key: "default", title: t("print_preview.margins_default") },
{ key: "none", title: t("print_preview.margins_none") },
{ key: "minimum", title: t("print_preview.margins_minimum") },
{ key: "custom", title: t("print_preview.margins_custom") },
]}
keyProperty="key"
titleProperty="title"
currentValue={marginPreset}
onChange={(value) => setMarginsStr(serializeMargins(value as MarginPreset | "custom", customMargins))}
disabled={loading}
>
<option value="default">{t("print_preview.margins_default")}</option>
<option value="none">{t("print_preview.margins_none")}</option>
<option value="minimum">{t("print_preview.margins_minimum")}</option>
<option value="custom">{t("print_preview.margins_custom")}</option>
</select>
/>
</OptionsRow>
{marginPreset === "custom" && (
@@ -401,26 +398,24 @@ export default function PrintPreviewDialog() {
label={t("print_preview.page_ranges")}
description={!pageRangesValid ? t("print_preview.page_ranges_invalid") : t("print_preview.page_ranges_hint")}
>
<input
type="text"
class={`form-control form-control-sm ${!pageRangesValid ? "is-invalid" : ""}`}
value={pageRanges}
<FormTextBox
className={`print-preview-page-ranges ${!pageRangesValid ? "is-invalid" : ""}`}
currentValue={pageRanges}
placeholder={t("print_preview.page_ranges_placeholder")}
onInput={(e) => setPageRanges((e.target as HTMLInputElement).value)}
onChange={(value) => setPageRanges(value)}
disabled={loading}
style={{ width: "140px" }}
/>
</OptionsRow>
</OptionsSection>
</div>
<div style={{ flex: 1, position: "relative" }}>
<div class="print-preview-pane">
{loading && (
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1, backgroundColor: "var(--modal-bg-color, rgba(255,255,255,0.8))" }}>
<span class="bx bx-loader-circle bx-spin" style={{ fontSize: "2rem" }} />
<div class="print-preview-loading-overlay">
<span class="bx bx-loader-circle bx-spin" />
</div>
)}
{pdfUrl && <PdfViewer pdfUrl={pdfUrl} disableSelection />}
{pdfUrl && <PdfViewer pdfUrl={pdfUrl} toolbar={false} disableSelection />}
</div>
</Modal>
);
@@ -439,42 +434,37 @@ function MarginEditor({ margins, onChange, disabled }: {
onChange: (side: keyof CustomMargins, value: number) => void;
disabled: boolean;
}) {
const spinnerStyle = { width: "130px" };
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", padding: "8px 0" }}>
<MarginSpinner label={t("print_preview.margin_top")} value={margins.top} onChange={(v) => onChange("top", v)} disabled={disabled} style={spinnerStyle} />
<div style={{ display: "flex", gap: "24px", alignItems: "center" }}>
<MarginSpinner label={t("print_preview.margin_left")} value={margins.left} onChange={(v) => onChange("left", v)} disabled={disabled} style={spinnerStyle} />
<MarginSpinner label={t("print_preview.margin_right")} value={margins.right} onChange={(v) => onChange("right", v)} disabled={disabled} style={spinnerStyle} />
<div class="margin-editor">
<MarginSpinner label={t("print_preview.margin_top")} value={margins.top} onChange={(v) => onChange("top", v)} disabled={disabled} />
<div class="margin-editor-row">
<MarginSpinner label={t("print_preview.margin_left")} value={margins.left} onChange={(v) => onChange("left", v)} disabled={disabled} />
<MarginSpinner label={t("print_preview.margin_right")} value={margins.right} onChange={(v) => onChange("right", v)} disabled={disabled} />
</div>
<MarginSpinner label={t("print_preview.margin_bottom")} value={margins.bottom} onChange={(v) => onChange("bottom", v)} disabled={disabled} style={spinnerStyle} />
<MarginSpinner label={t("print_preview.margin_bottom")} value={margins.bottom} onChange={(v) => onChange("bottom", v)} disabled={disabled} />
</div>
);
}
function MarginSpinner({ label, value, onChange, disabled, style }: {
function MarginSpinner({ label, value, onChange, disabled }: {
label: string;
value: number;
onChange: (value: number) => void;
disabled: boolean;
style?: Record<string, string>;
}) {
return (
<div class="input-group input-group-sm" style={style}>
<input
type="number"
class="form-control form-control-sm"
title={label}
aria-label={label}
value={value}
min={0}
max={100}
step={1}
onChange={(e) => onChange(Math.min(100, (e.target as HTMLInputElement).valueAsNumber || 0))}
disabled={disabled}
/>
<span class="input-group-text">mm</span>
</div>
<FormTextBoxWithUnit
type="number"
className="margin-spinner"
title={label}
aria-label={label}
currentValue={String(value)}
min={0}
max={100}
step={1}
onChange={(val) => onChange(Math.min(100, parseInt(val, 10) || 0))}
disabled={disabled}
unit="mm"
/>
);
}
@@ -31,6 +31,8 @@ import NoItems from "../react/NoItems";
import { RawHtmlBlock, SanitizedHtml } from "../react/RawHtml";
import PdfViewer from "../type_widgets/file/PdfViewer";
const DIFFABLE_TYPES = ["text", "code", "mermaid"];
export default function RevisionsDialog() {
const [ note, setNote ] = useState<FNote>();
const [ noteContent, setNoteContent ] = useState<string>();
@@ -358,7 +360,7 @@ function RevisionToolbar({ revisionItem, showDiff, setShowDiff, setShown, onRevi
onRevisionDeleted?: () => void,
onDescriptionUpdated?: (revisionId: string, description: string) => void,
}) {
const canShowDiff = ["text", "code", "mermaid"].includes(revisionItem?.type ?? "");
const canShowDiff = DIFFABLE_TYPES.includes(revisionItem?.type ?? "");
const canInteract = revisionItem && (!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable());
const [ editingDescription, setEditingDescription ] = useState(false);
const [ descriptionDraft, setDescriptionDraft ] = useState("");
@@ -529,7 +531,7 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }:
return <></>;
}
if (showDiff) {
if (showDiff && DIFFABLE_TYPES.includes(revisionItem.type)) {
return <RevisionContentDiff noteContent={noteContent} itemContent={content} itemType={revisionItem.type}/>;
}
switch (revisionItem.type) {
@@ -187,6 +187,15 @@ interface BreadcrumbSeparatorProps {
}
function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) {
const notePathComponents = (props.notePath ?? "").split("/");
const parentNoteId = notePathComponents.at(-1);
const parentNote = useNote(parentNoteId);
const [ subtreeHidden ] = useNoteLabelBoolean(parentNote, "subtreeHidden");
if (subtreeHidden && !props.activeNotePath) {
return null;
}
return (
<Dropdown
text={<Icon icon="bx bxs-chevron-right" />}
@@ -24,13 +24,14 @@ const supportedNoteTypes = new Set<NoteType>([
export default function InlineTitle() {
const { note, parentComponent, viewScope } = useNoteContext();
const type = useNoteProperty(note, "type");
const mime = useNoteProperty(note, "mime");
const [ shown, setShown ] = useState(shouldShow(note, type, viewScope));
const containerRef = useRef<HTMLDivElement>(null);
const [ titleHidden, setTitleHidden ] = useState(false);
useLayoutEffect(() => {
setShown(shouldShow(note, type, viewScope));
}, [ note, type, viewScope ]);
}, [ note, type, mime, viewScope ]);
useLayoutEffect(() => {
if (!shown) return;
+8 -1
View File
@@ -179,15 +179,22 @@ body.experimental-feature-new-layout.mobile div.similar-notes-widget div.similar
display: flex;
padding: 6px 12px;
background: var(--bottom-panel-title-bar-background-color);
justify-content: space-between;
align-items: center;
gap: 4px;
.bottom-panel-title-bar-caption {
flex-grow: 1;
text-transform: uppercase;
letter-spacing: .3pt;
font-weight: 600;
font-size: .85em;
}
.icon-action {
--icon-button-size: 24px;
--icon-button-icon-ratio: 0.8;
border-radius: 50%;
}
}
.bottom-panel-content {
+6 -3
View File
@@ -24,9 +24,9 @@ import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNot
import Icon from "../react/Icon";
import LinkButton from "../react/LinkButton";
import { ParentComponent } from "../react/react_utils";
import AutoLinkAttributesTab from "../ribbon/AutoLinkAttributesTab";
import { ContentLanguagesModal, NoteTypeCodeNoteList, NoteTypeOptionsModal, useLanguageSwitcher, useMimeTypes } from "../ribbon/BasicPropertiesTab";
import AttributeEditor, { AttributeEditorImperativeHandlers } from "../ribbon/components/AttributeEditor";
import AutoLinkAttributesTab from "../ribbon/AutoLinkAttributesTab";
import InheritedAttributesTab from "../ribbon/InheritedAttributesTab";
import { NoteSizeWidget, useNoteMetadata } from "../ribbon/NoteInfoTab";
import { NotePathsWidget, useSortedNotePaths } from "../ribbon/NotePathsTab";
@@ -397,7 +397,8 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown
<BottomPanel title={t("attributes_panel.title")}
className="attribute-list"
visible={attributesShown}
setVisible={setAttributesShown}>
setVisible={setAttributesShown}
helpPage="zEY4DaJG4YT5">
<span class="attributes-panel-label">{t("inherited_attribute_list.title")}</span>
<InheritedAttributesTab {...context} emptyListString="inherited_attribute_list.none" />
@@ -583,12 +584,14 @@ interface BottomPanelParams {
visible: boolean;
setVisible?: (visible: boolean) => void;
className?: string;
helpPage?: string;
}
function BottomPanel({ children, title, visible, setVisible, className }: BottomPanelParams) {
function BottomPanel({ children, title, visible, setVisible, className, helpPage }: BottomPanelParams) {
return <div className={clsx("bottom-panel", className, {"hidden-ext": !visible})}>
<div className="bottom-panel-title-bar">
<span className="bottom-panel-title-bar-caption">{title}</span>
{helpPage && <button class="icon-action bx bx-question-mark" onClick={() => openInAppHelpFromUrl(helpPage)} title={t("open-help-page")} />}
<button class="icon-action bx bx-x" onClick={() => setVisible?.(false)} />
</div>
<div class={clsx("bottom-panel-content")}>
+1 -1
View File
@@ -1,7 +1,7 @@
import clsx from "clsx";
import { HTMLAttributes } from "preact";
interface IconProps extends Pick<HTMLAttributes<HTMLSpanElement>, "className" | "onClick" | "title"> {
interface IconProps extends Pick<HTMLAttributes<HTMLSpanElement>, "className" | "onClick" | "title" | "style"> {
icon?: string;
className?: string;
}
+8 -1
View File
@@ -3,8 +3,10 @@ import clsx from "clsx";
import { ComponentChildren, CSSProperties, RefObject } from "preact";
import { useEffect, useMemo, useRef } from "preact/hooks";
import appContext from "../../components/app_context";
import { openDialog } from "../../services/dialog";
import { t } from "../../services/i18n";
import { openInAppHelpFromUrl } from "../../services/utils";
import { useSyncedRef } from "./hooks";
interface CustomTitleBarButton {
@@ -170,7 +172,12 @@ export default function Modal({ children, className, size, title, customTitleBar
)}
{header}
{helpPageId && (
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
<button
className="help-button"
type="button"
title={t("modal.help_title")}
onClick={() => appContext.triggerCommand("openInPopup", { noteIdOrPath: `_help_${helpPageId}` })}
>?</button>
)}
{titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => (
@@ -76,13 +76,15 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote
const [viewType] = useNoteLabel(note, "viewType");
const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment();
const isSourceView = noteContext?.viewScope?.viewMode === "source";
const isSearchable = isSourceView || ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType);
const isSearchable = isSourceView || ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType)
|| (noteType === "file" && note.mime === "application/pdf");
const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help");
const isExportableToImage = ["mermaid", "mindMap"].includes(noteType);
const isContentAvailable = note.isContentAvailable();
const isPrintable = isContentAvailable && (
["text", "code"].includes(noteType) ||
(noteType === "book" && ["presentation", "list", "table"].includes(viewType ?? ""))
["text", "code", "spreadsheet"].includes(noteType) ||
(noteType === "book" && ["presentation", "list", "table"].includes(viewType ?? "")) ||
(noteType === "file" && note.mime === "application/pdf")
);
const isElectron = getIsElectron();
const isMac = getIsMac();
@@ -8,6 +8,7 @@ import FNote from "../../entities/fnote";
import { removeOwnedAttributesByNameOrType } from "../../services/attributes";
import { t } from "../../services/i18n";
import server from "../../services/server";
import { openInAppHelpFromUrl } from "../../services/utils";
import Admonition from "../react/Admonition";
import FormSelect from "../react/FormSelect";
import FormTextArea from "../react/FormTextArea";
@@ -165,7 +166,10 @@ function SearchStringOption({ note, refreshResults, error, ...restProps }: Searc
title={t("search_string.title_column")}
className={clsx({ "has-error": !!error })}
help={<>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" onClick={(e) => {
e.preventDefault();
openInAppHelpFromUrl("eIg8jdvaoNNd");
}}>{t("search_string.complete_help")}</a>
<ul style="marigin-bottom: 0;">
<li>{t("search_string.full_text_search")}</li>
<li><code>#abc</code> - {t("search_string.label_abc")}</li>
@@ -13,9 +13,17 @@ body.experimental-feature-new-layout #right-pane {
justify-content: flex-start;
--icon-button-size: 26px;
.icon-action {
flex-shrink: 0;
transition: transform 0.15s ease;
}
.card-header-title {
padding-inline: 0;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@@ -28,13 +36,33 @@ body.experimental-feature-new-layout #right-pane {
border-bottom: 0;
}
&.collapsed .card-header > .tn-icon {
&.collapsed .card-header > .icon-action {
transform: rotate(-90deg);
}
}
.card.grow:not(.collapsed) {
flex-grow: 1;
flex: 1;
flex-shrink: 1;
min-height: 0;
display: flex;
flex-direction: column;
.body-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.card-body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
}
}
.gutter-vertical + .card .card-header {
@@ -16,6 +16,7 @@ import { useActiveNoteContext, useLegacyWidget, useNoteProperty, useTriliumEvent
import NoItems from "../react/NoItems";
import LegacyRightPanelWidget from "../right_panel_widget";
import HighlightsList from "./HighlightsList";
import PdfAnnotations from "./pdf/PdfAnnotations";
import PdfAttachments from "./pdf/PdfAttachments";
import PdfLayers from "./pdf/PdfLayers";
import PdfPages from "./pdf/PdfPages";
@@ -75,7 +76,7 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
const definitions: RightPanelWidgetDefinition[] = [
{
el: <TableOfContents />,
enabled: (noteType === "text" || noteType === "doc" || isPdf),
enabled: (noteType === "text" || noteType === "doc" || isPdf || !!note?.isMarkdown()),
},
{
el: <PdfPages />,
@@ -89,6 +90,10 @@ function useItems(rightPaneVisible: boolean, widgetsByParent: WidgetsByParent) {
el: <PdfLayers />,
enabled: isPdf,
},
{
el: <PdfAnnotations />,
enabled: isPdf,
},
{
el: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0,
@@ -30,6 +30,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain
return (
<div
ref={containerRef}
id={id}
class={clsx("card widget", {
collapsed: !expanded,
grow
@@ -9,7 +9,8 @@
}
.sidebar-chat-container .llm-chat-input-form {
flex-shrink: 0; /* Keep input bar from shrinking */
flex-shrink: 0;
padding: 8px;
.llm-chat-input {
font-size: 0.9em;
@@ -49,29 +50,10 @@
max-width: 100%;
}
/* Make the sidebar chat widget grow to fill available space when expanded */
#right-pane .widget.grow:not(.collapsed) {
flex: 1;
flex-shrink: 1; /* Override flex-shrink: 0 from main styles */
min-height: 0;
display: flex;
flex-direction: column;
}
#right-pane .widget.grow:not(.collapsed) .body-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden; /* Override overflow: auto from main styles */
}
#right-pane .widget.grow:not(.collapsed) .card-body {
flex: 1;
min-height: 0;
overflow: hidden; /* Override overflow: auto - let child handle scrolling */
display: flex;
flex-direction: column;
/* Remove default card-body padding and let child handle scrolling */
#sidebar-chat .card-body {
padding: 0;
overflow: hidden;
}
/* Compact markdown in sidebar */
@@ -279,7 +279,7 @@ export default function SidebarChat() {
}
>
<div className="sidebar-chat-container">
<div className="sidebar-chat-messages">
<div className="sidebar-chat-messages" ref={chat.scrollContainerRef}>
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
@@ -38,12 +38,13 @@ export default function TableOfContents() {
<RightPanelWidget id="toc" title={t("toc.table_of_contents")} grow>
{((noteType === "text" && isReadOnly) || (noteType === "doc")) && <ReadOnlyTextTableOfContents />}
{noteType === "text" && !isReadOnly && <EditableTextTableOfContents />}
{noteType === "file" && noteMime === "application/pdf" && <PdfTableOfContents />}
{noteType === "file" && noteMime === "application/pdf" && <ContextDataTableOfContents />}
{note?.isMarkdown() && <ContextDataTableOfContents />}
</RightPanelWidget>
);
}
function PdfTableOfContents() {
function ContextDataTableOfContents() {
const data = useGetContextData("toc");
return (
@@ -0,0 +1,59 @@
.pdf-annotations-list {
width: 100%;
}
.pdf-annotation-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
margin: 6px 8px;
border-radius: 6px;
cursor: pointer;
transition: filter 0.2s;
}
.pdf-annotation-item:hover {
filter: brightness(0.9);
}
.pdf-annotation-item,
.pdf-annotation-item .tn-icon {
color: #333;
}
.pdf-annotation-item .tn-icon {
flex-shrink: 0;
font-size: 18px;
}
.pdf-annotation-info {
flex: 1;
min-width: 0;
}
.pdf-annotation-highlighted-text {
font-size: 13px;
font-style: italic;
color: #555;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.pdf-annotation-contents {
font-size: 13px;
color: #333;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.pdf-annotation-author {
font-size: 11px;
color: #555;
margin-top: 2px;
}
@@ -0,0 +1,73 @@
import "./PdfAnnotations.css";
import { t } from "../../../services/i18n";
import { useActiveNoteContext, useGetContextData, useNoteProperty } from "../../react/hooks";
import Icon from "../../react/Icon";
import RightPanelWidget from "../RightPanelWidget";
const TYPE_ICONS: Record<string, string> = {
text: "bx bxs-comment-detail",
highlight: "bx bx-highlight",
};
export default function PdfAnnotations() {
const { note } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type");
const noteMime = useNoteProperty(note, "mime");
const annotationsData = useGetContextData("pdfAnnotations");
if (noteType !== "file" || noteMime !== "application/pdf") {
return null;
}
if (!annotationsData || annotationsData.annotations.length === 0) {
return null;
}
return (
<RightPanelWidget id="pdf-annotations" title={t("pdf.annotations", { count: annotationsData.annotations.length })}>
<div className="pdf-annotations-list">
{annotationsData.annotations.map((annotation) => (
<PdfAnnotationItem
key={annotation.id}
annotation={annotation}
onNavigate={annotationsData.scrollToAnnotation}
/>
))}
</div>
</RightPanelWidget>
);
}
function PdfAnnotationItem({
annotation,
onNavigate
}: {
annotation: PdfAnnotationInfo;
onNavigate: (annotationId: string, pageNumber: number) => void;
}) {
const icon = annotation.contents
? "bx bxs-comment-detail"
: TYPE_ICONS[annotation.type] ?? "bx bx-comment";
return (
<div
className="pdf-annotation-item"
onClick={() => onNavigate(annotation.id, annotation.pageNumber)}
style={annotation.color ? { backgroundColor: annotation.color } : undefined}
>
<Icon icon={icon} />
<div className="pdf-annotation-info">
{annotation.highlightedText && (
<div className="pdf-annotation-highlighted-text">{annotation.highlightedText}</div>
)}
{annotation.contents && (
<div className="pdf-annotation-contents">{annotation.contents}</div>
)}
{annotation.author && (
<div className="pdf-annotation-author">{annotation.author}</div>
)}
</div>
</div>
);
}
@@ -1,14 +1,18 @@
.pdf-pages-list {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
padding: 8px;
align-content: flex-start;
flex: 1;
min-height: 0;
}
.pdf-page-row {
display: flex;
gap: 4px;
padding: 0 8px;
}
.pdf-page-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -18,19 +22,6 @@
transition: border-color 0.2s;
box-sizing: border-box;
position: relative;
.pdf-page-number {
font-size: 12px;
margin-bottom: 4px;
color: var(--main-text-color);
position: absolute;
bottom: 1em;
left: 50%;
transform: translateX(-50%);
background-color: var(--accented-background-color);
padding: 0.2em 0.5em;
border-radius: 4px;
}
}
.pdf-page-item:hover {
@@ -44,6 +35,8 @@
.pdf-page-thumbnail {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -55,6 +48,15 @@
object-fit: contain;
}
.pdf-page-number {
font-size: 12px;
margin-top: 4px;
color: var(--main-text-color);
background-color: var(--accented-background-color);
padding: 0.2em 0.5em;
border-radius: 4px;
}
.pdf-page-loading {
color: var(--muted-text-color);
font-size: 11px;
@@ -1,12 +1,17 @@
import "./PdfPages.css";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import type React from "react";
import { List, type RowComponentProps } from "react-window";
import { NoteContextDataMap } from "../../../components/note_context";
import { t } from "../../../services/i18n";
import { useActiveNoteContext, useGetContextData, useNoteProperty } from "../../react/hooks";
import RightPanelWidget from "../RightPanelWidget";
const ROW_HEIGHT = 180;
const COLUMNS = 2;
export default function PdfPages() {
const { note } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type");
@@ -27,9 +32,10 @@ export default function PdfPages() {
function PdfPagesList({ pagesData }: { pagesData: NoteContextDataMap["pdfPages"] }) {
const [thumbnails, setThumbnails] = useState<Map<number, string>>(new Map());
const requestedThumbnails = useRef<Set<number>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState(0);
useEffect(() => {
// Listen for thumbnail responses via custom event
function handleThumbnail(event: CustomEvent) {
const { pageNumber, dataUrl } = event.detail;
setThumbnails(prev => new Map(prev).set(pageNumber, dataUrl));
@@ -41,8 +47,18 @@ function PdfPagesList({ pagesData }: { pagesData: NoteContextDataMap["pdfPages"]
};
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
setContainerHeight(entry.contentRect.height);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const requestThumbnail = useCallback((pageNumber: number) => {
// Only request if we haven't already requested it and don't have it
if (!requestedThumbnails.current.has(pageNumber) && !thumbnails.has(pageNumber) && pagesData) {
requestedThumbnails.current.add(pageNumber);
pagesData.requestThumbnail(pageNumber);
@@ -53,52 +69,77 @@ function PdfPagesList({ pagesData }: { pagesData: NoteContextDataMap["pdfPages"]
return <div className="no-pages">No pages available</div>;
}
const pages = Array.from({ length: pagesData.totalPages }, (_, i) => i + 1);
return (
<div className="pdf-pages-list">
{pages.map(pageNumber => (
<PdfPageItem
key={pageNumber}
pageNumber={pageNumber}
isActive={pageNumber === pagesData.currentPage}
thumbnail={thumbnails.get(pageNumber)}
onRequestThumbnail={requestThumbnail}
onPageClick={() => pagesData.scrollToPage(pageNumber)}
<div ref={containerRef} className="pdf-pages-list">
{containerHeight > 0 && (
<List
rowComponent={PdfPageRow}
rowCount={Math.ceil(pagesData.totalPages / COLUMNS)}
rowHeight={ROW_HEIGHT}
rowProps={{
totalPages: pagesData.totalPages,
thumbnails,
currentPage: pagesData.currentPage,
requestThumbnail,
scrollToPage: pagesData.scrollToPage
}}
style={{ height: containerHeight }}
/>
))}
)}
</div>
);
}
function PdfPageItem({
pageNumber,
isActive,
thumbnail,
onRequestThumbnail,
onPageClick
}: {
interface PdfPageRowData {
totalPages: number;
thumbnails: Map<number, string>;
currentPage: number;
requestThumbnail: (page: number) => void;
scrollToPage: (page: number) => void;
}
function PdfPageRow({ index, style, ...data }: RowComponentProps<PdfPageRowData>) {
const { totalPages, thumbnails, currentPage, requestThumbnail, scrollToPage } = data;
const startPage = index * COLUMNS + 1;
const pages = Array.from({ length: COLUMNS }, (_, i) => startPage + i).filter(p => p <= totalPages);
return (
<div style={style as preact.JSX.CSSProperties} className="pdf-page-row">
{pages.map(pageNumber => (
<PdfPageCell
key={pageNumber}
pageNumber={pageNumber}
isActive={pageNumber === currentPage}
thumbnail={thumbnails.get(pageNumber)}
requestThumbnail={requestThumbnail}
scrollToPage={scrollToPage}
/>
))}
</div>
) as React.ReactElement;
}
function PdfPageCell({ pageNumber, isActive, thumbnail, requestThumbnail, scrollToPage }: {
pageNumber: number;
isActive: boolean;
thumbnail?: string;
onRequestThumbnail(page: number): void;
onPageClick(): void;
requestThumbnail: (page: number) => void;
scrollToPage: (page: number) => void;
}) {
const hasRequested = useRef(false);
useEffect(() => {
if (!thumbnail && !hasRequested.current) {
hasRequested.current = true;
onRequestThumbnail(pageNumber);
requestThumbnail(pageNumber);
}
}, [pageNumber, thumbnail, onRequestThumbnail]);
}, [pageNumber, thumbnail, requestThumbnail]);
return (
<div
className={`pdf-page-item ${isActive ? 'active' : ''}`}
onClick={onPageClick}
onClick={() => scrollToPage(pageNumber)}
>
<div className="pdf-page-number">{pageNumber}</div>
<div className="pdf-page-thumbnail">
{thumbnail ? (
<img src={thumbnail} alt={t("pdf.pages_alt", { pageNumber })} />
@@ -106,6 +147,7 @@ function PdfPageItem({
<div className="pdf-page-loading">{t("pdf.pages_loading")}</div>
)}
</div>
<div className="pdf-page-number">{pageNumber}</div>
</div>
);
}
@@ -69,8 +69,16 @@ export function TextRepresentation({ textUrl, processUrl }: TextRepresentationPr
const result = response.result;
const minConfidence = response.minConfidence ?? 0;
// Check if this is an image-based PDF (no text extracted)
if (result && !result.text && result.processingType === 'pdf') {
toast.showPersistent({
id: `ocr-pdf-unsupported-${randomString(8)}`,
icon: "bx bx-info-circle",
message: t("ocr.image_based_pdf_not_supported"),
timeout: 15000
});
// Check if text was filtered due to low confidence
if (result && !result.text && result.confidence > 0 && minConfidence > 0) {
} else if (result && !result.text && result.confidence > 0 && minConfidence > 0) {
const confidencePercent = Math.round(result.confidence * 100);
const thresholdPercent = Math.round(minConfidence * 100);
toast.showPersistent({
@@ -13,11 +13,6 @@
overflow: auto;
padding: 0.5em 1em;
user-select: text;
.mermaid-diagram,
.mermaid-diagram svg {
max-width: 100%;
}
}
.markdown-preview [data-source-line] {
@@ -3,18 +3,23 @@ import { describe, expect, it } from "vitest";
import { renderWithSourceLines } from "./Markdown.js";
describe("renderWithSourceLines", () => {
function extractLines(html: string): number[] {
function extractLines(src: string): number[] {
const { html } = renderWithSourceLines(src);
return [ ...html.matchAll(/data-source-line="(\d+)"/g) ].map((m) => parseInt(m[1], 10));
}
it("returns empty string for empty input", () => {
expect(renderWithSourceLines("")).toBe("");
function html(src: string): string {
return renderWithSourceLines(src).html;
}
it("returns empty html for empty input", () => {
expect(html("")).toBe("");
});
it("tags a single block as line 1", () => {
const html = renderWithSourceLines("hello");
expect(extractLines(html)).toEqual([ 1 ]);
expect(html).toContain("hello");
const result = html("hello");
expect(extractLines("hello")).toEqual([ 1 ]);
expect(result).toContain("hello");
});
it("assigns correct source lines to consecutive blocks separated by blank lines", () => {
@@ -26,7 +31,7 @@ describe("renderWithSourceLines", () => {
"Another one." // line 5
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 3, 5 ]);
expect(extractLines(src)).toEqual([ 1, 3, 5 ]);
});
it("counts multi-line blocks so subsequent blocks get the right line", () => {
@@ -39,19 +44,18 @@ describe("renderWithSourceLines", () => {
"after" // 6
].join("\n");
expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 6 ]);
expect(extractLines(src)).toEqual([ 1, 6 ]);
});
it("renders standard markdown constructs inside the wrappers", () => {
const html = renderWithSourceLines("## Heading\n\n- item\n");
expect(html).toContain("<h2>Heading</h2>");
expect(html).toContain("<ul>");
expect(html).toContain("<li>item</li>");
const result = html("## Heading\n\n- item\n");
expect(result).toContain("<h2>Heading</h2>");
expect(result).toContain("<ul>");
expect(result).toContain("<li>item</li>");
});
it("keeps H1 as H1 in the preview (no title-row context to avoid)", () => {
const html = renderWithSourceLines("# Top level");
expect(html).toContain("<h1>Top level</h1>");
expect(html("# Top level")).toContain("<h1>Top level</h1>");
});
it("preserves reference-style links across per-block parsing", () => {
@@ -61,33 +65,47 @@ describe("renderWithSourceLines", () => {
"[t]: https://example.com"
].join("\n");
const html = renderWithSourceLines(src);
expect(html).toContain('href="https://example.com"');
expect(html(src)).toContain('href="https://example.com"');
});
it("normalizes fenced code languages to CKEditor MIME identifiers for syntax highlighting", () => {
const html = renderWithSourceLines("```javascript\nconst x = 1;\n```");
expect(html).toMatch(/class="language-application-javascript-env-(backend|frontend)"/);
expect(html("```javascript\nconst x = 1;\n```")).toMatch(/class="language-application-javascript-env-(backend|frontend)"/);
});
it("produces CKEditor admonition markup for GFM callouts", () => {
const html = renderWithSourceLines("> [!NOTE]\n> heads up");
expect(html).toContain('<aside class="admonition note">');
expect(html("> [!NOTE]\n> heads up")).toContain('<aside class="admonition note">');
});
it("preserves the `mermaid` fence language so the mermaid rewrite can match it", () => {
const html = renderWithSourceLines("```mermaid\ngraph TD;\nA-->B;\n```");
expect(html).toContain('class="language-mermaid"');
expect(html("```mermaid\ngraph TD;\nA-->B;\n```")).toContain('class="language-mermaid"');
});
it("produces math-tex spans for inline math", () => {
const html = renderWithSourceLines("Energy: $e=mc^2$.");
expect(html).toContain('<span class="math-tex">');
expect(html("Energy: $e=mc^2$.")).toContain('<span class="math-tex">');
});
it("renders [[wikilinks]] with hash-router hrefs so the preview navigates correctly", () => {
const html = renderWithSourceLines("See [[abc123]] for details.");
expect(html).toContain('class="reference-link"');
expect(html).toContain('href="#root/abc123"');
const result = html("See [[abc123]] for details.");
expect(result).toContain('class="reference-link"');
expect(result).toContain('href="#root/abc123"');
});
it("extracts headings with correct levels and lines", () => {
const src = [
"# Title", // line 1
"", // line 2
"text", // line 3
"", // line 4
"## Section", // line 5
"", // line 6
"### Sub" // line 7
].join("\n");
const { headings } = renderWithSourceLines(src);
expect(headings).toEqual([
{ id: "md-heading-0", level: 1, text: "Title", line: 1 },
{ id: "md-heading-1", level: 2, text: "Section", line: 5 },
{ id: "md-heading-2", level: 3, text: "Sub", line: 7 }
]);
});
});
@@ -1,20 +1,45 @@
import "./Markdown.css";
import VanillaCodeMirror from "@triliumnext/codemirror";
import { renderToHtml } from "@triliumnext/commons";
import { CustomMarkdownRenderer, renderToHtml } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { Marked } from "marked";
import { Marked, type Tokens } from "marked";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import NoteContext from "../../../components/note_context";
import SplitEditor from "../helpers/SplitEditor";
import { ReadOnlyTextContent } from "../text/ReadOnlyText";
import { TypeWidgetProps } from "../type_widget";
const marked = new Marked({ breaks: true, gfm: true });
/**
* The default {@link CustomMarkdownRenderer} falls back to
* `language-text-x-trilium-auto` on unlabeled fences, which drives
* `syntax_highlight.ts` into `highlightAuto` — that dynamic-imports every hljs
* language bundle on first render and runs detection on each block, which is
* both slow and often wrong on short snippets. For the live preview we instead
* emit unlabeled fences without a `language-` class so the highlighter skips
* them entirely.
*/
class MarkdownPreviewRenderer extends CustomMarkdownRenderer {
override code(token: Tokens.Code): string {
const html = super.code(token);
if (token.lang) return html;
return html.replace('<code class="language-text-x-trilium-auto">', `<code class="language-text-plain">`);
}
}
export interface MarkdownHeading {
id: string;
level: number;
text: string;
line: number;
}
interface MarkdownContextValue {
html: string;
headings: MarkdownHeading[];
setEditorView: (view: VanillaCodeMirror | null) => void;
setPreviewEl: (el: HTMLDivElement | null) => void;
}
@@ -31,14 +56,15 @@ export default function Markdown(props: TypeWidgetProps) {
const [ content, setContent ] = useState("");
const [ editorView, setEditorView ] = useState<VanillaCodeMirror | null>(null);
const [ previewEl, setPreviewEl ] = useState<HTMLDivElement | null>(null);
const html = useMemo(() => renderWithSourceLines(content), [ content ]);
const { html, headings } = useMemo(() => renderWithSourceLines(content), [ content ]);
useSyncedScrolling(editorView, previewEl);
useSyncedHighlight(editorView, previewEl, html);
usePublishToc(props.noteContext, editorView, headings);
const ctx = useMemo<MarkdownContextValue>(
() => ({ html, setEditorView, setPreviewEl }),
[ html ]
() => ({ html, headings, setEditorView, setPreviewEl }),
[ html, headings ]
);
return (
@@ -67,6 +93,36 @@ function MarkdownPreview({ ntxId }: { ntxId: TypeWidgetProps["ntxId"] }) {
);
}
//#region Table of contents
/**
* Publishes heading data via `setContextData("toc", ...)` so the sidebar
* Table of Contents can display headings extracted from the markdown source,
* independent of whether the preview pane is visible.
*/
function usePublishToc(
noteContext: NoteContext | undefined,
editorView: VanillaCodeMirror | null,
headings: MarkdownHeading[]
) {
useEffect(() => {
if (!noteContext) return;
noteContext.setContextData("toc", {
headings,
scrollToHeading(heading) {
if (!editorView) return;
const mdHeading = headings.find(h => h.id === heading.id);
if (!mdHeading) return;
const line = editorView.state.doc.line(Math.min(mdHeading.line, editorView.state.doc.lines));
const lineBlock = editorView.lineBlockAt(line.from);
const scrollerHeight = editorView.scrollDOM.clientHeight;
const targetTop = lineBlock.top - scrollerHeight / 2 + lineBlock.height / 2;
editorView.scrollDOM.scrollTo({ top: targetTop, behavior: "smooth" });
}
});
}, [ noteContext, headings, editorView ]);
}
//#endregion
//#region Synced scrolling
/**
* One-directional (editor → preview) scroll sync. On editor scroll, finds the
@@ -161,23 +217,34 @@ const NON_RENDERED_TOKENS = new Set([ "space", "def" ]);
* with the matching lexer token's start line. Marked does not emit source
* positions (markedjs/marked#1267) so we count newlines in `raw` ourselves.
*/
export function renderWithSourceLines(src: string): string {
export function renderWithSourceLines(src: string): { html: string; headings: MarkdownHeading[] } {
// Compute the start line of each renderable top-level token in source order.
const tokens = marked.lexer(src);
const lines: number[] = [];
const headings: MarkdownHeading[] = [];
let line = 1;
let headingIdx = 0;
for (const token of tokens) {
const startLine = line;
line += (token.raw.match(/\n/g) ?? []).length;
if (!NON_RENDERED_TOKENS.has(token.type)) lines.push(startLine);
if (token.type === "heading") {
headings.push({
id: `md-heading-${headingIdx++}`,
level: (token as { depth: number }).depth,
text: token.text ?? "",
line: startLine
});
}
}
const html = renderToHtml(src, "", {
sanitize: (h) => DOMPurify.sanitize(h),
wikiLink: { formatHref: (id) => `#root/${id}` },
demoteH1: false
demoteH1: false,
renderer: new MarkdownPreviewRenderer({ async: false })
});
if (!html) return "";
if (!html) return { html: "", headings };
const container = document.createElement("div");
container.innerHTML = html;
@@ -188,6 +255,6 @@ export function renderWithSourceLines(src: string): string {
const sourceLine = lines[i] ?? lines[lines.length - 1] ?? 1;
parts.push(`<div data-source-line="${sourceLine}">${children[i].outerHTML}</div>`);
}
return parts.join("");
return { html: parts.join(""), headings };
}
//#endregion
@@ -5,7 +5,7 @@ import type NoteContext from "../../../components/note_context";
import FBlob from "../../../entities/fblob";
import FNote from "../../../entities/fnote";
import { useViewModeConfig } from "../../collections/NoteList";
import { useBlobEditorSpacedUpdate, useTriliumEvent } from "../../react/hooks";
import { useBlobEditorSpacedUpdate, useEffectiveReadOnly, useTriliumEvent } from "../../react/hooks";
import PdfViewer from "./PdfViewer";
export default function PdfPreview({ note, blob, componentId, noteContext }: {
@@ -15,6 +15,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
componentId: string | undefined;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const isReadOnly = useEffectiveReadOnly(note, noteContext);
const historyConfig = useViewModeConfig<HistoryData>(note, "pdfHistory");
const spacedUpdate = useBlobEditorSpacedUpdate({
@@ -56,7 +57,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
useEffect(() => {
function handleMessage(event: PdfMessageEvent) {
if (event.data?.type === "pdfjs-viewer-document-modified") {
if (event.data.noteId === note.noteId && event.data.ntxId === noteContext.ntxId) {
if (!isReadOnly && event.data.noteId === note.noteId && event.data.ntxId === noteContext.ntxId) {
spacedUpdate.resetUpdateTimer();
spacedUpdate.scheduleUpdate();
}
@@ -153,6 +154,19 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
});
}
if (event.data.type === "pdfjs-viewer-annotations") {
noteContext.setContextData("pdfAnnotations", {
annotations: event.data.annotations,
scrollToAnnotation: (annotationId: string, pageNumber: number) => {
iframeRef.current?.contentWindow?.postMessage({
type: "trilium-scroll-to-annotation",
annotationId,
pageNumber
}, window.location.origin);
}
});
}
if (event.data.type === "pdfjs-viewer-layers") {
noteContext.setContextData("pdfLayers", {
layers: event.data.layers,
@@ -171,7 +185,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
return () => {
window.removeEventListener("message", handleMessage);
};
}, [ note, historyConfig, componentId, blob, noteContext ]);
}, [ note, historyConfig, componentId, blob, noteContext, isReadOnly, spacedUpdate ]);
useTriliumEvent("customDownload", ({ ntxId }) => {
if (ntxId !== noteContext.ntxId) return;
@@ -180,6 +194,20 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
});
});
useTriliumEvent("printActiveNote", () => {
if (!noteContext.isActive()) return;
iframeRef.current?.contentWindow?.postMessage({
type: "trilium-print"
}, window.location.origin);
});
useTriliumEvent("findInText", () => {
if (!noteContext.isActive()) return;
iframeRef.current?.contentWindow?.postMessage({
type: "trilium-find"
}, window.location.origin);
});
return (historyConfig &&
<PdfViewer
iframeRef={iframeRef}
@@ -199,7 +227,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
});
}
}}
editable
editable={!isReadOnly}
/>
);
}
@@ -22,6 +22,8 @@ interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabInd
* If set, enables editable mode which includes persistence of user settings, annotations as well as specific features such as sending table of contents data for the sidebar.
*/
editable?: boolean;
/** If set, hides the toolbar. Defaults to `true` (visible). */
toolbar?: boolean;
/** If set, disables text selection in the rendered PDF. */
disableSelection?: boolean;
}
@@ -29,7 +31,7 @@ interface PdfViewerProps extends Pick<HTMLAttributes<HTMLIFrameElement>, "tabInd
/**
* Reusable component displaying a PDF. The PDF needs to be provided via a URL.
*/
export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable, disableSelection }: PdfViewerProps) {
export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable, toolbar = true, disableSelection }: PdfViewerProps) {
const iframeRef = useSyncedRef(externalIframeRef, null);
const [ locale ] = useTriliumOption("locale");
const [ newLayout ] = useTriliumOptionBool("newLayout");
@@ -40,7 +42,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad
ref={iframeRef}
class="pdf-preview"
style={{width: "100%", height: "100%"}}
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&lang=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}`}
src={`pdfjs/web/viewer.html?v=${glob.triliumVersion}&file=${pdfUrl}&locale=${locale}&sidebar=${newLayout ? "0" : "1"}&editable=${editable ? "1" : "0"}&toolbar=${toolbar ? "1" : "0"}`}
onLoad={() => {
injectStyles();
onLoad?.();
@@ -98,8 +98,7 @@ export default function SplitEditor({ note, noteContext, error, splitOptions, pr
useEffect(() => {
if (mode !== "split" || !utils.isDesktop() || !containerRef.current) return;
// Only the visible (non-display:none) panes participate in the split.
const elements = (Array.from(containerRef.current.children) as HTMLElement[])
.filter(el => el.offsetParent !== null);
const elements = (Array.from(containerRef.current.children) as HTMLElement[]);
const splitInstance = Split(elements, {
rtl: glob.isRtl,
sizes: [ 50, 50 ],
@@ -113,7 +112,7 @@ export default function SplitEditor({ note, noteContext, error, splitOptions, pr
const layoutClass = mode === "source" ? "split-source-only"
: mode === "preview" ? "split-read-only"
: `split-${splitEditorOrientation}`;
: `split-${splitEditorOrientation}`;
return (
<div ref={containerRef} className={`note-detail-split note-detail-printable ${layoutClass} ${className ?? ""}`}>
@@ -1,14 +1,12 @@
import "./ChatMessage.css";
import { type LlmCitation, renderToHtml } from "@triliumnext/commons";
import DOMPurify from "dompurify";
import { Marked } from "marked";
import { useEffect, useMemo, useRef } from "preact/hooks";
import { useMemo } from "preact/hooks";
import { type LlmCitation, createWikiLinkExtension } from "@triliumnext/commons";
import link from "../../../services/link.js";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { ReadOnlyTextContent } from "../text/ReadOnlyText.js";
import { ExpandableCard, ExpandableSection } from "./ExpandableCard.js";
import { type ContentBlock, getMessageText, type StoredMessage, type TextBlock, type ToolCallBlock } from "./llm_chat_types.js";
import ToolCallCard from "./ToolCallCard.js";
@@ -19,41 +17,20 @@ function shortenNumber(n: number): string {
return n.toString();
}
// Configure marked for safe rendering with client-side URL format
const markedInstance = new Marked({
breaks: true, // Convert \n to <br>
gfm: true // GitHub Flavored Markdown
});
markedInstance.use({
extensions: [createWikiLinkExtension({ formatHref: (id) => `#root/${id}` })]
});
/** Parse markdown to HTML. */
/** Parse markdown to HTML using the shared rendering pipeline. */
function renderMarkdown(markdown: string): string {
return markedInstance.parse(markdown) as string;
return renderToHtml(markdown, "", {
sanitize: (h) => DOMPurify.sanitize(h),
wikiLink: { formatHref: (id) => `#root/${id}` },
demoteH1: false
});
}
/** Renders markdown content with reference link title loading. */
/** Renders markdown content using the shared read-only text pipeline (math, syntax highlighting, mermaid, etc.). */
function MarkdownContent({ html, isStreaming }: { html: string; isStreaming?: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const referenceLinks = containerRef.current.querySelectorAll<HTMLAnchorElement>("a.reference-link");
for (const el of referenceLinks) {
link.loadReferenceLinkTitle($(el), el.href);
}
}, [html]);
return (
<>
<div
ref={containerRef}
className="llm-chat-markdown"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }}
/>
<ReadOnlyTextContent html={html} className="llm-chat-markdown" />
{isStreaming && <span className="llm-chat-cursor" />}
</>
);
@@ -213,7 +190,7 @@ export default function ChatMessage({ message, isStreaming }: Props) {
{message.usage.cost != null && (
<>
<span className="llm-chat-usage-separator">·</span>
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(4)}</span>
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(2)}</span>
</>
)}
</>
@@ -2,8 +2,15 @@
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
box-sizing: border-box;
.llm-chat-messages {
padding: 1em;
}
.llm-chat-input-form {
padding: 0.5em 1em;
}
}
.llm-chat-messages {
@@ -55,7 +55,7 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
return (
<div className="llm-chat-container">
<div className="llm-chat-messages">
<div className="llm-chat-messages" ref={chat.scrollContainerRef}>
{chat.messages.length === 0 && !chat.isStreaming && (
<NoItems
icon="bx bx-conversation"
@@ -5,6 +5,17 @@
color: var(--muted-text-color);
}
.llm-chat-tool-call-count {
font-weight: 400;
color: var(--muted-text-color);
margin-left: 0.35rem;
}
/* Folded group — separate the first nested row from the group header. */
.llm-chat-tool-call-group[open] > .expandable-section-body > .expandable-section:first-child {
border-top: 1px solid var(--main-border-color);
}
.llm-chat-tool-call-note-ref {
font-weight: 400;
margin-left: 0.25rem;
@@ -51,18 +51,20 @@ function getToolCallContext(toolCall: ToolCall): ToolCallContext {
return { noteId: null, parentNoteId: null, detailText: detailText || null };
}
function toolNameIcon(toolName: string): string {
if (toolName.includes("search")) return "bx bx-search";
if (toolName.includes("note")) return "bx bx-note";
if (toolName.includes("attribute")) return "bx bx-purchase-tag";
if (toolName.includes("attachment")) return "bx bx-paperclip";
if (toolName.includes("skill")) return "bx bx-book-open";
if (toolName.includes("web")) return "bx bx-globe";
return "bx bx-wrench";
}
function toolCallIcon(toolCall: ToolCall): string {
if (toolCall.isError) return "bx bx-error-circle";
if (!toolCall.result) return "bx bx-loader-alt bx-spin";
const name = toolCall.toolName;
if (name.includes("search")) return "bx bx-search";
if (name.includes("note")) return "bx bx-note";
if (name.includes("attribute")) return "bx bx-purchase-tag";
if (name.includes("attachment")) return "bx bx-paperclip";
if (name.includes("skill")) return "bx bx-book-open";
if (name.includes("web")) return "bx bx-globe";
return "bx bx-wrench";
return toolNameIcon(toolCall.toolName);
}
/** Try to parse a JSON string into a structured value. */
@@ -201,13 +203,57 @@ function ToolCallSection({ toolCall }: { toolCall: ToolCall }) {
);
}
/** A card that groups one or more sequential tool calls together. */
export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) {
/** Fold a section showing multiple invocations of the same tool under a single header. */
function ToolCallGroupSection({ toolCalls }: { toolCalls: ToolCall[] }) {
const first = toolCalls[0];
const anyPending = toolCalls.some(tc => !tc.result);
const anyError = toolCalls.some(tc => tc.isError);
const icon = anyPending ? "bx bx-loader-alt bx-spin" : toolNameIcon(first.toolName);
const friendlyName = t(`llm.tools.${first.toolName}`, { defaultValue: first.toolName });
const label = (
<>
{friendlyName}
<span className="llm-chat-tool-call-count">×{toolCalls.length}</span>
{anyError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
</>
);
return (
<ExpandableCard>
<ExpandableSection icon={icon} label={label} className="llm-chat-tool-call-group">
{toolCalls.map((tc, idx) => (
<ToolCallSection key={tc.id ?? idx} toolCall={tc} />
))}
</ExpandableSection>
);
}
/** Group consecutive tool calls that share the same tool name. Singletons pass through unchanged. */
function groupByToolName(toolCalls: ToolCall[]): Array<ToolCall | ToolCall[]> {
const groups: Array<ToolCall | ToolCall[]> = [];
for (const tc of toolCalls) {
const last = groups[groups.length - 1];
if (Array.isArray(last) && last[0].toolName === tc.toolName) {
last.push(tc);
} else if (last && !Array.isArray(last) && last.toolName === tc.toolName) {
groups[groups.length - 1] = [last, tc];
} else {
groups.push(tc);
}
}
return groups;
}
/** A card that groups one or more sequential tool calls together. */
export default function ToolCallCard({ toolCalls }: { toolCalls: ToolCall[] }) {
const groups = groupByToolName(toolCalls);
return (
<ExpandableCard>
{groups.map((group, idx) => (
Array.isArray(group)
? <ToolCallGroupSection key={idx} toolCalls={group} />
: <ToolCallSection key={group.id ?? idx} toolCall={group} />
))}
</ExpandableCard>
);
}
@@ -38,6 +38,7 @@ export interface UseLlmChatReturn {
contextNoteId: string | undefined;
lastPromptTokens: number;
messagesEndRef: RefObject<HTMLDivElement>;
scrollContainerRef: RefObject<HTMLDivElement>;
textareaRef: RefObject<HTMLTextAreaElement>;
/** Whether a provider is configured and available */
hasProvider: boolean;
@@ -90,8 +91,10 @@ export function useLlmChat(
const [hasProvider, setHasProvider] = useState<boolean>(true); // Assume true initially
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const isNearBottomRef = useRef(true);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
@@ -147,9 +150,28 @@ export function useLlmChat(
refreshModels();
}, []);
// Scroll to bottom when content changes
// Track whether the user is near the bottom of the scroll container
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
const THRESHOLD = 50; // px from bottom
const handleScroll = () => {
isNearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight <= THRESHOLD;
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => container.removeEventListener("scroll", handleScroll);
}, []);
// Scroll to bottom when content changes, but only if user hasn't scrolled away.
// Always use instant scroll — smooth animations race with the scroll listener
// during streaming, causing the auto-scroll to "unstick" mid-animation.
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
if (isNearBottomRef.current) {
messagesEndRef.current?.scrollIntoView({ behavior: "instant" });
}
}, []);
useEffect(() => {
@@ -419,6 +441,7 @@ export function useLlmChat(
contextNoteId,
lastPromptTokens,
messagesEndRef,
scrollContainerRef,
textareaRef,
hasProvider,
isCheckingProvider,
@@ -5,8 +5,10 @@ import "@univerjs/preset-sheets-conditional-formatting/lib/index.css";
import "@univerjs/preset-sheets-find-replace/lib/index.css";
import "@univerjs/preset-sheets-note/lib/index.css";
import "@univerjs/preset-sheets-filter/lib/index.css";
import "@univerjs/preset-sheets-hyper-link/lib/index.css";
import "@univerjs/preset-sheets-data-validation/lib/index.css";
import { DEFAULT_STYLES } from '@univerjs/core';
import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting';
import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US';
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
@@ -17,17 +19,46 @@ import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter';
import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US';
import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace';
import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US';
import { UniverSheetsHyperLinkPreset } from '@univerjs/preset-sheets-hyper-link';
import UniverPresetSheetsHyperLinkEnUS from '@univerjs/preset-sheets-hyper-link/locales/en-US';
import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note';
import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US';
import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort';
import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US';
import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets';
import { IDialogService, ISidebarService } from '@univerjs/ui';
import { MutableRef, useEffect, useRef } from "preact/hooks";
import { useColorScheme, useEffectiveReadOnly, useTriliumEvent } from "../../react/hooks";
import type NoteContext from "../../../components/note_context";
import { t } from "../../../services/i18n";
import { useColorScheme, useEffectiveReadOnly, useTriliumEvent, useTriliumEvents } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget";
import usePersistence from "./persistence";
function buildReadOnlyLocaleOverrides() {
const msg = t("spreadsheet.read-only");
return {
permission: {
dialog: {
editErr: msg,
commonErr: msg,
pasteErr: msg,
setStyleErr: msg,
copyErr: msg,
setRowColStyleErr: msg,
moveRowColErr: msg,
moveRangeErr: msg,
autoFillErr: msg,
filterErr: msg,
operatorSheetErr: msg,
formulaErr: msg,
hyperLinkErr: msg,
commentErr: msg,
}
}
};
}
export default function Spreadsheet(props: TypeWidgetProps) {
const readOnly = useEffectiveReadOnly(props.note, props.noteContext);
@@ -41,8 +72,9 @@ function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & {
useInitializeSpreadsheet(containerRef, apiRef, readOnly);
useDarkMode(apiRef);
usePersistence(note, noteContext, apiRef, containerRef, readOnly);
useSearchIntegration(apiRef);
usePersistence(note, noteContext, apiRef, containerRef);
useSearchIntegration(apiRef, noteContext);
useDismissDialogsOnNoteSwitch(apiRef);
useFixRadixPortals();
// Focus the spreadsheet when the note is focused.
@@ -92,6 +124,12 @@ function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null
useEffect(() => {
if (!containerRef.current) return;
// Override Univer's hardcoded default font to match Trilium's UI font.
const ff = getComputedStyle(document.body).getPropertyValue("--detail-font-family").trim();
if (ff) {
DEFAULT_STYLES.ff = ff;
}
const { univerAPI } = createUniver({
locale: LocaleType.EN_US,
locales: {
@@ -103,6 +141,8 @@ function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null
UniverPresetSheetsSortEnUS,
UniverPresetSheetsDataValidationEnUS,
UniverPresetSheetsConditionalFormattingEnUS,
UniverPresetSheetsHyperLinkEnUS,
readOnly ? buildReadOnlyLocaleOverrides() : {},
),
},
presets: [
@@ -116,6 +156,7 @@ function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null
"sheet.contextMenu.permission": { hidden: true },
"sheet-permission.operation.openPanel": { hidden: true },
"sheet.command.add-range-protection-from-toolbar": { hidden: true },
"sheet.command.set-range-font-family": { hidden: true },
},
}),
UniverSheetsFindReplacePreset(),
@@ -123,9 +164,22 @@ function useInitializeSpreadsheet(containerRef: MutableRef<HTMLDivElement | null
UniverSheetsFilterPreset(),
UniverSheetsSortPreset(),
UniverSheetsDataValidationPreset(),
UniverSheetsConditionalFormattingPreset()
UniverSheetsConditionalFormattingPreset(),
UniverSheetsHyperLinkPreset()
]
});
if (readOnly) {
univerAPI.addEvent(univerAPI.Event.LifeCycleChanged, ({ stage }) => {
if (stage === univerAPI.Enum.LifecycleStages.Rendered) {
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) return;
workbook.disableSelection();
workbook.getWorkbookPermission().setReadOnly();
}
});
}
apiRef.current = univerAPI;
return () => univerAPI.dispose();
}, [ apiRef, containerRef, readOnly ]);
@@ -142,8 +196,10 @@ function useDarkMode(apiRef: MutableRef<FUniver | undefined>) {
}, [ colorScheme, apiRef ]);
}
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>, noteContext: NoteContext | undefined) {
useTriliumEvent("findInText", () => {
if (!noteContext?.isActive()) return;
const univerAPI = apiRef.current;
if (!univerAPI) return;
@@ -151,3 +207,14 @@ function useSearchIntegration(apiRef: MutableRef<FUniver | undefined>) {
univerAPI.executeCommand("ui.operation.open-find-dialog");
});
}
function useDismissDialogsOnNoteSwitch(apiRef: MutableRef<FUniver | undefined>) {
useTriliumEvents(["beforeNoteSwitch", "noteTypeMimeChanged"], () => {
const univerAPI = apiRef.current;
if (!univerAPI) return;
const injector = (univerAPI as unknown as { _injector: { get(id: unknown): { closeAll(): void; close(): void } } })._injector;
injector.get(IDialogService).closeAll();
injector.get(ISidebarService).close();
});
}
@@ -18,7 +18,7 @@ interface SpreadsheetViewState {
scrollCol?: number;
}
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>, readOnly: boolean) {
export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef<FUniver | undefined>, containerRef: MutableRef<HTMLDivElement | null>) {
const changeListener = useRef<IDisposable>(null);
const pendingContent = useRef<string | null>(null);
@@ -69,12 +69,7 @@ export default function usePersistence(note: FNote, noteContext: NoteContext | n
function applyContent(univerAPI: FUniver, newContent: string) {
const viewState = saveViewState(univerAPI);
// Dispose the existing workbook.
const existingWorkbook = univerAPI.getActiveWorkbook();
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
let workbookData: Partial<IWorkbookData> = {};
if (newContent) {
@@ -89,12 +84,12 @@ export default function usePersistence(note: FNote, noteContext: NoteContext | n
}
}
// Create the new workbook BEFORE disposing the old one so the formula
// engine transitions cleanly without a gap where stale state could leak.
const workbook = univerAPI.createWorkbook(workbookData);
if (readOnly) {
workbook.disableSelection();
const permission = workbook.getPermission();
permission.setWorkbookEditPermission(workbook.getId(), false);
permission.setPermissionDialogVisible(false);
if (existingWorkbook) {
univerAPI.disposeUnit(existingWorkbook.getId());
}
restoreViewState(workbook, viewState);
@@ -1,11 +1,18 @@
/* h1 should not be used at all since semantically that's a note title */
.note-detail-readonly-text-content {
/* h1 should not be used at all since semantically that's a note title */
h1 { font-size: 1.8em; }
h2 { font-size: 1.6em; }
h3 { font-size: 1.4em; }
h4 { font-size: 1.2em; }
h5 { font-size: 1.1em; }
h6 { font-size: 1.0em; }
.mermaid-diagram,
.mermaid-diagram svg,
img {
max-width: 100%;
height: auto;
}
}
body.heading-style-markdown .note-detail-readonly-text-content {
+1 -1
View File
@@ -45,7 +45,7 @@
"@triliumnext/server": "workspace:*",
"@types/electron-squirrel-startup": "1.0.2",
"copy-webpack-plugin": "14.0.0",
"electron": "41.2.0",
"electron": "41.3.0",
"prebuild-install": "7.1.3"
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "41.2.0",
"electron": "41.3.0",
"fs-extra": "11.3.4"
},
"scripts": {
+1 -3
View File
@@ -1,5 +1,4 @@
import cls from "@triliumnext/server/src/services/cls.js";
import TaskContext from "@triliumnext/server/src/services/task_context.js";
import windowService from "@triliumnext/server/src/services/window.js";
import archiver, { type Archiver } from "archiver";
import electron from "electron";
@@ -62,9 +61,8 @@ export function startElectron(callback: () => void): DeferredPromise<void> {
export async function importData(path: string) {
const buffer = await createImportZip(path);
const { zipImportService } = (await import("@triliumnext/core"));
const { zipImportService, TaskContext, becca } = (await import("@triliumnext/core"));
const context = new TaskContext("no-progress-reporting", "importNotes", null);
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
const rootNote = becca.getRoot();
if (!rootNote) {
+1
View File
@@ -0,0 +1 @@
data/
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@triliumnext/script-deployer",
"version": "1.0.0",
"private": true,
"description": "Development harness for writing and deploying Trilium scripts to a local instance",
"scripts": {
"dev": "tsx watch --clear-screen=false --ignore '../client/node_modules/.vite-temp' src/dev.ts",
"test": "vitest run",
"clean": "rm -rf data"
}
}
@@ -0,0 +1,498 @@
/**
* @trilium-script
*
* id: auto-import-rnote
* type: backend
* title: Auto-import Rnote to Canvas
* executeButton: true
* executeDescription: Converts all .rnote children of this note into Excalidraw canvas notes.
*/
import zlib from "zlib";
// ── Types (rnote v0.9+ / v0.13 JSON format) ────────────────────────────────
interface RnoteColor {
r: number;
g: number;
b: number;
a: number;
}
interface RnoteElement {
pos: [number, number];
pressure: number;
}
interface RnoteSegment {
lineto?: { end: RnoteElement };
quadbezto?: { cp: [number, number]; end: RnoteElement };
cubbezto?: { cp1: [number, number]; cp2: [number, number]; end: RnoteElement };
}
interface RnotePath {
start: RnoteElement;
segments: RnoteSegment[];
}
interface RnoteSmoothStyle {
stroke_width: number;
stroke_color: RnoteColor;
fill_color?: RnoteColor;
pressure_curve: string;
line_style?: string;
line_cap?: string;
}
interface RnoteBrushStroke {
path: RnotePath;
style: { smooth?: RnoteSmoothStyle; rough?: Record<string, unknown>; textured?: Record<string, unknown> };
}
interface RnoteTextStyle {
font_family?: string;
font_size?: number;
color?: RnoteColor;
}
interface RnoteTextStroke {
text: string;
transform: { affine: number[] | number[][] };
// v0.13: style, v0.14: text_style
style?: RnoteTextStyle;
text_style?: RnoteTextStyle;
}
interface RnoteBitmapImage {
// v0.13: image is base64 string, v0.14: image is { data, pixel_width, pixel_height, ... }
image: string | { data: string; pixel_width?: number; pixel_height?: number; memory_format?: string };
// v0.13: { mins, maxs }, v0.14: { cuboid: { half_extents }, transform: { affine } }
rectangle:
| { mins: [number, number]; maxs: [number, number] }
| { cuboid: { half_extents: [number, number] }; transform: { affine: number[] } };
}
interface RnoteSlotEntry {
value: {
brushstroke?: RnoteBrushStroke;
textstroke?: RnoteTextStroke;
bitmapimage?: RnoteBitmapImage;
vectorimage?: Record<string, unknown>;
shapestroke?: Record<string, unknown>;
} | null;
version: number;
}
interface RnoteFile {
version: string;
data: {
engine_snapshot: {
document: {
x: number;
y: number;
width: number;
height: number;
};
stroke_components: RnoteSlotEntry[];
};
};
}
// ── Raw RGBA → PNG encoding ─────────────────────────────────────────────────
/** Encode raw RGBA pixels into a minimal PNG buffer using zlib. */
function encodeRawRgbaToPng(data: Buffer, width: number, height: number): Buffer {
// Un-premultiply alpha (R8g8b8a8Premultiplied → straight RGBA)
const straight = Buffer.from(data);
for (let i = 0; i < straight.length; i += 4) {
const a = straight[i + 3];
if (a > 0 && a < 255) {
straight[i] = Math.min(255, Math.round((straight[i] * 255) / a));
straight[i + 1] = Math.min(255, Math.round((straight[i + 1] * 255) / a));
straight[i + 2] = Math.min(255, Math.round((straight[i + 2] * 255) / a));
}
}
// Build PNG filter rows: each row prefixed with filter byte 0 (None)
const rowLen = width * 4;
const rawRows = Buffer.alloc(height * (1 + rowLen));
for (let y = 0; y < height; y++) {
rawRows[y * (1 + rowLen)] = 0; // filter: None
straight.copy(rawRows, y * (1 + rowLen) + 1, y * rowLen, y * rowLen + rowLen);
}
const compressed = zlib.deflateSync(rawRows);
// CRC-32 (used by PNG chunks)
const crcTable: number[] = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[n] = c;
}
function crc32(buf: Buffer): number {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function makeChunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const typeB = Buffer.from(type, "ascii");
const payload = Buffer.concat([typeB, data]);
const crcB = Buffer.alloc(4);
crcB.writeUInt32BE(crc32(payload));
return Buffer.concat([len, payload, crcB]);
}
// IHDR: width, height, bit depth 8, color type 6 (RGBA)
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(width, 0);
ihdrData.writeUInt32BE(height, 4);
ihdrData[8] = 8; // bit depth
ihdrData[9] = 6; // color type: RGBA
ihdrData[10] = 0; // compression
ihdrData[11] = 0; // filter
ihdrData[12] = 0; // interlace
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), // PNG signature
makeChunk("IHDR", ihdrData),
makeChunk("IDAT", compressed),
makeChunk("IEND", Buffer.alloc(0)),
]);
}
// ── Color conversion ────────────────────────────────────────────────────────
function convertColor(c: RnoteColor): string {
const r = Math.round(c.r * 255);
const g = Math.round(c.g * 255);
const b = Math.round(c.b * 255);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
function convertOpacity(c: RnoteColor): number {
return Math.round(c.a * 100);
}
// ── Excalidraw element helpers ──────────────────────────────────────────────
function generateId(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let id = "";
for (let i = 0; i < 20; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
function makeBaseElement(type: string, x: number, y: number, width: number, height: number) {
return {
id: generateId(),
type,
x,
y,
width,
height,
angle: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 0,
opacity: 100,
groupIds: [] as string[],
frameId: null,
index: null,
roundness: null,
seed: Math.floor(Math.random() * 2_000_000_000),
version: 1,
versionNonce: Math.floor(Math.random() * 2_000_000_000),
isDeleted: false,
boundElements: null,
updated: Date.now(),
link: null,
locked: false,
} as Record<string, unknown>;
}
// ── Bezier flattening ───────────────────────────────────────────────────────
const BEZIER_STEPS = 8;
function flattenQuadBezier(
p0: [number, number], pr0: number,
cp: [number, number],
p1: [number, number], pr1: number,
): { pos: [number, number]; pressure: number }[] {
const result: { pos: [number, number]; pressure: number }[] = [];
for (let i = 1; i <= BEZIER_STEPS; i++) {
const t = i / BEZIER_STEPS;
const u = 1 - t;
const x = u * u * p0[0] + 2 * u * t * cp[0] + t * t * p1[0];
const y = u * u * p0[1] + 2 * u * t * cp[1] + t * t * p1[1];
const p = u * pr0 + t * pr1;
result.push({ pos: [x, y], pressure: p });
}
return result;
}
function flattenCubicBezier(
p0: [number, number], pr0: number,
cp1: [number, number], cp2: [number, number],
p1: [number, number], pr1: number,
): { pos: [number, number]; pressure: number }[] {
const result: { pos: [number, number]; pressure: number }[] = [];
for (let i = 1; i <= BEZIER_STEPS; i++) {
const t = i / BEZIER_STEPS;
const u = 1 - t;
const x = u * u * u * p0[0] + 3 * u * u * t * cp1[0] + 3 * u * t * t * cp2[0] + t * t * t * p1[0];
const y = u * u * u * p0[1] + 3 * u * u * t * cp1[1] + 3 * u * t * t * cp2[1] + t * t * t * p1[1];
const p = u * pr0 + t * pr1;
result.push({ pos: [x, y], pressure: p });
}
return result;
}
// ── Converters ──────────────────────────────────────────────────────────────
interface ExcalidrawFile {
mimeType: string;
id: string;
dataURL: string;
created: number;
lastRetrieved: number;
}
function convertBrushStroke(stroke: RnoteBrushStroke) {
const style = stroke.style.smooth;
if (!style) return null; // rough/textured styles not yet supported
// Flatten path into points + pressures
const allPoints: { pos: [number, number]; pressure: number }[] = [
{ pos: stroke.path.start.pos, pressure: stroke.path.start.pressure },
];
let lastPos = stroke.path.start.pos;
let lastPressure = stroke.path.start.pressure;
for (const seg of stroke.path.segments) {
if (seg.lineto) {
const end = seg.lineto.end;
allPoints.push({ pos: end.pos, pressure: end.pressure });
lastPos = end.pos;
lastPressure = end.pressure;
} else if (seg.quadbezto) {
const end = seg.quadbezto.end;
const flattened = flattenQuadBezier(lastPos, lastPressure, seg.quadbezto.cp, end.pos, end.pressure);
allPoints.push(...flattened);
lastPos = end.pos;
lastPressure = end.pressure;
} else if (seg.cubbezto) {
const end = seg.cubbezto.end;
const flattened = flattenCubicBezier(lastPos, lastPressure, seg.cubbezto.cp1, seg.cubbezto.cp2, end.pos, end.pressure);
allPoints.push(...flattened);
lastPos = end.pos;
lastPressure = end.pressure;
}
}
if (allPoints.length < 2) return null;
const originX = allPoints[0].pos[0];
const originY = allPoints[0].pos[1];
const points: number[][] = [];
const pressures: number[] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const pt of allPoints) {
const px = pt.pos[0] - originX;
const py = pt.pos[1] - originY;
points.push([px, py]);
pressures.push(pt.pressure);
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
const el = makeBaseElement("freedraw", originX, originY, maxX - minX, maxY - minY);
el.points = points;
el.pressures = pressures;
el.simulatePressure = false;
el.strokeColor = convertColor(style.stroke_color);
el.strokeWidth = Math.max(1, Math.floor((style.stroke_width || 1) / 2));
el.opacity = convertOpacity(style.stroke_color);
el.lastCommittedPoint = points[points.length - 1] || null;
return el;
}
function convertTextStroke(text: RnoteTextStroke) {
if (!text.text) return null;
// Extract position from affine transform
// v0.13: nested arrays [[1,0,tx],[0,1,ty],[0,0,1]]
// v0.14: flat array [1,0,0, 0,1,0, tx,ty,1]
const affine = text.transform?.affine;
let x = 0, y = 0;
if (Array.isArray(affine?.[0])) {
// nested: row-major 3x3
const nested = affine as number[][];
x = nested[0]?.[2] ?? 0;
y = nested[1]?.[2] ?? 0;
} else if (affine) {
// flat 9-element array (column-major: [m00,m10,m20, m01,m11,m21, tx,ty,1])
const flat = affine as number[];
x = flat[6] ?? 0;
y = flat[7] ?? 0;
}
const ts = text.text_style ?? text.style;
const size = ts?.font_size ?? 12;
const color = ts?.color ?? { r: 0, g: 0, b: 0, a: 1 };
const lines = text.text.split("\n");
const estWidth = Math.max(...lines.map((l) => l.length)) * size * 0.6;
const estHeight = lines.length * size * 1.3;
const el = makeBaseElement("text", x, y, estWidth, estHeight);
el.text = text.text;
el.fontSize = size;
el.fontFamily = 5;
el.textAlign = "left";
el.verticalAlign = "top";
el.strokeColor = convertColor(color);
el.opacity = convertOpacity(color);
el.containerId = null;
el.originalText = text.text;
el.autoResize = true;
el.lineHeight = 1.25;
return el;
}
function convertBitmapImage(img: RnoteBitmapImage, files: Record<string, ExcalidrawFile>) {
if (!img.image) return null;
// Extract base64 image data and encode as PNG if needed
let dataURL: string;
if (typeof img.image === "string") {
// v0.13: already PNG base64
dataURL = `data:image/png;base64,${img.image}`;
} else {
// v0.14: raw RGBA pixels, need to encode as PNG
const rawBuf = Buffer.from(img.image.data, "base64");
const pw = img.image.pixel_width ?? 0;
const ph = img.image.pixel_height ?? 0;
if (!pw || !ph) return null;
const pngBuf = encodeRawRgbaToPng(rawBuf, pw, ph);
dataURL = `data:image/png;base64,${pngBuf.toString("base64")}`;
}
// Extract bounds: v0.13 uses mins/maxs, v0.14 uses cuboid + transform
let minX: number, minY: number, w: number, h: number;
if ("mins" in img.rectangle) {
[minX, minY] = img.rectangle.mins;
const [maxX, maxY] = img.rectangle.maxs;
w = maxX - minX;
h = maxY - minY;
} else {
const [halfW, halfH] = img.rectangle.cuboid.half_extents;
const affine = img.rectangle.transform.affine;
// flat affine: tx=affine[6], ty=affine[7]
const cx = affine[6] ?? 0;
const cy = affine[7] ?? 0;
minX = cx - halfW;
minY = cy - halfH;
w = halfW * 2;
h = halfH * 2;
}
const fileId = generateId();
files[fileId] = {
mimeType: "image/png",
id: fileId,
dataURL,
created: Date.now(),
lastRetrieved: Date.now(),
};
const el = makeBaseElement("image", minX, minY, w, h);
el.fileId = fileId;
el.status = "saved";
el.scale = [1, 1];
return el;
}
// ── Main conversion ─────────────────────────────────────────────────────────
function convertRnote(doc: RnoteFile) {
const snapshot = doc.data?.engine_snapshot;
if (!snapshot) throw new Error("Not a valid .rnote file: missing engine_snapshot");
const elements: Record<string, unknown>[] = [];
const files: Record<string, ExcalidrawFile> = {};
for (const entry of snapshot.stroke_components) {
if (!entry.value) continue;
if (entry.value.brushstroke) {
const el = convertBrushStroke(entry.value.brushstroke);
if (el) elements.push(el);
} else if (entry.value.textstroke) {
const el = convertTextStroke(entry.value.textstroke);
if (el) elements.push(el);
} else if (entry.value.bitmapimage) {
const el = convertBitmapImage(entry.value.bitmapimage, files);
if (el) elements.push(el);
}
}
return {
type: "excalidraw",
version: 2,
elements,
files,
appState: {
gridModeEnabled: false,
viewBackgroundColor: "#ffffff",
},
};
}
// ── Entry point ─────────────────────────────────────────────────────────────
const scriptNote = api.currentNote;
const childNotes = scriptNote.getChildNotes();
for (const note of childNotes) {
if (note.type !== "file" || !note.title?.endsWith(".rnote")) {
continue;
}
try {
const content = note.getContent();
const jsonBuffer = zlib.gunzipSync(content);
const doc: RnoteFile = JSON.parse(jsonBuffer.toString("utf-8"));
const excalidrawData = convertRnote(doc);
const title = note.title.replace(/\.rnote$/, "");
const { note: canvasNote } = api.createNewNote({
parentNoteId: scriptNote.noteId,
title,
content: JSON.stringify(excalidrawData),
type: "canvas",
});
api.log(`auto-import-rnote: converted "${note.title}" → canvas note ${canvasNote.noteId}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
api.log(`auto-import-rnote: failed for "${note.title}": ${msg}`);
}
}
@@ -0,0 +1,324 @@
/**
* @trilium-script
*
* id: auto-import-xopp
* type: backend
* title: Auto-import XOPP to Canvas
* executeButton: true
* executeDescription: Converts all .xopp children of this note into Excalidraw canvas notes.
*/
import zlib from "zlib";
// ── Color mapping ────────────────────────────────────────────────────────────
const XOPP_COLORS: Record<string, string> = {
black: "#000000",
blue: "#3333cc",
red: "#ff0000",
green: "#008000",
gray: "#808080",
lightblue: "#00c0ff",
lightgreen: "#00ff00",
magenta: "#ff00ff",
orange: "#ff8000",
yellow: "#ffff00",
white: "#ffffff",
};
function convertColor(color: string | null): string {
if (!color) return "#000000";
const lower = color.toLowerCase();
if (XOPP_COLORS[lower]) return XOPP_COLORS[lower];
if (lower.startsWith("#")) {
return lower.length > 7 ? lower.slice(0, 7) : lower;
}
return "#000000";
}
function extractOpacity(color: string | null): number {
if (!color) return 100;
if (color.startsWith("#") && color.length === 9) {
const alpha = parseInt(color.slice(7, 9), 16);
return Math.round((alpha / 255) * 100);
}
return 100;
}
// ── Excalidraw element helpers ───────────────────────────────────────────────
function generateId(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let id = "";
for (let i = 0; i < 20; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
function makeBaseElement(type: string, x: number, y: number, width: number, height: number) {
return {
id: generateId(),
type,
x,
y,
width,
height,
angle: 0,
strokeColor: "#000000",
backgroundColor: "transparent",
fillStyle: "solid",
strokeWidth: 1,
strokeStyle: "solid",
roughness: 0,
opacity: 100,
groupIds: [] as string[],
frameId: null,
index: null,
roundness: null,
seed: Math.floor(Math.random() * 2_000_000_000),
version: 1,
versionNonce: Math.floor(Math.random() * 2_000_000_000),
isDeleted: false,
boundElements: null,
updated: Date.now(),
link: null,
locked: false,
} as Record<string, unknown>;
}
// ── xml2js element accessors ─────────────────────────────────────────────────
/** Get attribute from an xml2js node. */
function attr(node: Record<string, unknown>, name: string): string | null {
const attrs = node.$ as Record<string, string> | undefined;
return attrs?.[name] ?? null;
}
/** Get text content from an xml2js node. */
function text(node: Record<string, unknown>): string {
return (node._ as string | undefined)?.trim() ?? "";
}
/** Get child elements by tag name, always as array. */
function children(node: Record<string, unknown>, tag: string): Record<string, unknown>[] {
const val = node[tag];
if (!val) return [];
return Array.isArray(val) ? val : [val as Record<string, unknown>];
}
// ── Converters ───────────────────────────────────────────────────────────────
interface ExcalidrawFile {
mimeType: string;
id: string;
dataURL: string;
created: number;
lastRetrieved: number;
}
function convertStroke(strokeNode: Record<string, unknown>, yOffset: number) {
const coordText = text(strokeNode);
const coords = coordText.split(/\s+/).map(Number);
if (coords.length < 4) return null;
const originX = coords[0];
const originY = coords[1] + yOffset;
const points: number[][] = [];
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (let i = 0; i < coords.length; i += 2) {
const px = coords[i] - originX;
const py = (coords[i + 1] + yOffset) - originY;
points.push([px, py]);
minX = Math.min(minX, px);
minY = Math.min(minY, py);
maxX = Math.max(maxX, px);
maxY = Math.max(maxY, py);
}
const widthAttr = attr(strokeNode, "width") || "2";
const widths = widthAttr.trim().split(/\s+/).map(Number);
const hasPressure = widths.length > 1;
let pressures: number[] = [];
if (hasPressure) {
const maxW = Math.max(...widths);
pressures = widths.map((w) => maxW > 0 ? w / maxW : 0.5);
while (pressures.length < points.length) {
pressures.push(pressures[pressures.length - 1] || 0.5);
}
pressures = pressures.slice(0, points.length);
}
const color = attr(strokeNode, "color") || "black";
const tool = attr(strokeNode, "tool") || "pen";
const isHighlighter = tool === "highlighter";
const el = makeBaseElement("freedraw", originX, originY, maxX - minX, maxY - minY);
el.points = points;
el.pressures = hasPressure ? pressures : [];
el.simulatePressure = !hasPressure;
el.strokeColor = convertColor(color);
el.strokeWidth = Math.floor(widths[0] || 1);
el.opacity = isHighlighter ? 40 : extractOpacity(color);
el.lastCommittedPoint = points[points.length - 1] || null;
return el;
}
function convertText(textNode: Record<string, unknown>, yOffset: number) {
const x = parseFloat(attr(textNode, "x") ?? "0");
const y = parseFloat(attr(textNode, "y") ?? "0") + yOffset;
const size = parseFloat(attr(textNode, "size") ?? "12");
const color = attr(textNode, "color") || "black";
const content = text(textNode);
if (!content) return null;
const lines = content.split("\n");
const estWidth = Math.max(...lines.map((l) => l.length)) * size * 0.6;
const estHeight = lines.length * size * 1.3;
const el = makeBaseElement("text", x, y, estWidth, estHeight);
el.text = content;
el.fontSize = size;
el.fontFamily = 5;
el.textAlign = "left";
el.verticalAlign = "top";
el.strokeColor = convertColor(color);
el.opacity = extractOpacity(color);
el.containerId = null;
el.originalText = content;
el.autoResize = true;
el.lineHeight = 1.25;
return el;
}
function convertImage(
imageNode: Record<string, unknown>,
yOffset: number,
files: Record<string, ExcalidrawFile>,
) {
const left = parseFloat(attr(imageNode, "left") ?? "");
const top = parseFloat(attr(imageNode, "top") ?? "") + yOffset;
const right = parseFloat(attr(imageNode, "right") ?? "");
const bottom = parseFloat(attr(imageNode, "bottom") ?? "") + yOffset;
if (isNaN(left) || isNaN(top) || isNaN(right) || isNaN(bottom)) return null;
const imgData = text(imageNode);
if (!imgData) return null;
const fileId = generateId();
files[fileId] = {
mimeType: "image/png",
id: fileId,
dataURL: `data:image/png;base64,${imgData}`,
created: Date.now(),
lastRetrieved: Date.now(),
};
const el = makeBaseElement("image", left, top, right - left, bottom - top);
el.fileId = fileId;
el.status = "saved";
el.scale = [1, 1];
return el;
}
// ── Main conversion ──────────────────────────────────────────────────────────
function convertXoppXml(doc: Record<string, unknown>) {
const xournal = doc.xournal as Record<string, unknown> | undefined;
if (!xournal) throw new Error("Not a valid .xopp file: missing <xournal> root element");
const pages = children(xournal, "page");
const elements: Record<string, unknown>[] = [];
const files: Record<string, ExcalidrawFile> = {};
let yOffset = 0;
const PAGE_GAP = 80;
for (const page of pages) {
const pageHeight = parseFloat(attr(page, "height") ?? "792");
const pageWidth = parseFloat(attr(page, "width") ?? "612");
// Page boundary rectangle
const bgRect = makeBaseElement("rectangle", 0, yOffset, pageWidth, pageHeight);
bgRect.strokeColor = "#d0d0d0";
bgRect.strokeWidth = 1;
bgRect.strokeStyle = "dashed";
bgRect.backgroundColor = "transparent";
bgRect.opacity = 50;
elements.push(bgRect);
const layers = children(page, "layer");
for (const layer of layers) {
for (const stroke of children(layer, "stroke")) {
const el = convertStroke(stroke, yOffset);
if (el) elements.push(el);
}
for (const txt of children(layer, "text")) {
const el = convertText(txt, yOffset);
if (el) elements.push(el);
}
for (const img of children(layer, "image")) {
const el = convertImage(img, yOffset, files);
if (el) elements.push(el);
}
}
yOffset += pageHeight + PAGE_GAP;
}
return {
type: "excalidraw",
version: 2,
elements,
files,
appState: {
gridModeEnabled: false,
viewBackgroundColor: "#ffffff",
},
};
}
// ── Entry point (manually run) ───────────────────────────────────────────────
const scriptNote = api.currentNote;
const childNotes = scriptNote.getChildNotes();
for (const note of childNotes) {
if (note.type !== "file" || !note.title?.endsWith(".xopp")) {
continue;
}
const content = note.getContent();
const xmlBuffer = zlib.gunzipSync(content);
const xmlString = xmlBuffer.toString("utf-8");
api.xml2js.parseString(xmlString, (err: Error | null, result: Record<string, unknown>) => {
if (err) {
api.log(`auto-import-xopp: failed to parse XML for "${note.title}": ${err.message}`);
return;
}
try {
const excalidrawData = convertXoppXml(result);
const title = note.title.replace(/\.xopp$/, "");
const { note: canvasNote } = api.createNewNote({
parentNoteId: scriptNote.noteId,
title,
content: JSON.stringify(excalidrawData),
type: "canvas",
});
api.log(`auto-import-xopp: converted "${note.title}" → canvas note ${canvasNote.noteId}`);
} catch (convErr) {
const msg = convErr instanceof Error ? convErr.message : String(convErr);
api.log(`auto-import-xopp: conversion failed for "${note.title}": ${msg}`);
}
});
}
+386
View File
@@ -0,0 +1,386 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import becca from "@triliumnext/server/src/becca/becca";
import BBranch from "@triliumnext/server/src/becca/entities/bbranch";
import { buildNote } from "@triliumnext/server/src/test/becca_easy_mocking";
import {
parseScriptMeta,
transpile,
deployScript,
codeNoteId,
renderNoteId,
SCRIPTS_NOTE_ID,
type NoteService,
} from "./deploy";
// ── Mocks — only SQL and entity_changes, becca is real ───────────────────────
vi.mock("@triliumnext/server/src/services/sql", () => ({
default: {
transactional: (cb: Function) => cb(),
upsert: () => {},
execute: () => {},
replace: () => {},
getMap: () => {},
getValue: () => null,
},
}));
vi.mock("@triliumnext/server/src/services/sql_init", () => ({
dbReady: () => {},
}));
vi.mock("@triliumnext/server/src/services/entity_changes", () => ({
default: { putEntityChange: () => {} },
putEntityChange: () => {},
}));
// ── parseScriptMeta ──────────────────────────────────────────────────────────
describe("parseScriptMeta", () => {
it("parses a valid @trilium-script block", () => {
const source = `/**
* @trilium-script
*
* id: my-script
* type: render
* title: My Script
*/
export default function() {}`;
const meta = parseScriptMeta(source, "my-script.jsx");
expect(meta).toEqual({
id: "my-script",
type: "render",
title: "My Script",
});
});
it("returns null when @trilium-script marker is missing", () => {
const source = `/** Just a normal comment */\nexport default function() {}`;
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
});
it("returns null and logs error when id is missing", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const source = `/**
* @trilium-script
*
* type: render
* title: My Script
*/`;
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: id"));
spy.mockRestore();
});
it("returns null when type is missing", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const source = `/**
* @trilium-script
*
* id: test
* title: Test
*/`;
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: type"));
spy.mockRestore();
});
it("returns null when title is missing", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
const source = `/**
* @trilium-script
*
* id: test
* type: code
*/`;
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: title"));
spy.mockRestore();
});
it("preserves extra metadata fields", () => {
const source = `/**
* @trilium-script
*
* id: test
* type: render
* title: Test
* icon: bx-rocket
*/`;
const meta = parseScriptMeta(source, "test.jsx");
expect(meta).toMatchObject({ icon: "bx-rocket" });
});
});
// ── transpile ────────────────────────────────────────────────────────────────
describe("transpile", () => {
it("passes .jsx files through unchanged", () => {
const source = `const x: number = 1; return <div>{x}</div>;`;
expect(transpile(source, "test.jsx")).toBe(source);
});
it("strips type annotations from .tsx but preserves JSX", () => {
const source = `const x: number = 1;\nconst el = <div>{x}</div>;`;
const result = transpile(source, "test.tsx");
expect(result).not.toContain(": number");
expect(result).toContain("<div>");
});
it("strips type annotations from .ts", () => {
const source = `const x: number = 1;`;
const result = transpile(source, "test.ts");
expect(result).not.toContain(": number");
expect(result).toContain("const x = 1");
});
it("passes .js files through unchanged", () => {
const source = `const x = 1;`;
expect(transpile(source, "test.js")).toBe(source);
});
});
// ── deployScript ─────────────────────────────────────────────────────────────
describe("deployScript", () => {
/**
* A NoteService backed by buildNote — each createNewNote call
* populates the real becca cache via becca_easy_mocking.
*/
const notesService: NoteService = {
createNewNote(params) {
const note = buildNote({
id: params.noteId,
title: params.title,
type: params.type as Parameters<typeof buildNote>[0]["type"],
mime: params.mime ?? "text/html",
content: params.content,
});
// buildNote doesn't create a branch to the parent, so add one.
new BBranch({
branchId: `${params.parentNoteId}_${params.noteId}`,
noteId: params.noteId,
parentNoteId: params.parentNoteId,
});
return { note: { noteId: note.noteId } };
},
};
beforeEach(() => {
becca.reset();
// Root + Scripts folder, using buildNote for the tree structure.
buildNote({
id: "root",
title: "root",
children: [
{ id: SCRIPTS_NOTE_ID, title: "Scripts", type: "doc" },
],
});
});
describe("render note with JSX", () => {
const meta = { id: "xopp-importer", type: "render", title: "XOPP Importer" };
const jsxContent = `import { h } from "trilium:preact";\nexport default function() { return <div>Hello</div>; }`;
const mime = "text/jsx";
it("creates a render note as parent with code note child linked via ~renderNote", () => {
deployScript(meta, jsxContent, mime, becca, notesService);
const renderId = renderNoteId(meta.id);
const codeId = codeNoteId(meta.id);
const renderNote = becca.notes[renderId];
const codeNote = becca.notes[codeId];
// Render note exists with correct type/title
expect(renderNote).toBeDefined();
expect(renderNote.type).toBe("render");
expect(renderNote.title).toBe("XOPP Importer");
// Code note exists with correct type/title
expect(codeNote).toBeDefined();
expect(codeNote.type).toBe("code");
expect(codeNote.title).toBe("XOPP Importer");
// Code note is a child of the render note
expect(codeNote.getParentBranches().some((b) => b.parentNoteId === renderId)).toBe(true);
// Render note is under scripts folder
expect(renderNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true);
// ~renderNote relation points to the code note
const relation = renderNote.getRelations().find((a) => a.name === "renderNote");
expect(relation).toBeDefined();
expect(relation!.value).toBe(codeId);
});
it("returns a 'created' result with both note IDs", () => {
const result = deployScript(meta, jsxContent, mime, becca, notesService);
expect(result).toEqual({
action: "created",
codeNoteId: codeNoteId(meta.id),
renderNoteId: renderNoteId(meta.id),
title: "XOPP Importer",
type: "render",
});
});
});
describe("render note with TSX", () => {
const meta = { id: "tsx-widget", type: "render", title: "TSX Widget" };
const tsxSource = `const x: number = 1;\nexport default function() { return <div>{x}</div>; }`;
const transpiledContent = transpile(tsxSource, "tsx-widget.tsx");
const mime = "text/jsx";
it("creates the same render+code structure as JSX, with types stripped but JSX preserved", () => {
// Content was correctly transpiled
expect(transpiledContent).not.toContain(": number");
expect(transpiledContent).toContain("<div>");
deployScript(meta, transpiledContent, mime, becca, notesService);
const renderId = renderNoteId(meta.id);
const codeId = codeNoteId(meta.id);
expect(becca.notes[renderId]).toBeDefined();
expect(becca.notes[renderId].type).toBe("render");
expect(becca.notes[codeId]).toBeDefined();
expect(becca.notes[codeId].type).toBe("code");
});
});
describe("updating an existing script", () => {
const meta = { id: "existing-script", type: "render", title: "Original" };
const mime = "text/jsx";
it("updates content and title of existing code note", () => {
deployScript(meta, "old content", mime, becca, notesService);
const codeId = codeNoteId(meta.id);
const note = becca.notes[codeId];
const saveSpy = vi.fn();
const setContentSpy = vi.fn();
note.save = saveSpy;
note.setContent = setContentSpy;
const result = deployScript({ ...meta, title: "New Title" }, "new content", mime, becca, notesService);
expect(result.action).toBe("updated");
expect(note.title).toBe("New Title");
expect(saveSpy).toHaveBeenCalled();
expect(setContentSpy).toHaveBeenCalledWith("new content");
});
});
describe("plain code note (non-render)", () => {
const meta = { id: "backend-helper", type: "code", title: "Backend Helper" };
const content = "module.exports = {};";
const mime = "application/javascript;env=backend";
it("creates a single code note under scripts folder, no render note", () => {
deployScript(meta, content, mime, becca, notesService);
const codeId = codeNoteId(meta.id);
const codeNote = becca.notes[codeId];
expect(codeNote).toBeDefined();
expect(codeNote.type).toBe("code");
expect(codeNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true);
// No render note was created
expect(becca.notes[renderNoteId(meta.id)]).toBeUndefined();
});
it("returns a 'created' result without renderNoteId", () => {
const result = deployScript(meta, content, mime, becca, notesService);
expect(result).toEqual({
action: "created",
codeNoteId: codeNoteId(meta.id),
title: "Backend Helper",
type: "code",
});
});
});
describe("backend script", () => {
const meta = { id: "hello-world", type: "backend", run: "backendStartup", title: "Hello World" };
const content = `api.log("Hello from script-deployer!");`;
const mime = "application/javascript;env=backend";
it("creates a code note with #run label", () => {
deployScript(meta, content, mime, becca, notesService);
const codeId = codeNoteId(meta.id);
const codeNote = becca.notes[codeId];
expect(codeNote).toBeDefined();
expect(codeNote.type).toBe("code");
const runLabel = codeNote.getLabels().find((a) => a.name === "run");
expect(runLabel).toBeDefined();
expect(runLabel!.value).toBe("backendStartup");
});
it("places the code note under scripts folder, no render note", () => {
deployScript(meta, content, mime, becca, notesService);
const codeId = codeNoteId(meta.id);
const codeNote = becca.notes[codeId];
expect(codeNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true);
expect(becca.notes[renderNoteId(meta.id)]).toBeUndefined();
});
it("does not set #run when run field is absent", () => {
const metaNoRun = { id: "no-run", type: "backend", title: "No Run" };
deployScript(metaNoRun, content, mime, becca, notesService);
const codeNote = becca.notes[codeNoteId(metaNoRun.id)];
expect(codeNote.getLabels().find((a) => a.name === "run")).toBeUndefined();
});
});
describe("execute labels", () => {
const content = `api.log("test");`;
const mime = "application/javascript;env=backend";
it("sets executeButton, executeDescription, and executeTitle on creation", () => {
const meta = {
id: "exec-test",
type: "backend",
title: "Exec Test",
executeButton: "true",
executeDescription: "Does something useful",
executeTitle: "Run It",
};
deployScript(meta, content, mime, becca, notesService);
const codeNote = becca.notes[codeNoteId(meta.id)];
const labels = codeNote.getLabels();
expect(labels.find((a) => a.name === "executeButton")?.value).toBe("true");
expect(labels.find((a) => a.name === "executeDescription")?.value).toBe("Does something useful");
expect(labels.find((a) => a.name === "executeTitle")?.value).toBe("Run It");
});
it("sets execute labels on update of an existing note", () => {
const meta = { id: "exec-update", type: "backend", title: "Before" };
deployScript(meta, content, mime, becca, notesService);
const updated = { ...meta, title: "After", executeButton: "true", executeDescription: "Now executable" };
const result = deployScript(updated, content, mime, becca, notesService);
expect(result.action).toBe("updated");
const labels = becca.notes[codeNoteId(meta.id)].getLabels();
expect(labels.find((a) => a.name === "executeButton")?.value).toBe("true");
expect(labels.find((a) => a.name === "executeDescription")?.value).toBe("Now executable");
});
it("does not set execute labels when absent from metadata", () => {
const meta = { id: "no-exec", type: "backend", title: "Plain" };
deployScript(meta, content, mime, becca, notesService);
const labels = becca.notes[codeNoteId(meta.id)].getLabels();
expect(labels.find((a) => a.name === "executeButton")).toBeUndefined();
expect(labels.find((a) => a.name === "executeDescription")).toBeUndefined();
});
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* Core deployment logic for Trilium scripts.
*
* Extracted from dev.ts so it can be tested without the full server lifecycle.
*/
import { extname } from "node:path";
import { transformSync } from "esbuild";
// ── Public constants ─────────────────────────────────────────────────────────
export const SCRIPTS_NOTE_ID = "_scripts";
export const MIME_BY_EXT: Record<string, string> = {
".jsx": "text/jsx",
".tsx": "text/jsx",
".js": "application/javascript;env=frontend",
".ts": "application/javascript;env=backend",
".html": "text/html",
".css": "text/css",
};
// ── Front-matter parsing ─────────────────────────────────────────────────────
export interface ScriptMeta {
id: string;
type: string;
title: string;
[key: string]: string;
}
/**
* Parses the `@trilium-script` YAML front matter from a JSDoc comment block.
*
* ```
* /**
* * @trilium-script
* *
* * id: my-script
* * type: render
* * title: My Script
* *\/
* ```
*
* Returns `null` (with a console error) when the marker is missing or
* required fields (`id`, `type`, `title`) are absent.
*/
export function parseScriptMeta(source: string, filePath: string): ScriptMeta | null {
const match = source.match(/\/\*\*[\s\S]*?@trilium-script\s*([\s\S]*?)\*\//);
if (!match) return null;
const block = match[1];
const meta: Record<string, string> = {};
for (const line of block.split("\n")) {
const cleaned = line.replace(/^\s*\*\s?/, "").trim();
if (!cleaned) continue;
const colon = cleaned.indexOf(":");
if (colon === -1) continue;
const key = cleaned.slice(0, colon).trim();
const value = cleaned.slice(colon + 1).trim();
if (key && value) meta[key] = value;
}
const missing = ["id", "type", "title"].filter((k) => !meta[k]);
if (missing.length) {
console.error(` SKIP ${filePath}: missing required fields: ${missing.join(", ")}`);
return null;
}
return meta as ScriptMeta;
}
// ── Transpilation ────────────────────────────────────────────────────────────
/** Extensions that need esbuild transpilation before deployment. */
const TRANSPILE_EXTS = new Set([".ts", ".tsx"]);
/**
* Strips TypeScript type annotations via esbuild, preserving JSX intact.
* Trilium's frontend handles JSX natively, so we only strip types.
*/
export function transpile(source: string, filePath: string): string {
const ext = extname(filePath);
if (!TRANSPILE_EXTS.has(ext)) return source;
const isBackend = ext === ".ts";
const result = transformSync(source, {
loader: ext === ".tsx" ? "tsx" : "ts",
jsx: "preserve",
sourcemap: false,
...(isBackend && { format: "cjs" }),
});
return result.code;
}
// ── Note ID helpers ──────────────────────────────────────────────────────────
export function codeNoteId(scriptId: string) {
return `_sd_${scriptId}`;
}
export function renderNoteId(scriptId: string) {
return `_sd_${scriptId}_render`;
}
// ── Deployment ───────────────────────────────────────────────────────────────
/**
* Abstraction over the note-creation service so that tests can supply
* a mock while the real dev server passes the actual `notesService`.
*/
export interface NoteService {
createNewNote(params: {
noteId: string;
parentNoteId: string;
title: string;
type: string; // NoteType union at runtime, kept as string for test compatibility
mime?: string;
content: string;
}): { note: { noteId: string } };
}
/** Helper to cast the real notesService (which uses NoteType) to our interface. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function asNoteService(svc: any): NoteService {
return svc as NoteService;
}
export interface BeccaLike {
notes: Record<string, {
noteId: string;
title: string;
save(): void;
setContent(content: string): void;
setRelation(name: string, targetNoteId: string): void;
setLabel(name: string, value?: string): void;
invalidateThisCache(): void;
}>;
}
export interface DeployResult {
action: "created" | "updated";
codeNoteId: string;
renderNoteId?: string;
title: string;
type: string;
}
/** Labels that map directly from front-matter keys to note attributes. */
const PASSTHROUGH_LABELS = ["run", "executeButton", "executeDescription", "executeTitle"] as const;
function applyLabels(note: BeccaLike["notes"][string], meta: ScriptMeta) {
note.setLabel("readOnly");
for (const label of PASSTHROUGH_LABELS) {
if (meta[label]) {
note.setLabel(label, meta[label]);
}
}
// BNote caches attributes eagerly during setLabel's save() path.
// Invalidate so subsequent reads see all labels we just set.
note.invalidateThisCache();
}
/**
* Deploys a single script file into the Trilium note tree.
*
* - For `type: render`: creates a render note with a child code note
* linked via `~renderNote`.
* - For other types: creates a plain code note under the scripts folder.
* - If the code note already exists, only its content and title are updated.
*/
export function deployScript(
meta: ScriptMeta,
content: string,
mime: string,
becca: BeccaLike,
notesService: NoteService,
parentNoteId: string = SCRIPTS_NOTE_ID,
): DeployResult {
const codeId = codeNoteId(meta.id);
const existing = becca.notes[codeId];
if (existing) {
existing.title = meta.title;
existing.save();
existing.setContent(content);
applyLabels(existing, meta);
return { action: "updated", codeNoteId: codeId, title: meta.title, type: meta.type };
}
if (meta.type === "render") {
const renderId = renderNoteId(meta.id);
notesService.createNewNote({
noteId: renderId,
parentNoteId,
title: meta.title,
type: "render",
content: "",
});
notesService.createNewNote({
noteId: codeId,
parentNoteId: renderId,
title: meta.title,
type: "code",
mime,
content,
});
const renderNote = becca.notes[renderId];
renderNote.setRelation("renderNote", codeId);
return { action: "created", codeNoteId: codeId, renderNoteId: renderId, title: meta.title, type: meta.type };
}
// Plain code note (backend script, widget, etc.)
notesService.createNewNote({
noteId: codeId,
parentNoteId,
title: meta.title,
type: "code",
mime,
content,
});
applyLabels(becca.notes[codeId], meta);
return { action: "created", codeNoteId: codeId, title: meta.title, type: meta.type };
}
+212
View File
@@ -0,0 +1,212 @@
/**
* Starts a dedicated Trilium instance for script development.
*
* On first run it initializes a fresh database (no demo content),
* creates an ETAPI token and persists it to data/.etapi-token.
* On subsequent runs it just boots the server.
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, watch, writeFileSync } from "node:fs";
import { extname,join, resolve } from "node:path";
import { asNoteService,codeNoteId, deployScript, MIME_BY_EXT, parseScriptMeta, renderNoteId, SCRIPTS_NOTE_ID, transpile } from "./deploy";
// ── Environment — must be set before any server module is imported ──────────
const DATA_DIR = resolve(__dirname, "../data");
const TOKEN_PATH = join(DATA_DIR, ".etapi-token");
const PORT = 37842;
mkdirSync(DATA_DIR, { recursive: true });
process.env.TRILIUM_DATA_DIR = DATA_DIR;
process.env.TRILIUM_PORT = String(PORT);
process.env.TRILIUM_RESOURCE_DIR = resolve(__dirname, "../../server/src");
process.env.NODE_ENV = "development";
process.env.TRILIUM_ENV = "dev";
// ── Constants ───────────────────────────────────────────────────────────────
const SCRIPTS_DIR = resolve(__dirname, "../scripts");
const needsInit = !existsSync(join(DATA_DIR, "document.db"));
async function ensureTranslations() {
const i18n = await import("@triliumnext/server/src/services/i18n.js");
await i18n.initializeTranslations();
}
async function ensureDatabase() {
if (!needsInit) return;
console.log("No database found — creating a fresh instance…");
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
const sqlInit = (await import("@triliumnext/server/src/services/sql_init.js")).default;
// createInitialDatabase must run inside CLS (it touches becca).
await cls.init(async () => {
await sqlInit.createInitialDatabase(/* skipDemoDb */ true);
});
console.log("Database created.");
}
async function ensureEtapiToken() {
if (existsSync(TOKEN_PATH)) {
const token = readFileSync(TOKEN_PATH, "utf-8").trim();
console.log(`ETAPI token: ${token}`);
return;
}
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
const etapiTokens = (await import("@triliumnext/server/src/services/etapi_tokens.js")).default;
const authToken: string = cls.init(() => {
const { authToken } = etapiTokens.createToken("script-deployer");
return authToken;
});
writeFileSync(TOKEN_PATH, `${authToken }\n`);
console.log(`ETAPI token created and saved to ${TOKEN_PATH}`);
console.log(`ETAPI token: ${authToken}`);
}
async function ensureScriptsFolder() {
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
if (becca.notes[SCRIPTS_NOTE_ID]) return;
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
const notesService = (await import("@triliumnext/server/src/services/notes.js")).default;
cls.init(() => {
notesService.createNewNote({
noteId: SCRIPTS_NOTE_ID,
parentNoteId: "root",
title: "Scripts",
type: "doc",
content: "",
});
});
console.log("Created 'Scripts' folder note.");
}
async function deployScripts() {
const files = readdirSync(SCRIPTS_DIR).filter((f) => MIME_BY_EXT[extname(f)]);
if (!files.length) {
console.log("No scripts to deploy.");
return;
}
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
const notesService = (await import("@triliumnext/server/src/services/notes.js")).default;
console.log(`Deploying ${files.length} script(s)…`);
for (const file of files) {
const filePath = join(SCRIPTS_DIR, file);
const source = readFileSync(filePath, "utf-8");
const meta = parseScriptMeta(source, file);
if (!meta) continue;
const mime = MIME_BY_EXT[extname(file)]!;
const content = transpile(source, file);
cls.init(() => {
const result = deployScript(meta, content, mime, becca, asNoteService(notesService));
console.log(` ${result.action === "created" ? "Created" : "Updated"}: ${result.title} (${result.codeNoteId})`);
});
}
}
function watchScripts() {
// Debounce per file — editors can fire multiple events on a single save.
const timers = new Map<string, ReturnType<typeof setTimeout>>();
watch(SCRIPTS_DIR, (_eventType, filename) => {
if (!filename || !MIME_BY_EXT[extname(filename)]) return;
if (timers.has(filename)) clearTimeout(timers.get(filename));
timers.set(filename, setTimeout(() => {
timers.delete(filename);
syncFile(filename);
}, 100));
});
async function syncFile(filename: string) {
const filePath = join(SCRIPTS_DIR, filename);
if (!existsSync(filePath)) return;
const source = readFileSync(filePath, "utf-8");
const meta = parseScriptMeta(source, filename);
if (!meta) return;
const noteId = codeNoteId(meta.id);
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
const note = becca.notes[noteId];
if (!note) {
console.log(` [watch] ${filename}: note ${noteId} not found, restart to create.`);
return;
}
const content = transpile(source, filename);
cls.init(() => {
note.setContent(content);
});
// For render scripts, trigger a refresh of all contexts viewing
// the render note by injecting a client-side script via websocket.
if (meta.type === "render") {
const ws = (await import("@triliumnext/server/src/services/ws.js")).default;
const renderId = renderNoteId(meta.id);
ws.sendMessageToAllClients({
type: "execute-script",
script: `function({ renderId, title }) {
for (const ctx of api.getNoteContexts()) {
if (ctx.noteId === renderId) {
api.triggerEvent("refreshData", { ntxId: ctx.ntxId });
console.log("[script-deployer] refreshed", title, "in context", ctx.ntxId);
}
}
}`,
params: [{
renderId,
title: meta.title
}],
currentNoteId: noteId,
originEntityName: "notes",
originEntityId: noteId,
});
}
console.log(` [watch] Synced: ${meta.title}`);
}
console.log(`Watching ${SCRIPTS_DIR} for changes…`);
}
async function main() {
await ensureTranslations();
await ensureDatabase();
await ensureEtapiToken();
// Start the full HTTP server — this loads becca and makes the note
// tree available for subsequent setup steps.
const startTriliumServer = (await import("@triliumnext/server/src/www.js")).default;
await startTriliumServer();
await ensureScriptsFolder();
await deployScripts();
watchScripts();
console.log(`\nScript-deployer Trilium instance running on http://localhost:${PORT}`);
console.log(`Token file: ${TOKEN_PATH}\n`);
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
+115
View File
@@ -0,0 +1,115 @@
/// <reference types="vite/client" />
/**
* Ambient type declarations for the virtual modules available inside
* Trilium user scripts (`trilium:preact` and `trilium:api`).
*
* These modules don't exist on disk — the server rewrites imports at
* runtime — but providing declarations here gives us editor
* intellisense and `tsc` checking for scripts in the `scripts/` dir.
*
* Types are pulled directly from the real implementations so they
* stay in sync automatically.
*/
type FrontendApi = import("@triliumnext/client/src/services/frontend_script_api").Api;
type PreactApi = typeof import("@triliumnext/client/src/services/frontend_script_api_preact").preactAPI;
/**
* `trilium:api` — destructured members of the frontend script API.
*
* At runtime the server rewrites `require("trilium:api")` to `api`,
* which is the FrontendApi instance. Scripts destructure named
* members from it: `import { runOnBackend, showMessage } from "trilium:api"`.
*/
declare module "trilium:api" {
// Re-export every member of the frontend API as a named export.
export const {
activateNote,
showMessage,
showError,
runOnBackend,
getNote,
getActiveContextNote,
getActiveContextTextEditor,
getActiveContextCodeEditor,
getActiveContextNotePath,
getComponentByEl,
bindGlobalShortcut,
triggerEvent,
triggerCommand,
getNoteContexts,
refreshIncludedNote,
createLink,
startNote,
currentNote,
originEntity,
dayjs,
}: FrontendApi;
}
/**
* `trilium:preact` — destructured members of the Preact API surface.
*
* At runtime the server rewrites `require("trilium:preact")` to
* `api.preact`, which is the frozen preactAPI object. Scripts
* destructure from it: `import { useState, h } from "trilium:preact"`.
*/
declare module "trilium:preact" {
export const {
// Core
h,
Fragment,
createContext,
defineWidget,
defineLauncherWidget,
// Hooks
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
// Built-in components
ActionButton,
Admonition,
Button,
CKEditor,
Collapsible,
Dropdown,
FormCheckbox,
FormDropdownList,
FormFileUploadButton,
FormFileUploadActionButton,
FormGroup,
FormListItem,
FormDropdownDivider,
FormDropdownSubmenu,
FormRadioGroup,
FormText,
FormTextArea,
FormTextBox,
FormToggle,
Icon,
LinkButton,
LoadingSpinner,
Modal,
NoteAutocomplete,
NoteLink,
RawHtml,
Slider,
RightPanelWidget,
}: PreactApi;
}
/**
* Global `api` object available inside `runOnBackend()` callbacks.
* The function body is serialised and executed on the server where
* Trilium injects this as a global.
*/
// eslint-disable-next-line no-var
declare var api: import("@triliumnext/server/src/services/backend_script_api").Api;
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "preserve",
"moduleResolution": "bundler",
"module": "esnext"
},
"include": ["src", "scripts", "../server/src/express.d.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
/// <reference types='vitest' />
import { defineConfig } from "vite";
export default defineConfig(() => ({
root: __dirname,
test: {
watch: false,
globals: true,
environment: "node",
env: {
NODE_ENV: "development",
TRILIUM_DATA_DIR: "./data",
TRILIUM_INTEGRATION_TEST: "memory",
TRILIUM_RESOURCE_DIR: "../server/src",
TRILIUM_ENV: "dev",
},
include: ["src/**/*.{test,spec}.ts"],
testTimeout: 20_000,
},
}));
+7 -7
View File
@@ -31,11 +31,11 @@
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.69",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/google": "3.0.64",
"@ai-sdk/openai": "3.0.53",
"@modelcontextprotocol/sdk": "^1.12.1",
"ai": "6.0.161",
"ai": "6.0.168",
"better-sqlite3": "12.9.0",
"html-to-text": "9.0.5",
"i18next-fs-backend": "2.6.3",
@@ -83,7 +83,7 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.2",
"electron": "41.2.0",
"electron": "41.3.0",
"electron-window-state": "5.0.3",
"express": "5.2.1",
"express-http-proxy": "2.1.2",
@@ -97,13 +97,13 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "9.0.0",
"https-proxy-agent": "9.0.0",
"i18next-fs-backend": "2.6.3",
"i18next-fs-backend": "2.6.4",
"image-type": "6.1.0",
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
"jimp": "1.6.1",
"marked": "18.0.1",
"marked": "18.0.2",
"mime-types": "3.0.2",
"multer": "2.1.1",
"normalize-strings": "1.1.1",
@@ -119,7 +119,7 @@
"time2fa": "1.4.2",
"tmp": "0.2.5",
"turnish": "1.8.0",
"vite": "8.0.8",
"vite": "8.0.9",
"xml2js": "0.6.2",
"yauzl": "3.3.0"
}
@@ -52,8 +52,7 @@
<ul>
<li>Type a few words to filter between commands.</li>
<li>Use the up and down arrows on the keyboard or the mouse to select a command.</li>
<li
>Press <kbd>Enter</kbd> to execute the command.</li>
<li>Press <kbd>Enter</kbd> to execute the command.</li>
</ul>
<p>To exit the command palette:</p>
<ul>
@@ -5,7 +5,7 @@
</figure>
<p>Trilium allows printing notes to a real printer, or generating a structured
PDF for a single note or for multiple notes through&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/_help_GTwFsgaA0lCt">Collections</a>.</p>
href="#root/_help_GTwFsgaA0lCt">Collections</a>.</p>
<p>Note that not all note types are printable as of now. We do plan to increase
the coverage of supported note types in the future.</p>
<p>Printing and exporting as PDF are not perfect. Due to technical limitations,
@@ -20,10 +20,10 @@
<p>On the desktop application of Trilium it is possible to export a note
as PDF. To print a note:</p>
<ul>
<li>Press the menu button in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;area
<li>Press the menu button in the&nbsp;<a class="reference-link" href="#root/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;area
and select <em>Print note</em>.</li>
<li>Alternatively, printing can be triggered from a <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/_help_A9Oc6YKKc65v">keyboard shortcut</a> (unassigned
by default) or through the <a href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/wArbEsdSae6g/_help_F1r9QtzQLZqm">command palette</a>.</li>
<li>Alternatively, printing can be triggered from a <a href="#root/_help_A9Oc6YKKc65v">keyboard shortcut</a> (unassigned
by default) or through the <a href="#root/_help_F1r9QtzQLZqm">command palette</a>.</li>
</ul>
<p>This will trigger the print preview screen next.</p>
<h3>Print preview &amp; print options</h3>
@@ -32,11 +32,9 @@
<li>
<p>The printer to use</p>
<ul>
<li>
<p><em>Save as PDF</em> generates a PDF that is structured (maintains a table
of contents, keeps the text selectable). Prefer this over other virtual
PDF printers that ship with the operating system.</p>
</li>
<li><em>Save as PDF</em> generates a PDF that is structured (maintains a table
of contents, keeps the text selectable). Prefer this over other virtual
PDF printers that ship with the operating system.</li>
</ul>
</li>
<li>
@@ -71,7 +69,7 @@
spellcheck="false">#printScale</code>, <code spellcheck="false">#printMargins</code>).</p>
<p>This means that the print settings will be restored when printing the
same note. There are no default settings that can be configured for all
the notes, but this can be achieved via <a href="#root/pOsGYCXsbNQG/tC7s2alapj8V/zEY4DaJG4YT5/_help_bwZpz2ajCEwO">inheritable attributes</a>.</p>
the notes, but this can be achieved via <a href="#root/_help_bwZpz2ajCEwO">inheritable attributes</a>.</p>
</aside>
<h2>Printing on the browser</h2>
<p>This feature allows printing of notes. It works on both the desktop client,
@@ -116,8 +114,7 @@
<ol>
<li>First create a collection.</li>
<li>Configure it to use&nbsp;<a class="reference-link" href="#root/_help_mULW0Q3VojwY">List View</a>.</li>
<li
>Print the collection note normally.</li>
<li>Print the collection note normally.</li>
</ol>
<p>The resulting collection will contain all the children of the collection,
while maintaining the hierarchy.</p>
@@ -134,9 +131,9 @@
href="#root/_help_4TIF1oA4VQRO">Options</a>&nbsp;and assigning a key combination
for:</p>
<ul>
<li class="ck-list-marker-italic"><em>Print Active Note</em>
<li><em>Print Active Note</em>
</li>
<li class="ck-list-marker-italic"><em>Export Active Note as PDF</em>
<li><em>Export Active Note as PDF</em>
</li>
</ul>
<h2>Constraints &amp; limitations</h2>
@@ -212,13 +209,12 @@
<p>To remark:</p>
<ul>
<li>Multiple CSS notes can be add by using multiple <code spellcheck="false">~printCss</code> relations.</li>
<li
>If the note pointing to the <code spellcheck="false">printCss</code> doesn't
<li>If the note pointing to the <code spellcheck="false">printCss</code> doesn't
have the right note type or mime type, it will be ignored.</li>
<li>If migrating from a previous version where&nbsp;<a class="reference-link"
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a>, there's no need for
<code
spellcheck="false">@media print {</code> since the style-sheet is used only for printing.</li>
<li>If migrating from a previous version where&nbsp;<a class="reference-link"
href="#root/_help_AlhDUqhENtH7">Custom app-wide CSS</a>, there's no need for
<code
spellcheck="false">@media print {</code> since the style-sheet is used only for printing.</li>
</ul>
<h2>Under the hood</h2>
<p>Both printing and exporting as PDF use the same mechanism: a note is rendered
@@ -232,13 +228,7 @@
understand why a particular note doesn't render well. The mechanism for
rendering is similar to the one used in&nbsp;<a class="reference-link"
href="#root/_help_0ESUbbAxVnoK">Note List</a>.</p>
<ol class="footnote-section footnotes"
data-footnote-section="" role="doc-endnotes">
<li class="footnote-item" data-footnote-item="" data-footnote-index="1"
data-footnote-id="sr779u3zm6" role="doc-endnote" id="fnsr779u3zm6"><span class="footnote-back-link" data-footnote-back-link="" data-footnote-id="sr779u3zm6"><sup><strong><a href="#fnrefsr779u3zm6">^</a></strong></sup></span>
<div
class="footnote-content" data-footnote-content="">
<p>&nbsp;</p>
</div>
<ol>
<li><sup><strong><a href="#fnrefsr779u3zm6">^</a></strong></sup>
</li>
</ol>
@@ -54,8 +54,7 @@
hide the Mermaid source code and display the diagram preview in full-size.
In this case, the read-only mode can be easily toggled on or off via a
dedicated button in the&nbsp;<a class="reference-link" href="#root/_help_XpOYSgsLkTJy">Floating buttons</a>&nbsp;area.</li>
<li
><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>&nbsp;will
<li><a class="reference-link" href="#root/_help_81SGnPGMk7Xc">Geo Map</a>&nbsp;will
disallow all interaction that would otherwise change the map (dragging
notes, adding new items).</li>
</ul>
@@ -4,16 +4,15 @@
<p>This can be useful for a few things:</p>
<ul>
<li>computer programmers can store code snippets as notes with syntax highlighting</li>
<li
>JavaScript code notes can be executed inside Trilium for some extra functionality
<li>JavaScript code notes can be executed inside Trilium for some extra functionality
<ul>
<li>we call such JavaScript code notes "scripts" - see&nbsp;<a class="reference-link"
href="#root/_help_CdNpE2pqjmI6">Scripting</a>
</li>
</ul>
</li>
<li>JSON, XML etc. can be used as storage for structured data (typically used
in conjunction with scripting)</li>
</li>
<li>JSON, XML etc. can be used as storage for structured data (typically used
in conjunction with scripting)</li>
</ul>
<p>For shorter snippets of code that can be embedded in&nbsp;<a href="#root/_help_iPIMuisry3hd">Text</a>&nbsp;notes,
see&nbsp;<a href="#root/_help_QxEyIjRBizuC">Code blocks</a>.</p>
@@ -41,26 +40,24 @@
<h2>Word wrap</h2>
<p>Long lines can be displayed on multiple lines:</p>
<ul>
<li>Globally for all code notes, from&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_4TIF1oA4VQRO">Options</a>&nbsp;→ <em>Code Notes.</em>
<li>Globally for all code notes, from&nbsp;<a class="reference-link" href="#root/_help_4TIF1oA4VQRO">Options</a>&nbsp;→ <em>Code Notes.</em>
</li>
<li>For a particular note, by going to the menu in&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;and
selecting <em>Word wrap</em> and selecting the appropriate option:
href="#root/_help_8YBEPzcpUgxw">Note buttons</a>&nbsp;and selecting <em>Word wrap</em> and
selecting the appropriate option:
<ul>
<li><em>Auto</em>, to respect the global word wrap for code notes.</li>
<li
><em>On</em> or <em>Off</em>, to change the state of the word wrap for this
<li><em>On</em> or <em>Off</em>, to change the state of the word wrap for this
note regardless of the global option.</li>
</ul>
</ul>
</li>
</ul>
<h2>Adjusting options using the status bar</h2>
<aside class="admonition note">
<p>This feature is only available for the&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>.
For the old layout, the tab width can be adjusted at note level using the
<code
spellcheck="false">#tabWidth</code>attribute, but re-indentation is not available.</p>
href="#root/_help_IjZS7iK5EXtb">New Layout</a>. For the old layout, the tab width
can be adjusted at note level using the <code spellcheck="false">#tabWidth</code> attribute,
but re-indentation is not available.</p>
</aside>
<p>The status bar at the bottom of the editor shows the current indentation
settings and language. Clicking on the indentation indicator opens a menu
@@ -70,8 +67,7 @@
override is active, a "Reset to default" option appears.</li>
<li><strong>Display Width</strong> — choose from preset widths (1, 2, 3, 4,
6, 8). Changes are saved as a per-note <code spellcheck="false">#tabWidth</code> label.</li>
<li
><strong>Re-indent Content To</strong> — convert existing indentation to
<li><strong>Re-indent Content To</strong> — convert existing indentation to
a different style. For example, re-indent a file from 4 spaces to 2 spaces,
or from spaces to tabs. This rewrites the leading whitespace on every line
while preserving alignment remainders.</li>
@@ -8,6 +8,32 @@
which is a customization of <a href="https://mozilla.github.io/pdf.js/">Mozilla's PDF.js viewer</a> (also
built-in in the Mozilla Firefox browser). Versions prior to that render
PDFs using the browser's default PDF viewer.</p>
<h2>Features</h2>
<ul>
<li>
<p>The last page viewed and scroll position is maintained in between restarts
or note navigations.</p>
</li>
<li>
<p>Annotations (text, highlights) as well as comments. These are saved automatically.</p>
</li>
<li>
<p>Forms can be filled.</p>
</li>
<li>
<p>Can be printed or downloaded.</p>
</li>
<li>
<p>Can be saved as a <a href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_KC1HB96bqqHX">template</a> and
the content of the PDF will be copied over to the new note. This is especially
useful in combination with annotations or filled forms.</p>
</li>
<li>
<p>Integrates with the sidebar in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>,
by displaying the list of pages with thumbnails, table of contents and
a listing of the annotations.</p>
</li>
</ul>
<h2>Storing last position and settings</h2>
<p>For every PDF, Trilium will remember the following information:</p>
<ul>
@@ -29,6 +55,18 @@ class="admonition tip">
annotation buttons on the right side of the PDF toolbar (
<img src="1_PDFs_image.png"
width="120" height="32">).</p>
<p>Since v0.103.0:</p>
<ul>
<li>Annotations are disabled if the note is marked as&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/BFs8mudNFgCS/_help_CoFPLs3dRlXc">Read-Only Notes</a>.</li>
<li
>Comments can also be added, which is similar to highlights but also attach
a text.</li>
<li>The&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_RnaPdbciOfeq">Right Sidebar</a>&nbsp;also
displays a list of annotations (highlights, comments), but only in the&nbsp;
<a
class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>.</li>
</ul>
<h3>Supported annotations</h3>
<p>The following annotation methods are supported:</p>
<ul>
@@ -82,33 +120,43 @@ class="admonition tip">
<li>Table of contents/outline
<ul>
<li>All the headings and “bookmarks” will be displayed hierarchially.</li>
<li>The heading on the current page is also highlighted (note that it can
<li
>The heading on the current page is also highlighted (note that it can
be slightly offset depending on how many headings are on the same page).</li>
<li>Clicking on a heading will jump to the corresponding position in the PDF.</li>
<li
>Clicking on a heading will jump to the corresponding position in the PDF.</li>
</ul>
</li>
<li>Pages
<ul>
<li>A preview of all the pages with a small thumbnail.</li>
<li>Clicking on a page will automatically navigate to that page.</li>
</li>
<li>Pages
<ul>
<li>A preview of all the pages with a small thumbnail.</li>
<li>Clicking on a page will automatically navigate to that page.</li>
</ul>
</li>
<li>Annotations
<ul>
<li>Highlight and comment annotations are listed here.</li>
<li>For the old layout, this feature is not directly available, however there
is a listing of comments directly in the PDF toolbar.</li>
</ul>
</li>
<li>Attachments
<ul>
<li>If the PDF has its own attachments (not to be confused with Trilium's&nbsp;
<a
class="reference-link" href="#root/_help_0vhv7lsOLy82">Attachments</a>), they will be displayed in a list.</li>
<li>Some information such as the name and size of the attachment are displayed.</li>
<li
>It's possible to download the attachment by clicking on the download button.</li>
</ul>
</li>
<li>Attachments
<ul>
<li>If the PDF has its own attachments (not to be confused with Trilium's&nbsp;
<a
class="reference-link" href="#root/_help_0vhv7lsOLy82">Attachments</a>), they will be displayed in a list.</li>
<li>Some information such as the name and size of the attachment are displayed.</li>
<li>It's possible to download the attachment by clicking on the download button.</li>
</ul>
</li>
<li>Layers
<ul>
<li>A less common feature, if the PDF has toggle-able layers, these layers
will be displayed in a list here.</li>
<li>It's possible to toggle the visibility for each individual layer.</li>
</ul>
</li>
</li>
<li>Layers
<ul>
<li>A less common feature, if the PDF has toggle-able layers, these layers
will be displayed in a list here.</li>
<li>It's possible to toggle the visibility for each individual layer.</li>
</ul>
</li>
</ul>
<h2>Share functionality</h2>
<p>PDFs can also be shared using the&nbsp;<a class="reference-link" href="#root/_help_R9pX4DGra2Vt">Sharing</a>&nbsp;feature.
@@ -32,46 +32,63 @@
<p>The following features are supported by Trilium's Markdown format and
will show up in the preview pane:</p>
<ul>
<li>
<p>All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)</p>
</li>
<li>
<p>Code blocks with syntax highlight (e.g. <code spellcheck="false">```js</code>)
and automatic syntax highlight</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>
</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;using
<code
spellcheck="false">```mermaid</code>
</p>
</li>
<li>
<p><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;(no
builtin Markdown syntax, but HTML syntax works just fine):</p><pre><code class="language-text-x-trilium-auto">&lt;section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable"&gt;
<li>All standard and GitHub-flavored syntax (basic formatting, tables, blockquotes)</li>
<li
>Code blocks with syntax highlight. Note that the language must be specified
for syntax highlight to apply (e.g. <code spellcheck="false">```js</code>).</li>
<li
><a class="reference-link" href="#root/_help_NwBbFdNZ9h7O">Block quotes &amp; admonitions</a>
</li>
<li><a class="reference-link" href="#root/_help_YfYAtQBcfo5V">Math Equations</a>&nbsp;(both
inline and block)</li>
<li><a class="reference-link" href="#root/_help_s1aBHPd79XYj">Mermaid Diagrams</a>&nbsp;using
<code
spellcheck="false">```mermaid</code>
</li>
<li>
<p><a class="reference-link" href="#root/_help_nBAXQFj20hS1">Include Note</a>&nbsp;(no
built-in Markdown syntax, but HTML syntax works just fine):</p><pre><code class="language-text-x-trilium-auto">&lt;section class="include-note" data-note-id="vJDjQm0VK8Na" data-box-size="expandable"&gt;
&amp;nbsp;
&lt;/section&gt;n</code></pre>
</li>
<li>
<p><a class="reference-link" href="#root/_help_hrZ1D00cLbal">Internal (reference) links</a>&nbsp;via
its HTML syntax, or through a <em>Wikilinks</em>-like format (only&nbsp;
<a
class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>):</p><pre><code class="language-text-x-trilium-auto">[[Hg8TS5ZOxti6]]</code></pre>
</li>
<li>
<p><a class="reference-link" href="#root/_help_hrZ1D00cLbal">Internal (reference) links</a>&nbsp;via
its HTML syntax, or through a <em>Wikilinks</em>-like format (only&nbsp;
<a
class="reference-link" href="#root/_help_m1lbrzyKDaRB">Note ID</a>):</p><pre><code class="language-text-x-trilium-auto">[[Hg8TS5ZOxti6]]</code></pre>
</li>
<li>
<p><a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_AgjCISero73a">Footnotes</a>&nbsp;are
also supported via the corresponding Markdown syntax:</p><pre><code class="language-text-x-trilium-auto">This is [^1], while this is [^2].
[^1]: the first footnote
[^2]: the second footnote</code></pre>
</li>
</ul>
<h3>Other features</h3>
<ul>
<li>The&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/_help_BFvAtE74rbP6">Table of contents</a>&nbsp;will
be displayed in the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_RnaPdbciOfeq">Right Sidebar</a>&nbsp;based
on the Markdown-level headings.
<ul>
<li>This feature is available only on the&nbsp;<a class="reference-link" href="#root/pOsGYCXsbNQG/gh7bpGYxajRS/Vc8PjrjAGuOp/_help_IjZS7iK5EXtb">New Layout</a>.</li>
</ul>
</li>
</ul>
<h3>Shared notes</h3>
<p>When a Markdown note is <a href="#root/pOsGYCXsbNQG/tC7s2alapj8V/_help_R9pX4DGra2Vt">shared publicly</a>,
it will be rendered with extended formatting just like&nbsp;<a class="reference-link"
href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/_help_iPIMuisry3hd">Text</a>&nbsp;notes.</p>
<p>Most of the features described previously should be supported. If you
face any issues, feel free to <a href="#root/pOsGYCXsbNQG/BgmBlOIl72jZ/_help_wy8So3yZZlH9">report it</a> alongside
a sample Markdown file.</p>
<h2>Creating Markdown notes</h2>
<p>There are two ways to create a Markdown note:</p>
<ol>
<li>Create a new note (e.g. in the&nbsp;<a class="reference-link" href="#root/_help_oPVyFC7WL2Lp">Note Tree</a>)
and select the type <em>Markdown</em>, just like all the other note types.</li>
<li>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;and
<li
>Create a note of type&nbsp;<a class="reference-link" href="#root/_help_6f9hih2hXXZk">Code</a>&nbsp;and
select as the language either <em>Markdown</em> or <em>GitHub-Flavored Markdown</em>.
This maintains compatibility with your existing notes prior to the introduction
of this feature.</li>
@@ -62,10 +62,9 @@
<ul>
<li>It is possible to disable the syntax highlighting for all the notes by
selecting “No syntax highlighting” in the “Color scheme” option.</li>
<li
>Word wrapping is disabled by default, but can be configured from the same
<li>Word wrapping is disabled by default, but can be configured from the same
section.</li>
<li>The tab width can also be adjusted from Options.</li>
<li>The tab width can also be adjusted from Options.</li>
</ul>
<aside class="admonition note">
<p><strong>Context regarding syntax highlighting</strong>
@@ -103,7 +103,8 @@
"find-in-text": "在文本中查找",
"back-in-note-history": "导航到历史记录中的上一个笔记",
"forward-in-note-history": "导航到历史记录的下一个笔记",
"scroll-to-active-note": "滚动笔记树到当前笔记"
"scroll-to-active-note": "滚动笔记树到当前笔记",
"save-named-revision": "保存活动笔记的命名修订"
},
"login": {
"title": "登录",
@@ -357,7 +358,8 @@
"open-developer-tools": "打开开发者工具",
"find-in-text": "在文本中查找",
"toggle-left-pane": "切换左侧面板",
"toggle-full-screen": "切换全屏模式"
"toggle-full-screen": "切换全屏模式",
"save-named-revision": "保存已命名的修订"
},
"share_theme": {
"site-theme": "网站主题",
@@ -187,7 +187,8 @@
"migration": {
"old_version": "La migrazione diretta dalla tua versione attuale non è supportata. Aggiorna prima all'ultima versione v0.60.4 e solo dopo a questa versione.",
"error_message": "Errore durante la migrazione alla versione {{version}}: {{stack}}",
"wrong_db_version": "La versione del database ({{version}}) è più recente di quanto l'applicazione si aspetti ({{targetVersion}}), il che significa che è stato creato da una versione più nuova e incompatibile di Trilium. Aggiorna Trilium all'ultima versione per risolvere questo problema."
"wrong_db_version": "La versione del database ({{version}}) è più recente di quanto l'applicazione si aspetti ({{targetVersion}}), il che significa che è stato creato da una versione più nuova e incompatibile di Trilium. Aggiorna Trilium all'ultima versione per risolvere questo problema.",
"invalid_db_version": "La versione del database non è un numero valido. Questo di solito indica che l'opzione «dbVersion» nel database è danneggiata. Si prega di ripristinare il sistema da un backup."
},
"modals": {
"error_title": "Errore"
@@ -103,7 +103,8 @@
"find-in-text": "切換搜尋面板",
"toggle-classic-editor-toolbar": "切換固定工具列編輯器的格式分頁",
"export-as-pdf": "匯出目前筆記為 PDF",
"toggle-zen-mode": "啟用 / 禁用禪模式(極簡界面以專注編輯)"
"toggle-zen-mode": "啟用 / 禁用禪模式(極簡界面以專注編輯)",
"save-named-revision": "儲存目前筆記的命名修訂"
},
"login": {
"title": "登入",
@@ -259,7 +260,8 @@
"find-in-text": "在文字中尋找",
"toggle-left-pane": "切換左側面板",
"toggle-full-screen": "切換全螢幕",
"command-palette": "命令面板"
"command-palette": "命令面板",
"save-named-revision": "儲存命名修訂"
},
"weekdayNumber": "第 {weekNumber} 週",
"quarterNumber": "第 {quarterNumber} 季度",
@@ -353,7 +355,8 @@
"migration": {
"old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。",
"error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}",
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。"
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。",
"invalid_db_version": "資料庫版本號不是有效的數字。這通常表示資料庫中的「dbVersion」選項已損毀。請從備份還原。"
},
"modals": {
"error_title": "錯誤"
@@ -414,5 +417,11 @@
"fulltext-after-expression": "\"{{- token}}\" 不是有效的表達式。若要搜尋文字,請將其置於屬性篩選條件之前(例如:\"{{- token}} #label\",而非 \"#label {{- token}}\")。",
"unrecognized-expression": "無法辨識的表達式 \"{{- token}}\""
}
},
"password": {
"incorrect": "您輸入的密碼不正確。"
},
"script": {
"wrong-environment": "無法執行筆記「{{- noteTitle}}」({{- noteId}})。這是一個 {{- actualEnv}} 腳本,但嘗試在 {{- expectedEnv}} 中執行。"
}
}
+7 -1
View File
@@ -83,7 +83,13 @@ function downloadBackup(req: Request, res: Response) {
return;
}
res.download(resolvedPath, path.basename(resolvedPath));
const mtime = fs.statSync(resolvedPath).mtime;
const dateStr = mtime.toISOString().slice(0, 19)
.replaceAll(":", "-")
.replace("T", "_");
const ext = path.extname(resolvedPath);
const baseName = path.basename(resolvedPath, ext);
res.download(resolvedPath, `${baseName}_${dateStr}${ext}`);
}
export default {
+51
View File
@@ -0,0 +1,51 @@
import type { BlobRow } from "@triliumnext/commons";
import { blob as blobService } from "@triliumnext/core";
import { describe, expect, it } from "vitest";
// These tests lock the exact output of calculateContentHash against known inputs.
// The hash is written to entity_changes.hash and shipped to sync peers — any
// change to the formula (fields, separators, ordering) silently invalidates
// hashes on already-synced blobs, so changes must be deliberate. If a code
// change breaks these expectations, update the expected values AND ensure a
// migration or sync-compatibility plan is in place.
describe("calculateContentHash", () => {
const baseBlob: Pick<BlobRow, "blobId" | "content" | "textRepresentation"> = {
blobId: "blob001",
content: "hello world"
};
it("hashes a blob without textRepresentation to the locked value (no trailing separator)", () => {
// Must match hash("blobId|content") — same formula used before
// textRepresentation existed, so pre-OCR blobs don't re-hash on upgrade.
expect(blobService.calculateContentHash(baseBlob)).toBe("dyhIZrQHB3Bb1bhcZVPld8Q6ONU=");
});
it("hashes a blob with textRepresentation to the locked value", () => {
expect(blobService.calculateContentHash({ ...baseBlob, textRepresentation: "OCR result" }))
.toBe("CsMYMZbvYJtrGVGJwxnr5w6KbMg=");
});
it("treats undefined, null, and empty-string textRepresentation identically", () => {
const undefinedHash = blobService.calculateContentHash(baseBlob);
const nullHash = blobService.calculateContentHash({ ...baseBlob, textRepresentation: null });
const emptyHash = blobService.calculateContentHash({ ...baseBlob, textRepresentation: "" });
expect(nullHash).toBe(undefinedHash);
expect(emptyHash).toBe(undefinedHash);
});
it("produces different hashes for different blobIds, content, and textRepresentation", () => {
const base = blobService.calculateContentHash(baseBlob);
const diffId = blobService.calculateContentHash({ ...baseBlob, blobId: "blob002" });
const diffContent = blobService.calculateContentHash({ ...baseBlob, content: "other" });
const diffTextRep = blobService.calculateContentHash({ ...baseBlob, textRepresentation: "x" });
expect(diffId).not.toBe(base);
expect(diffContent).not.toBe(base);
expect(diffTextRep).not.toBe(base);
});
it("hashes string and equivalent Buffer content identically", () => {
const stringHash = blobService.calculateContentHash(baseBlob);
const bufferHash = blobService.calculateContentHash({ ...baseBlob, content: Buffer.from("hello world") });
expect(bufferHash).toBe(stringHash);
});
});

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