Compare commits

..

1 Commits

Author SHA1 Message Date
Elian Doran
1e95c2da57 chore: add analysis of all issues & PRs 2026-04-10 00:40:21 +03:00
225 changed files with 14097 additions and 10243 deletions

View File

@@ -1,7 +1,5 @@
# Trilium Notes - AI Coding Agent Instructions
> **Note**: When updating this file, also update `CLAUDE.md` in the repository root to keep both AI coding assistants in sync.
## Project Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. Built as a TypeScript monorepo using pnpm, it implements a three-layer caching architecture (Becca/Froca/Shaca) with a widget-based UI system and supports extensive user scripting capabilities.
@@ -117,15 +115,6 @@ 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:
- `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
- `Slider` - Range slider with label
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
## Development Workflow
### Running & Testing
@@ -333,26 +322,8 @@ Trilium provides powerful user scripting capabilities:
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
#### Client vs Server Translation Usage
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
### Storing User Preferences
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
## Testing Conventions
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
```typescript
// ETAPI test pattern
describe("etapi/feature", () => {

View File

@@ -2,8 +2,6 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **Note**: When updating this file, also update `.github/copilot-instructions.md` to keep both AI coding assistants in sync.
## Overview
Trilium Notes is a hierarchical note-taking application with advanced features like synchronization, scripting, and rich text editing. It's built as a TypeScript monorepo using pnpm, with multiple applications and shared packages.
@@ -68,15 +66,6 @@ Frontend uses a widget system (`apps/client/src/widgets/`):
- `RightPanelWidget` - Widgets displayed in the right panel
- Type-specific widgets in `type_widgets/` directory
#### Reusable Preact Components
Common UI components are available in `apps/client/src/widgets/react/` — prefer reusing these over creating 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
- `Slider` - Range slider with label
- `Checkbox`, `RadioButton` - Form controls
- `CollapsibleSection` - Expandable content sections
#### API Architecture
- **Internal API**: REST endpoints in `apps/server/src/routes/api/`
- **ETAPI**: External API for third-party integrations (`apps/server/src/etapi/`)
@@ -119,8 +108,6 @@ Trilium supports multiple note types, each with specialized widgets:
- Client tests can run in parallel
- E2E tests use Playwright for both server and desktop apps
- Build validation tests check artifact integrity
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
### Scripting System
Trilium provides powerful user scripting capabilities:
@@ -137,11 +124,6 @@ Trilium provides powerful user scripting capabilities:
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
- **Server-side translations** (e.g. hidden subtree titles) go in `apps/server/src/assets/translations/en/server.json`, not in the client `translation.json`
#### Client vs Server Translation Usage
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
### Electron Desktop App
- Desktop entry point: `apps/desktop/src/main.ts`, window management: `apps/server/src/services/window.ts`
- IPC communication: use `electron.ipcMain.on(channel, handler)` on server side, `electron.ipcRenderer.send(channel, data)` on client side
@@ -157,16 +139,6 @@ Trilium provides powerful user scripting capabilities:
- **Do not use `crypto.randomUUID()`** or other Web Crypto APIs that require secure contexts - Trilium can run over HTTP, not just HTTPS
- Use `randomString()` from `apps/client/src/services/utils.ts` for generating IDs instead
### Storing User Preferences
- **Do not use `localStorage`** for user preferences — Trilium has a synced options system that persists across devices
- To add a new user preference:
1. Add the option type to `OptionDefinitions` in `packages/commons/src/lib/options_interface.ts`
2. Add a default value in `apps/server/src/services/options_init.ts` in the `defaultOptions` array
3. **Whitelist the option** in `apps/server/src/routes/api/options.ts` by adding it to `ALLOWED_OPTIONS` (required for client updates)
4. Use `useTriliumOption("optionName")` hook in React components to read/write the option
- Available hooks: `useTriliumOption` (string), `useTriliumOptionBool`, `useTriliumOptionInt`, `useTriliumOptionJson`
- See `docs/Developer Guide/Developer Guide/Concepts/Options/Creating a new option.md` for detailed documentation
### Shared Types Policy
- Types shared between client and server belong in `@triliumnext/commons` (`packages/commons/src/lib/`)
- Import shared types directly from `@triliumnext/commons` - do not re-export them from app-specific modules

View File

@@ -61,7 +61,7 @@
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",
"marked": "18.0.0",
"marked": "17.0.5",
"mermaid": "11.14.0",
"mind-elixir": "5.10.0",
"panzoom": "9.4.4",

View File

@@ -236,16 +236,6 @@ export default class FNote {
return this.hasAttribute("label", "archived");
}
/**
* Returns true if the note's metadata (title, icon) should not be editable.
* This applies to system notes like options, help, and launch bar configuration.
*/
get isMetadataReadOnly() {
return utils.isLaunchBarConfig(this.noteId)
|| this.noteId.startsWith("_help_")
|| this.noteId.startsWith("_options");
}
getChildNoteIds() {
return this.children;
}

View File

@@ -6,8 +6,10 @@ import froca from "./froca";
import server from "./server.js";
// Spy on server methods to track calls
server.put = vi.fn(async () => ({})) as typeof server.put;
server.remove = vi.fn(async () => ({})) as typeof server.remove;
// @ts-expect-error the generic typing is causing issues here
server.put = vi.fn(async <T> (url: string, data?: T) => ({} as T));
// @ts-expect-error the generic typing is causing issues here
server.remove = vi.fn(async <T> (url: string) => ({} as T));
describe("Set boolean with inheritance", () => {
beforeEach(() => {

View File

@@ -120,7 +120,7 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
if (moveToParent) {
try {
await activateParentNotePath(branchIdsToDelete);
await activateParentNotePath();
} catch (e) {
console.error(e);
}
@@ -152,28 +152,13 @@ async function deleteNotes(branchIdsToDelete: string[], forceDeleteAllClones = f
return true;
}
async function activateParentNotePath(branchIdsToDelete: string[]) {
async function activateParentNotePath() {
// this is not perfect, maybe we should find the next/previous sibling, but that's more complex
const activeContext = appContext.tabManager.getActiveContext();
const activeNotePath = activeContext?.notePathArray ?? [];
const parentNotePathArr = activeContext?.notePathArray.slice(0, -1);
// Find the deleted branch that appears earliest in the active note's path
let earliestIndex = activeNotePath.length;
for (const branchId of branchIdsToDelete) {
const branch = froca.getBranch(branchId);
if (branch) {
const index = activeNotePath.indexOf(branch.noteId);
if (index !== -1 && index < earliestIndex) {
earliestIndex = index;
}
}
}
// Navigate to the parent of the highest deleted ancestor
if (earliestIndex < activeNotePath.length) {
const parentPath = activeNotePath.slice(0, earliestIndex);
if (parentPath.length > 0) {
await activeContext?.setNote(parentPath.join("/"));
}
if (parentNotePathArr && parentNotePathArr.length > 0) {
activeContext?.setNote(parentNotePathArr.join("/"));
}
}

View File

@@ -27,8 +27,7 @@ export interface StreamCallbacks {
export async function streamChatCompletion(
messages: LlmMessage[],
config: LlmChatConfig,
callbacks: StreamCallbacks,
abortSignal?: AbortSignal
callbacks: StreamCallbacks
): Promise<void> {
const headers = await server.getHeaders();
@@ -38,8 +37,7 @@ export async function streamChatCompletion(
...headers,
"Content-Type": "application/json"
} as HeadersInit,
body: JSON.stringify({ messages, config }),
signal: abortSignal
body: JSON.stringify({ messages, config })
});
if (!response.ok) {

View File

@@ -68,8 +68,7 @@ async function autocompleteSourceForCKEditor(queryText: string) {
name: row.notePathTitle || "",
link: `#${row.notePath}`,
notePath: row.notePath,
highlightedNotePathTitle: row.highlightedNotePathTitle,
icon: row.icon
highlightedNotePathTitle: row.highlightedNotePathTitle
};
})
);

View File

@@ -18,10 +18,6 @@ async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: ErrorHand
for (const renderNoteId of renderNoteIds) {
const bundle = await server.postWithSilentInternalServerError<Bundle>(`script/bundle/${renderNoteId}`);
if (!bundle) {
throw new Error(`Script note '${renderNoteId}' could not be loaded. It may be protected and require an active protected session.`);
}
const $scriptContainer = $("<div>");
$el.append($scriptContainer);

View File

@@ -1,87 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import SpacedUpdate from "./spaced_update";
// Mock logError which is a global in Trilium
vi.stubGlobal("logError", vi.fn());
describe("SpacedUpdate", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should only call updater once per interval even with multiple pending callbacks", async () => {
const updater = vi.fn(async () => {
// Simulate a slow network request - this is where the race condition occurs
await new Promise((resolve) => setTimeout(resolve, 100));
});
const spacedUpdate = new SpacedUpdate(updater, 50);
// Simulate rapid typing - each keystroke calls scheduleUpdate()
// This queues multiple setTimeout callbacks due to recursive scheduleUpdate() calls
for (let i = 0; i < 10; i++) {
spacedUpdate.scheduleUpdate();
// Small delay between keystrokes
await vi.advanceTimersByTimeAsync(5);
}
// Advance time past the update interval to trigger the update
await vi.advanceTimersByTimeAsync(100);
// Let the "network request" complete and any pending callbacks run
await vi.advanceTimersByTimeAsync(200);
// The updater should have been called only ONCE, not multiple times
// With the bug, multiple pending setTimeout callbacks would all pass the time check
// during the async updater call and trigger multiple concurrent requests
expect(updater).toHaveBeenCalledTimes(1);
});
it("should call updater again if changes occur during the update", async () => {
const updater = vi.fn(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
const spacedUpdate = new SpacedUpdate(updater, 30);
// First update
spacedUpdate.scheduleUpdate();
await vi.advanceTimersByTimeAsync(40);
// Schedule another update while the first one is in progress
spacedUpdate.scheduleUpdate();
// Let first update complete
await vi.advanceTimersByTimeAsync(60);
// Advance past the interval again for the second update
await vi.advanceTimersByTimeAsync(100);
// Should have been called twice - once for each distinct change period
expect(updater).toHaveBeenCalledTimes(2);
});
it("should restore changed flag on error so retry can happen", async () => {
const updater = vi.fn()
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValue(undefined);
const spacedUpdate = new SpacedUpdate(updater, 50);
spacedUpdate.scheduleUpdate();
// Advance to trigger first update (which will fail)
await vi.advanceTimersByTimeAsync(60);
// The error should have restored the changed flag, so scheduling again should work
spacedUpdate.scheduleUpdate();
await vi.advanceTimersByTimeAsync(60);
expect(updater).toHaveBeenCalledTimes(2);
});
});

View File

@@ -77,22 +77,16 @@ export default class SpacedUpdate {
}
if (Date.now() - this.lastUpdated > this.updateInterval) {
// Update these BEFORE the async call to prevent race conditions.
// Multiple setTimeout callbacks may be pending from recursive scheduleUpdate() calls.
// Without this, they would all pass the time check during the await and trigger multiple requests.
this.lastUpdated = Date.now();
this.changed = false;
this.onStateChanged("saving");
try {
await this.updater();
this.onStateChanged("saved");
this.changed = false;
} catch (e) {
// Restore changed flag on error so a retry can happen
this.changed = true;
this.onStateChanged("error");
logError(getErrorMessage(e));
}
this.lastUpdated = Date.now();
} else {
// update isn't triggered but changes are still pending, so we need to schedule another check
this.scheduleUpdate();

View File

@@ -33,14 +33,6 @@ export async function formatCodeBlocks($container: JQuery<HTMLElement>) {
applySingleBlockSyntaxHighlight($(codeBlock), normalizedMimeType);
}
}
// Add click-to-copy for inline code (code elements not inside pre)
if (glob.device !== "print") {
const inlineCodeElements = $container.find("code:not(pre code)");
for (const inlineCode of inlineCodeElements) {
applyInlineCodeCopy($(inlineCode));
}
}
}
export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
@@ -59,23 +51,6 @@ export function applyCopyToClipboardButton($codeBlock: JQuery<HTMLElement>) {
$codeBlock.parent().append($copyButton);
}
export function applyInlineCodeCopy($inlineCode: JQuery<HTMLElement>) {
$inlineCode
.addClass("copyable-inline-code")
.attr("title", t("code_block.click_to_copy"))
.off("click")
.on("click", (e) => {
e.stopPropagation();
const text = $inlineCode.text();
if (!isShare) {
copyTextWithToast(text);
} else {
copyText(text);
}
});
}
/**
* Applies syntax highlight to the given code block (assumed to be <pre><code>), using highlight.js.
*/

View File

@@ -134,7 +134,7 @@ async function handleMessage(event: MessageEvent<any>) {
} else if (message.type === "api-log-messages") {
appContext.triggerEvent("apiLogMessages", { noteId: message.noteId, messages: message.messages });
} else if (message.type === "toast") {
toast.showMessage(message.message, message.timeout);
toast.showMessage(message.message);
} else if (message.type === "execute-script") {
const originEntity = message.originEntityId ? await froca.getNote(message.originEntityId) : null;

View File

@@ -99,7 +99,7 @@ class SetupController {
}
private async finish() {
const syncServerHost = this.syncServerHostInput.value.trim().replace(/\/+$/, "");
const syncServerHost = this.syncServerHostInput.value.trim();
const syncProxy = this.syncProxyInput.value.trim();
const password = this.passwordInput.value;

View File

@@ -1230,43 +1230,6 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href
width: 100%;
}
/* Expandable include note styles */
.include-note-title-row {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.include-note-title-row .include-note-title {
margin: 0;
}
.include-note-toggle {
background: none;
border: none;
padding: 2px;
cursor: pointer;
font-size: 1.2em;
color: var(--main-text-color);
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.include-note-toggle:hover {
color: var(--main-link-color);
}
.include-note-toggle.expanded {
transform: rotate(90deg);
}
.include-note[data-box-size="expandable"] .include-note-content {
margin-top: 10px;
}
.alert {
padding: 8px 14px;
width: auto;

View File

@@ -393,7 +393,9 @@
},
"delete_notes": {
"close": "غلق",
"cancel": "الغاء"
"cancel": "الغاء",
"ok": "نعم",
"delete_notes_preview": "حذف معاينة الملاحظات"
},
"export": {
"close": "غلق",
@@ -624,8 +626,7 @@
"date-and-time": "التاريخ والوقت",
"no_backup_yet": "لايوجد نسخة احتياطية لحد الان",
"enable_daily_backup": "تمكين النسخ الاحتياطي اليومي",
"backup_database_now": "نسخ اختياطي لقاعدة البيانات الان",
"download": "تنزيل"
"backup_database_now": "نسخ اختياطي لقاعدة البيانات الان"
},
"etapi": {
"created": "تم الأنشاء",
@@ -662,6 +663,7 @@
"default_shortcuts": "اختصارات افتراضية"
},
"sync_2": {
"timeout_unit": "ميلي ثانية",
"note": "ملاحظة",
"save": "حفظ",
"help": "المساعدة",

View File

@@ -25,7 +25,8 @@
},
"delete_notes": {
"close": "Tanca",
"cancel": "Cancel·la"
"cancel": "Cancel·la",
"ok": "OK"
},
"export": {
"close": "Tanca",

View File

@@ -88,6 +88,7 @@
"also_delete_note": "同时删除笔记"
},
"delete_notes": {
"delete_notes_preview": "删除笔记预览",
"close": "关闭",
"delete_all_clones_description": "同时删除所有克隆(可以在最近修改中撤消)",
"erase_notes_description": "通常(软)删除仅标记笔记为已删除,可以在一段时间内通过最近修改对话框撤消。选中此选项将立即擦除笔记,不可撤销。",
@@ -95,7 +96,9 @@
"notes_to_be_deleted": "将删除以下笔记 ({{notesCount}})",
"no_note_to_delete": "没有笔记将被删除(仅克隆)。",
"broken_relations_to_be_deleted": "将删除以下关系并断开连接 ({{ relationCount}})",
"cancel": "取消"
"cancel": "取消",
"ok": "确定",
"deleted_relation_text": "笔记 {{- note}} (将被删除的笔记) 被以下关系 {{- relation}} 引用, 来自 {{- source}}。"
},
"export": {
"export_note_title": "导出笔记",
@@ -365,7 +368,7 @@
"calendar_root": "标记应用作为每日笔记的根。只应标记一个笔记。",
"archived": "含有此标签的笔记默认在搜索结果中不可见(也适用于跳转到、添加链接对话框等)。",
"exclude_from_export": "笔记(及其子树)不会包含在任何笔记导出中",
"run": "定义脚本应运行的事件。可能的值包括:\n<ul>\n<li>frontendStartup - Trilium前端启动时或刷新时但不会在移动端执行。</li>\n<li>mobileStartup - Trilium前端启动时或刷新时 在移动端会执行。</li>\n<li>backendStartup - Trilium后端启动时</li>\n<li>hourly - 每小时运行一次。您可以使用附加标签<code>runAtHour</code>指定小时。</li>\n<li>daily - 每天运行一次</li>\n</ul>",
"run": "定义脚本应运行的事件。可能的值包括:\n<ul>\n<li>frontendStartup - Trilium前端启动时或刷新时但不会在移动端执行。</li>\n<li>mobileStartup - Trilium前端启动时或刷新时 在移动端会执行。</li>\n<li>backendStartup - Trilium后端启动时</li>\n<li>hourly - 每小时运行一次。您可以使用附加标签<code>runAtHour</code>指定小时。</li>\n<li>daily - 每天运行一次</li>\n</ul>",
"run_on_instance": "定义应在哪个Trilium实例上运行。默认为所有实例。",
"run_at_hour": "应在哪个小时运行。应与<code>#run=hourly</code>一起使用。可以多次定义,以便一天内运行多次。",
"disable_inclusion": "含有此标签的脚本不会包含在父脚本执行中。",
@@ -801,10 +804,7 @@
"expand_first_level": "展开直接子代",
"expand_nth_level": "展开 {{depth}} 层",
"expand_all_levels": "展开所有层级",
"hide_child_notes": "隐藏树中的子笔记",
"open_all_in_tabs": "全部打开",
"open_all_in_tabs_tooltip": "在新标签页中打开所有结果",
"open_all_confirm": "这将在新标签页中打开 {{count}} 个笔记。继续吗?"
"hide_child_notes": "隐藏树中的子笔记"
},
"edited_notes": {
"no_edited_notes_found": "今天还没有编辑过的笔记...",
@@ -858,8 +858,7 @@
"collapse": "折叠到正常大小",
"title": "笔记地图",
"fix-nodes": "固定节点",
"link-distance": "链接距离",
"too-many-notes": "此子树包含 {{count}} 个笔记,超过了笔记地图中可显示的 {{max}} 个笔记的限制。"
"link-distance": "链接距离"
},
"note_paths": {
"title": "笔记路径",
@@ -1064,8 +1063,7 @@
"note_already_in_diagram": "笔记 \"{{title}}\" 已经在图中。",
"enter_title_of_new_note": "输入新笔记的标题",
"default_new_note_title": "新笔记",
"click_on_canvas_to_place_new_note": "点击画布以放置新笔记",
"rename_relation": "重命名关系"
"click_on_canvas_to_place_new_note": "点击画布以放置新笔记"
},
"backend_log": {
"refresh": "刷新"
@@ -1339,8 +1337,7 @@
"date-and-time": "日期和时间",
"path": "路径",
"database_backed_up_to": "数据库已备份到 {{backupFilePath}}",
"no_backup_yet": "尚无备份",
"download": "下载"
"no_backup_yet": "尚无备份"
},
"etapi": {
"title": "ETAPI",
@@ -1438,15 +1435,9 @@
"spellcheck": {
"title": "拼写检查",
"description": "这些选项仅适用于桌面版本,浏览器将使用其原生的拼写检查功能。",
"enable": "拼写检查",
"language_code_label": "拼写检查语言",
"restart-required": "拼写检查选项的更改将在应用重启后生效。",
"custom_dictionary_title": "自定义词典",
"custom_dictionary_description": "添加到词典中的单词会在您的所有设备上同步。",
"custom_dictionary_edit": "自定义词",
"custom_dictionary_edit_description": "编辑拼写检查器不应标记的单词列表。更改将在重启后生效。",
"custom_dictionary_open": "编辑词典",
"related_description": "配置拼写检查语言和自定义词典。"
"enable": "启用拼写检查",
"language_code_label": "语言代码",
"restart-required": "拼写检查选项的更改将在应用重启后生效。"
},
"sync_2": {
"config_title": "同步配置",
@@ -1462,7 +1453,7 @@
"test_description": "测试和同步服务器之间的连接。如果同步服务器没有初始化,会将本地文档同步到同步服务器上。",
"test_button": "测试同步",
"handshake_failed": "同步服务器握手失败,错误:{{message}}",
"timeout_description": "同步连接速度慢时,应该等待多久才放弃?如果网络不稳定,请增加等待时间。"
"timeout_unit": "毫秒"
},
"api_log": {
"close": "关闭"
@@ -1885,7 +1876,7 @@
},
"content_language": {
"title": "内容语言",
"description": "在只读或可编辑文本笔记的“基本属性”部分,选择一种或多种语言,这些语言将显示在语言选择列表中。这将启用拼写检查从右到左的阅读支持和文本提取OCR功能。"
"description": "选择一种或多种语言出现在只读或可编辑文本注释的基本属性,这将支持拼写检查从右向左之类的功能。"
},
"switch_layout_button": {
"title_vertical": "将编辑面板移至底部",
@@ -2240,9 +2231,7 @@
"sample_xy": "散点图",
"sample_venn": "韦恩图",
"sample_ishikawa": "鱼骨图",
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。",
"sample_treeview": "树形视图",
"sample_wardley": "沃德利地图"
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。"
},
"llm_chat": {
"placeholder": "输入消息…",
@@ -2273,8 +2262,7 @@
"note_context_disabled": "点击即可将当前注释添加到上下文中",
"no_provider_message": "未配置人工智能提供商。添加一个即可开始对话。",
"add_provider": "添加人工智能提供商",
"note_tools": "笔记访问",
"sources_summary": "来自 {{sites}} 个网站的 {{count}} 个来源"
"note_tools": "笔记访问"
},
"sidebar_chat": {
"title": "AI对话",
@@ -2297,10 +2285,7 @@
"processing": "正在处理...",
"processing_started": "OCR识别已开始。请稍候片刻并刷新页面。",
"processing_failed": "OCR处理启动失败",
"view_extracted_text": "查看提取的文本OCR",
"processing_complete": "OCR识别处理完成。",
"text_filtered_low_confidence": "OCR 检测到文本,置信度为 {{confidence}}% ,但由于您的最小阈值为 {{threshold}}% ,因此该文本已被丢弃。",
"open_media_settings": "打开设置"
"view_extracted_text": "查看提取的文本OCR"
},
"mind-map": {
"addChild": "添加子节点",
@@ -2318,13 +2303,6 @@
},
"llm": {
"settings_description": "配置人工智能和大语言模型集成。",
"add_provider": "添加提供商",
"settings_title": "AI / LLM",
"feature_not_enabled": "在“设置”→“高级”→“实验性功能”中启用 LLM 实验性功能,即可使用 AI 集成。",
"add_provider_title": "添加AI供应商",
"configured_providers": "已配置的供应商",
"no_providers_configured": "尚未配置任何供应商。",
"provider_name": "名称",
"provider_type": "供应商"
"add_provider": "添加提供商"
}
}

View File

@@ -77,13 +77,16 @@
},
"delete_notes": {
"cancel": "Zrušit",
"ok": "OK",
"close": "Zavřít",
"delete_notes_preview": "Odstranit náhled poznámek",
"delete_all_clones_description": "Odstraňte také všechny klony (lze vrátit zpět v nedávných změnách)",
"erase_notes_description": "Normální (měkké) smazání pouze označí poznámky jako smazané a lze je během určité doby obnovit (v dialogovém okně posledních změn). Zaškrtnutím této možnosti se poznámky okamžitě vymažou a nebude možné je obnovit.",
"erase_notes_warning": "Trvale smažte poznámky (nelze vrátit zpět), včetně všech klonů. Tím se vynutí opětovné načtení aplikace.",
"notes_to_be_deleted": "Následující poznámky budou smazány ({{notesCount}})",
"no_note_to_delete": "Žádná poznámka nebude smazána (pouze klony).",
"broken_relations_to_be_deleted": "Následující vazby budou přerušeny a smazány ({{relationCount}})"
"broken_relations_to_be_deleted": "Následující vazby budou přerušeny a smazány ({{relationCount}})",
"deleted_relation_text": "Poznámka {{- note}} (bude smazána) je odkazována vazbou {{- relation}} pocházející z {{- source}}."
},
"export": {
"close": "Zavřít",
@@ -1505,6 +1508,7 @@
"config_title": "Konfigurace Synchronizace",
"server_address": "Adresa instance serveru",
"timeout": "Časový limit synchronizace",
"timeout_unit": "milisekund",
"proxy_label": "Proxy server pro synchronizaci (volitelné)",
"note": "Poznámka",
"note_description": "Pokud ponecháte nastavení proxy prázdné, bude použit systémový proxy (platí pouze pro desktop/electron build).",

View File

@@ -88,6 +88,7 @@
"also_delete_note": "Auch die Notiz löschen"
},
"delete_notes": {
"delete_notes_preview": "Vorschau der Notizen löschen",
"close": "Schließen",
"delete_all_clones_description": "auch alle Klone löschen (kann bei letzte Änderungen rückgängig gemacht werden)",
"erase_notes_description": "Beim normalen (vorläufigen) Löschen werden die Notizen nur als gelöscht markiert und sie können innerhalb eines bestimmten Zeitraums (im Dialogfeld „Letzte Änderungen“) wiederhergestellt werden. Wenn du diese Option aktivierst, werden die Notizen sofort gelöscht und es ist nicht möglich, die Notizen wiederherzustellen.",
@@ -95,7 +96,9 @@
"notes_to_be_deleted": "Folgende Notizen werden gelöscht ({{notesCount}})",
"no_note_to_delete": "Es werden keine Notizen gelöscht (nur Klone).",
"broken_relations_to_be_deleted": "Folgende Beziehungen werden gelöst und gelöscht ({{ relationCount}})",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"ok": "OK",
"deleted_relation_text": "Notiz {{- note}} (soll gelöscht werden) wird von Beziehung {{- relation}} ausgehend von {{- source}} referenziert."
},
"export": {
"export_note_title": "Notiz exportieren",
@@ -1398,7 +1401,8 @@
"test_title": "Synchronisierungstest",
"test_description": "Dadurch werden die Verbindung und der Handshake zum Synchronisierungsserver getestet. Wenn der Synchronisierungsserver nicht initialisiert ist, wird er dadurch für die Synchronisierung mit dem lokalen Dokument eingerichtet.",
"test_button": "Teste die Synchronisierung",
"handshake_failed": "Handshake des Synchronisierungsservers fehlgeschlagen, Fehler: {{message}}"
"handshake_failed": "Handshake des Synchronisierungsservers fehlgeschlagen, Fehler: {{message}}",
"timeout_unit": "Millisekunden"
},
"api_log": {
"close": "Schließen"

View File

@@ -4,7 +4,7 @@
"homepage": "Αρχική Σελίδα:",
"app_version": "Έκδοση εφαρμογής:",
"db_version": "Έκδοση βάσης δεδομένων:",
"sync_version": "Έκδοση συγχρονισμού:",
"sync_version": "Έκδοση πρωτοκόλου συγχρονισμού:",
"build_date": "Ημερομηνία χτισίματος εφαρμογής:",
"build_revision": "Αριθμός αναθεώρησης χτισίματος:",
"data_directory": "Φάκελος δεδομένων:"

View File

@@ -88,23 +88,17 @@
"also_delete_note": "Also delete the note"
},
"delete_notes": {
"title": "Delete notes",
"delete_notes_preview": "Delete notes preview",
"close": "Close",
"clones_label": "Clones",
"delete_clones_description_one": "Also delete {{count}} other clone. Can be undone in recent changes.",
"delete_clones_description_other": "Also delete {{count}} other clones. Can be undone in recent changes.",
"delete_all_clones_description": "Delete also all clones (can be undone in recent changes)",
"erase_notes_label": "Erase permanently",
"erase_notes_description": "Erase notes immediately instead of soft deletion. This cannot be undone and will force application reload.",
"erase_notes_description": "Normal (soft) deletion only marks the notes as deleted and they can be undeleted (in recent changes dialog) within a period of time. Checking this option will erase the notes immediately and it won't be possible to undelete the notes.",
"erase_notes_warning": "Erase notes permanently (can't be undone), including all clones. This will force application reload.",
"notes_to_be_deleted": "Notes to be deleted ({{notesCount}})",
"notes_to_be_deleted": "Following notes will be deleted ({{notesCount}})",
"no_note_to_delete": "No note will be deleted (only clones).",
"broken_relations_to_be_deleted": "Broken relations ({{relationCount}})",
"table_note_with_relation": "Note with relation",
"table_relation": "Relation",
"table_points_to": "Points to (deleted)",
"broken_relations_to_be_deleted": "Following relations will be broken and deleted ({{ relationCount}})",
"cancel": "Cancel",
"delete": "Delete"
"ok": "OK",
"deleted_relation_text": "Note {{- note}} (to be deleted) is referenced by relation {{- relation}} originating from {{- source}}."
},
"export": {
"export_note_title": "Export note",
@@ -215,7 +209,6 @@
"box_size_small": "small (~ 10 lines)",
"box_size_medium": "medium (~ 30 lines)",
"box_size_full": "full (box shows complete text)",
"box_size_expandable": "expandable (collapsed by default)",
"button_include": "Include note"
},
"info": {
@@ -813,11 +806,7 @@
"board": "Board",
"presentation": "Presentation",
"include_archived_notes": "Show archived notes",
"hide_child_notes": "Hide child notes in tree",
"open_all_in_tabs": "Open all",
"open_all_in_tabs_tooltip": "Open all results in new tabs",
"open_all_confirm": "This will open {{count}} notes in new tabs. Continue?",
"open_all_too_many": "Too many results ({{count}}). Maximum is {{max}}."
"hide_child_notes": "Hide child notes in tree"
},
"edited_notes": {
"no_edited_notes_found": "No edited notes on this day yet...",
@@ -871,8 +860,7 @@
"collapse": "Collapse to normal size",
"title": "Note Map",
"fix-nodes": "Fix nodes",
"link-distance": "Link distance",
"too-many-notes": "This subtree contains {{count}} notes, which exceeds the limit of {{max}} that can be displayed in the note map."
"link-distance": "Link distance"
},
"note_paths": {
"title": "Note Paths",
@@ -1413,8 +1401,7 @@
"date-and-time": "Date & time",
"path": "Path",
"database_backed_up_to": "Database has been backed up to {{backupFilePath}}",
"no_backup_yet": "no backup yet",
"download": "Download"
"no_backup_yet": "no backup yet"
},
"etapi": {
"title": "ETAPI",
@@ -1526,7 +1513,7 @@
"config_title": "Sync Configuration",
"server_address": "Server instance address",
"timeout": "Sync timeout",
"timeout_description": "How long to wait before giving up on a slow sync connection. Increase if you have an unstable network.",
"timeout_unit": "milliseconds",
"proxy_label": "Sync proxy server (optional)",
"note": "Note",
"note_description": "If you leave the proxy setting blank, the system proxy will be used (applies to desktop/electron build only).",
@@ -1677,8 +1664,7 @@
"note_context_enabled": "Click to disable note context: {{title}}",
"note_context_disabled": "Click to include current note in context",
"no_provider_message": "No AI provider configured. Add one to start chatting.",
"add_provider": "Add AI Provider",
"stop": "Stop"
"add_provider": "Add AI Provider"
},
"sidebar_chat": {
"title": "AI Chat",
@@ -1884,8 +1870,7 @@
"theme_none": "No syntax highlighting",
"theme_group_light": "Light themes",
"theme_group_dark": "Dark themes",
"copy_title": "Copy to clipboard",
"click_to_copy": "Click to copy"
"copy_title": "Copy to clipboard"
},
"classic_editor_toolbar": {
"title": "Formatting"
@@ -2382,11 +2367,7 @@
"web_search": "Web search",
"note_in_parent": "<Note/> in <Parent/>",
"get_attachment": "Get attachment",
"get_attachment_content": "Read attachment content",
"rename_note": "Rename note",
"delete_note": "Delete note",
"move_note": "Move note",
"clone_note": "Clone note"
"get_attachment_content": "Read attachment content"
}
}
}

View File

@@ -88,6 +88,7 @@
"also_delete_note": "También eliminar la nota"
},
"delete_notes": {
"delete_notes_preview": "Eliminar vista previa de notas",
"close": "Cerrar",
"delete_all_clones_description": "Eliminar también todos los clones (se puede deshacer en cambios recientes)",
"erase_notes_description": "La eliminación normal (suave) solo marca las notas como eliminadas y se pueden recuperar (en el cuadro de diálogo de cambios recientes) dentro de un periodo de tiempo. Al marcar esta opción se borrarán las notas inmediatamente y no será posible recuperarlas.",
@@ -95,7 +96,9 @@
"notes_to_be_deleted": "Las siguientes notas serán eliminadas ({{notesCount}})",
"no_note_to_delete": "No se eliminará ninguna nota (solo clones).",
"broken_relations_to_be_deleted": "Las siguientes relaciones se romperán y serán eliminadas ({{ relationCount}})",
"cancel": "Cancelar"
"cancel": "Cancelar",
"ok": "Aceptar",
"deleted_relation_text": "Nota {{- note}} (para ser eliminada) está referenciado por la relación {{- relation}} que se origina en {{- source}}."
},
"export": {
"export_note_title": "Exportar nota",
@@ -1329,8 +1332,7 @@
"date-and-time": "Fecha y hora",
"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"
"no_backup_yet": "no hay copia de seguridad todavía"
},
"etapi": {
"title": "ETAPI",
@@ -1436,6 +1438,7 @@
"config_title": "Configuración de sincronización",
"server_address": "Dirección de la instancia del servidor",
"timeout": "Tiempo de espera de sincronización (milisegundos)",
"timeout_unit": "milisegundos",
"proxy_label": "Sincronizar servidor proxy (opcional)",
"note": "Nota",
"note_description": "Si deja la configuración del proxy en blanco, se utilizará el proxy del sistema (se aplica únicamente a la compilación de escritorio/electron).",

View File

@@ -62,10 +62,12 @@
"also_delete_note": "Poista myös muistio"
},
"delete_notes": {
"delete_notes_preview": "Poista muistion esikatselu",
"close": "Sulje",
"notes_to_be_deleted": "Seuraavat muistiot tullaan poistamaan ({{notesCount}})",
"no_note_to_delete": "Muistioita ei poisteta (vain kopiot).",
"cancel": "Peruuta"
"cancel": "Peruuta",
"ok": "OK"
},
"export": {
"export_note_title": "Vie muistio",

View File

@@ -88,6 +88,7 @@
"also_delete_note": "Supprimer également la note"
},
"delete_notes": {
"delete_notes_preview": "Supprimer la note",
"close": "Fermer",
"delete_all_clones_description": "Supprimer aussi les clones (peut être annulé dans des modifications récentes)",
"erase_notes_description": "La suppression normale (douce) marque uniquement les notes comme supprimées et elles peuvent être restaurées (dans la boîte de dialogue des Modifications récentes) dans un délai donné. Cocher cette option effacera les notes immédiatement et il ne sera pas possible de les restaurer.",
@@ -95,7 +96,9 @@
"notes_to_be_deleted": "Les notes suivantes seront supprimées ({{notesCount}})",
"no_note_to_delete": "Aucune note ne sera supprimée (uniquement les clones).",
"broken_relations_to_be_deleted": "Les relations suivantes seront rompues et supprimées ({{ relationCount}})",
"cancel": "Annuler"
"cancel": "Annuler",
"ok": "OK",
"deleted_relation_text": "Note {{- note}} (à supprimer) est référencée dans la relation {{- relation}} provenant de {{- source}}."
},
"export": {
"export_note_title": "Exporter la note",
@@ -1403,7 +1406,8 @@
"test_title": "Test de synchronisation",
"test_description": "Testera la connexion et la prise de contact avec le serveur de synchronisation. Si le serveur de synchronisation n'est pas initialisé, cela le configurera pour qu'il se synchronise avec le document local.",
"test_button": "Tester la synchronisation",
"handshake_failed": "Échec de la négociation avec le serveur de synchronisation, erreur : {{message}}"
"handshake_failed": "Échec de la négociation avec le serveur de synchronisation, erreur : {{message}}",
"timeout_unit": "millisecondes"
},
"api_log": {
"close": "Fermer"

View File

@@ -119,6 +119,7 @@
"also_delete_note": "Scrios an nóta freisin"
},
"delete_notes": {
"delete_notes_preview": "Réamhamharc ar scriosadh nótaí",
"close": "Dún",
"delete_all_clones_description": "Scrios gach clón freisin (is féidir é seo a chealú in athruithe le déanaí)",
"erase_notes_description": "Ní mharcálann scriosadh gnáth (bog) ach na nótaí mar scriosta agus is féidir iad a dhíscriosadh (sa dialóg athruithe le déanaí) laistigh de thréimhse ama. Scriosfar na nótaí láithreach má sheiceálann tú an rogha seo agus ní bheidh sé indéanta na nótaí a dhíscriosadh.",
@@ -126,7 +127,9 @@
"notes_to_be_deleted": "Scriosfar na nótaí seo a leanas ({{notesCount}})",
"no_note_to_delete": "Ní scriosfar aon nóta (clóin amháin).",
"broken_relations_to_be_deleted": "Brisfear agus scriosfar na caidrimh seo a leanas ({{ relationCount}})",
"cancel": "Cealaigh"
"cancel": "Cealaigh",
"ok": "Ceart go leor",
"deleted_relation_text": "Tá tagairt don nóta {{- note}} (le scriosadh) le gaol {{- relation}} a thagann ó {{- source}}."
},
"export": {
"export_note_title": "Nóta easpórtála",
@@ -1480,6 +1483,7 @@
"config_title": "Cumraíocht Sioncrónaithe",
"server_address": "Seoladh sampla an fhreastalaí",
"timeout": "Am scoir sioncrónaithe",
"timeout_unit": "milleasoicindí",
"proxy_label": "Sioncrónaigh freastalaí seachfhreastalaí (roghnach)",
"note": "Nóta",
"note_description": "Má fhágann tú an socrú seachfhreastalaí bán, úsáidfear seachfhreastalaí an chórais (baineann sé le tógáil deisce/leictreon amháin).",

View File

@@ -94,6 +94,7 @@
"if_you_dont_check": "अगर आप इसे चेक नहीं करते हैं, तो नोट केवल रिलेशन मैप से हटाया जाएगा।"
},
"delete_notes": {
"delete_notes_preview": "नोट्स प्रिव्यू डिलीट करें",
"close": "बंद करें",
"delete_all_clones_description": "सभी क्लोन भी डिलीट करें (हाल के बदलावों में वापस ला सकते हैं)",
"erase_notes_description": "सामान्य (सॉफ्ट) डिलीट करने पर नोट केवल 'डिलीटेड' मार्क होते हैं और उन्हें एक निश्चित समय के भीतर (हाल के बदलावों वाले डायलॉग में) वापस लाया जा सकता है। इस विकल्प को चुनने पर नोट तुरंत पूरी तरह मिटा दिए जाएंगे और उन्हें वापस लाना संभव नहीं होगा।",
@@ -101,7 +102,9 @@
"notes_to_be_deleted": "निम्नलिखित नोट डिलीट कर दिए जाएंगे ({{notesCount}})",
"no_note_to_delete": "कोई भी नोट डिलीट नहीं होगा (केवल क्लोन हटाए जाएंगे)।",
"broken_relations_to_be_deleted": "निम्नलिखित रिलेशन टूट जाएंगे और डिलीट हो जाएंगे ({{relationCount}})",
"cancel": "रद्द करें"
"cancel": "रद्द करें",
"ok": "ठीक है",
"deleted_relation_text": "नोट {{- note}} (जिसे डिलीट किया जाना है) का संदर्भ {{- source}} से शुरू होने वाले रिलेशन {{- relation}} में दिया गया है।"
},
"branch_prefix": {
"edit_branch_prefix": "ब्रांच प्रीफ़िक्स एडिट करें",
@@ -1464,6 +1467,7 @@
"config_title": "सिंक कॉन्फ़िगरेशन",
"server_address": "सर्वर एड्रेस (Address)",
"timeout": "सिंक समय-सीमा (Timeout)",
"timeout_unit": "मिलीसेकंड (milliseconds)",
"proxy_label": "सिंक प्रॉक्सी सर्वर (वैकल्पिक)",
"note": "नोट",
"note_description": "अगर आप प्रॉक्सी खाली छोड़ते हैं, तो सिस्टम प्रॉक्सी का इस्तेमाल होगा।",

View File

@@ -76,6 +76,7 @@
"confirmation": "Konfirmasi"
},
"delete_notes": {
"delete_notes_preview": "Hapus pratinjau catatan",
"close": "Tutup",
"delete_all_clones_description": "Hapus seluruh duplikat (bisa dikembalikan di menu revisi)",
"erase_notes_description": "Penghapusan normal hanya menandai catatan sebagai dihapus dan dapat dipulihkan (melalui dialog versi revisi) dalam jangka waktu tertentu. Mencentang opsi ini akan menghapus catatan secara permanen seketika dan catatan tidak akan bisa dipulihkan kembali.",
@@ -83,7 +84,9 @@
"notes_to_be_deleted": "Catatan-catatan berikut akan dihapuskan ({{notesCount}})",
"no_note_to_delete": "Tidak ada Catatan yang akan dihapus (hanya duplikat).",
"broken_relations_to_be_deleted": "Hubungan berikut akan diputus dan dihapus ({{ relationCount}})",
"cancel": "Batalkan"
"cancel": "Batalkan",
"ok": "Setuju",
"deleted_relation_text": "Catatan {{- note}} (yang akan dihapus) dirujuk oleh relasi {{- relation}} yang berasal dari {{- source}}."
},
"clone_to": {
"clone_notes_to": "Duplikat catatan ke…",

View File

@@ -88,14 +88,17 @@
"also_delete_note": "Rimuove anche la nota"
},
"delete_notes": {
"ok": "OK",
"close": "Chiudi",
"delete_notes_preview": "Anteprima di eliminazione delle note",
"delete_all_clones_description": "Elimina anche tutti i cloni (può essere ripristinato nella sezione cambiamenti recenti)",
"erase_notes_description": "L'eliminazione normale (soft) marca le note come eliminate e potranno essere recuperate entro un certo lasso di tempo (dalla finestra dei cambiamenti recenti). Selezionando questa opzione le note si elimineranno immediatamente e non sarà possibile recuperarle.",
"erase_notes_warning": "Elimina le note in modo permanente (non potrà essere disfatto), compresi tutti i cloni. Ciò forzerà un nuovo caricamento dell'applicazione.",
"cancel": "Annulla",
"notes_to_be_deleted": "Le seguenti note saranno eliminate ({{notesCount}})",
"no_note_to_delete": "Nessuna nota sarà eliminata (solo i cloni).",
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{relationCount}})"
"broken_relations_to_be_deleted": "Le seguenti relazioni saranno interrotte ed eliminate ({{relationCount}})",
"deleted_relation_text": "La nota {{- note}} (da eliminare) è referenziata dalla relazione {{- relation}} originata da {{- source}}."
},
"info": {
"okButton": "OK",
@@ -494,6 +497,7 @@
"proxy_label": "Server Proxy per la sincronizzazione (opzionale)",
"test_title": "Test di sincronizzazione",
"timeout": "Timeout per la sincronizzazione",
"timeout_unit": "millisecondi",
"save": "Salva",
"help": "Aiuto",
"server_address": "Indirizzo dell'istanza del server",

View File

@@ -111,8 +111,11 @@
"notes_to_be_deleted": "以下のノートが削除されます ({{notesCount}})",
"no_note_to_delete": "ノートは削除されません(クローンのみ)。",
"cancel": "キャンセル",
"ok": "OK",
"close": "閉じる",
"broken_relations_to_be_deleted": "次のリレーション ({{relationCount}})は壊れているので消去されます"
"delete_notes_preview": "ノートのプレビューを削除",
"broken_relations_to_be_deleted": "次のリレーション ({{relationCount}})は壊れているので消去されます",
"deleted_relation_text": "削除予定のノート{{- note}}は{{- source}}からリレーション{{- relation}}によって参照されています."
},
"calendar": {
"mon": "月",
@@ -573,10 +576,7 @@
"expand_first_level": "直下の子を展開",
"expand_nth_level": "{{depth}} 階層下まで展開",
"expand_all_levels": "すべての階層を展開",
"hide_child_notes": "ツリー内の子ノートを非表示",
"open_all_in_tabs": "すべて開く",
"open_all_in_tabs_tooltip": "すべての結果を新しいタブで開く",
"open_all_confirm": "{{count}} 件のノートが新しいタブで開かれます。続行しますか?"
"hide_child_notes": "ツリー内の子ノートを非表示"
},
"note_types": {
"geo-map": "ジオマップ",
@@ -1001,8 +1001,7 @@
"date-and-time": "日時",
"path": "パス",
"database_backed_up_to": "データベースは{{backupFilePath}}にバックアップされました",
"no_backup_yet": "バックアップがありません",
"download": "ダウンロード"
"no_backup_yet": "バックアップがありません"
},
"password": {
"wiki": "wiki",
@@ -1042,6 +1041,7 @@
"config_title": "同期設定",
"server_address": "サーバーインスタンスのアドレス",
"timeout": "同期タイムアウト",
"timeout_unit": "ミリ秒",
"proxy_label": "同期プロキシサーバー(任意)",
"note": "注",
"note_description": "プロキシ設定を空白のままにすると、システムプロキシが使用されます(デスクトップ/electronビルドにのみ適用されます。",
@@ -1051,8 +1051,7 @@
"test_title": "同期のテスト",
"test_description": "これは同期サーバとの接続とハンドシェイクをテストします。同期サーバーが初期化されていない場合、ローカルドキュメントと同期するように設定します。",
"test_button": "同期試行",
"handshake_failed": "同期サーバーのハンドシェイクに失敗しました。エラー: {{message}}",
"timeout_description": "同期接続が遅い場合に、接続を諦めるまでの待機時間。ネットワークが不安定な場合は、この時間を長く設定してください。"
"handshake_failed": "同期サーバーのハンドシェイクに失敗しました。エラー: {{message}}"
},
"api_log": {
"close": "閉じる"
@@ -1543,8 +1542,7 @@
"collapse": "通常サイズに折りたたむ",
"title": "ノートマップ",
"link-distance": "リンク距離",
"fix-nodes": "ノードを修正",
"too-many-notes": "このサブツリーには {{count}} 件のノートが含まれており、ノートマップに表示できる {{max}} の上限を超えています。"
"fix-nodes": "ノードを修正"
},
"owned_attribute_list": {
"owned_attributes": "所有属性"

View File

@@ -100,6 +100,9 @@
"no_note_to_delete": "삭제되는 노트가 없습니다 (클론만 삭제됩니다).",
"broken_relations_to_be_deleted": "다음 관계가 끊어지고 삭제됩니다({{ relationCount}})",
"cancel": "취소",
"ok": "OK",
"deleted_relation_text": "삭제 예정인 노트 {{- note}} (은)는 {{- source}}에서 시작된 관계 {{- relation}}에 의해 참조되고 있습니다.",
"delete_notes_preview": "노트 미리보기 삭제",
"close": "닫기",
"delete_all_clones_description": "모든 복제본 삭제(최근 변경 사항에서 되돌릴 수 있습니다)"
},

View File

@@ -39,7 +39,8 @@
},
"delete_notes": {
"close": "Lukk",
"cancel": "Avbryt"
"cancel": "Avbryt",
"ok": "OK"
},
"export": {
"close": "Lukk",

View File

@@ -78,12 +78,15 @@
"delete_notes": {
"cancel": "Anuluj",
"close": "Zamknij",
"delete_notes_preview": "Podgląd usuwania notatek",
"delete_all_clones_description": "Usuń również wszystkie klony (można cofnąć w oknie Ostatnie zmiany)",
"erase_notes_description": "Normalne (miękkie) usuwanie jedynie oznacza notatki jako usunięte i można je przywrócić (w oknie Ostatnie zmiany) przez pewien czas. Zaznaczenie tej opcji spowoduje natychmiastowe wymazanie notatek i nie będzie możliwe ich przywrócenie.",
"erase_notes_warning": "Wymaż notatki trwale (nie można cofnąć), w tym wszystkie klony. Wymusi to przeładowanie aplikacji.",
"notes_to_be_deleted": "Następujące notatki zostaną usunięte ({{notesCount}})",
"no_note_to_delete": "Żadna notatka nie zostanie usunięta (tylko klony).",
"broken_relations_to_be_deleted": "Następujące relacje zostaną zerwane i usunięte ({{ relationCount}})"
"broken_relations_to_be_deleted": "Następujące relacje zostaną zerwane i usunięte ({{ relationCount}})",
"ok": "OK",
"deleted_relation_text": "Notatka {{- note}} (do usunięcia) jest powiązana relacją {{- relation}} pochodzącą z {{- source}}."
},
"export": {
"close": "Zamknij",
@@ -1668,6 +1671,7 @@
"config_title": "Konfiguracja synchronizacji",
"server_address": "Adres instancji serwera",
"timeout": "Limit czasu synchronizacji",
"timeout_unit": "milisekund",
"proxy_label": "Serwer proxy synchronizacji (opcjonalnie)",
"note": "Uwaga",
"note_description": "Jeśli pozostawisz ustawienie proxy puste, zostanie użyte proxy systemowe (dotyczy tylko wersji desktop/electron).",

View File

@@ -88,6 +88,7 @@
"also_delete_note": "Também apagar a nota"
},
"delete_notes": {
"delete_notes_preview": "Apagar pré-visualização de notas",
"close": "Fechar",
"delete_all_clones_description": "Apagar também todos os clones (pode ser desfeito em alterações recentes)",
"erase_notes_description": "Apagar normal (suave) apenas marca as notas como apagadas, permitindo que sejam recuperadas (no diálogo de alterações recentes) num período. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
@@ -95,7 +96,9 @@
"notes_to_be_deleted": "As seguintes notas serão apagadas ({{notesCount}})",
"no_note_to_delete": "Nenhuma nota será apagada (apenas os clones).",
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e apagadas ({{ relationCount}})",
"cancel": "Cancelar"
"cancel": "Cancelar",
"ok": "OK",
"deleted_relation_text": "A nota {{- note}} (a ser apagada) está referenciada pela relação {{- relation}} originada de {{- source}}."
},
"export": {
"export_note_title": "Exportar nota",
@@ -1438,6 +1441,7 @@
"config_title": "Configuração da Sincronização",
"server_address": "Endereço da instância do Servidor",
"timeout": "Tempo limite da sincronização",
"timeout_unit": "milisegundos",
"proxy_label": "Servidor proxy para sincronização (opcional)",
"note": "Nota",
"note_description": "Se deixar a configuração de proxy em branco, o proxy do sistema será usado (aplica-se apenas à versão desktop/Electron).",

View File

@@ -94,6 +94,7 @@
"also_delete_note": "Também excluir a nota"
},
"delete_notes": {
"delete_notes_preview": "Excluir pré-visualização de notas",
"close": "Fechar",
"delete_all_clones_description": "Excluir também todos os clones (pode ser desfeito em alterações recentes)",
"erase_notes_description": "A exclusão normal (suave) apenas marca as notas como excluídas, permitindo que sejam recuperadas (no diálogo de alterações recentes) dentro de um período de tempo. Se esta opção for marcada, as notas serão apagadas imediatamente e não será possível restaurá-las.",
@@ -101,7 +102,9 @@
"notes_to_be_deleted": "As seguintes notas serão excluídas ({{notesCount}})",
"no_note_to_delete": "Nenhuma nota será excluída (apenas os clones).",
"broken_relations_to_be_deleted": "As seguintes relações serão quebradas e excluídas ({{ relationCount}})",
"cancel": "Cancelar"
"cancel": "Cancelar",
"ok": "OK",
"deleted_relation_text": "A nota {{- note}} (a ser excluída) está referenciada pela relação {{- relation}} originada de {{- source}}."
},
"export": {
"export_note_title": "Exportar nota",
@@ -1947,6 +1950,7 @@
"config_title": "Configuração da Sincronização",
"server_address": "Endereço da instância do Servidor",
"timeout": "Tempo limite da sincronização",
"timeout_unit": "milisegundos",
"proxy_label": "Servidor proxy para sincronização (opcional)",
"note": "Nota",
"note_description": "Se você deixar a configuração de proxy em branco, o proxy do sistema será usado (aplica-se apenas à versão desktop/Electron).",

View File

@@ -459,10 +459,13 @@
"broken_relations_to_be_deleted": "Următoarele relații vor fi întrerupte și șterse ({{ relationCount}})",
"cancel": "Anulează",
"delete_all_clones_description": "Șterge și toate clonele (se pot recupera în ecranul Schimbări recente)",
"delete_notes_preview": "Previzualizare ștergerea notițelor",
"erase_notes_description": "Ștergerea obișnuită doar marchează notițele ca fiind șterse și pot fi recuperate (în ecranul Schimbări recente) pentru o perioadă de timp. Dacă se bifează această opțiune, notițele vor fi șterse imediat fără posibilitatea de a le recupera.",
"erase_notes_warning": "Șterge notițele permanent (nu se mai pot recupera), incluzând toate clonele. Va forța reîncărcarea aplicației.",
"no_note_to_delete": "Nicio notiță nu va fi ștearsă (doar clonele).",
"notes_to_be_deleted": "Următoarele notițe vor fi șterse ({{notesCount}})",
"ok": "OK",
"deleted_relation_text": "Notița {{- note}} ce va fi ștearsă este referențiată de relația {{- relation}}, originând din {{- source}}.",
"close": "Închide"
},
"delete_relation": {
@@ -1263,7 +1266,8 @@
"test_button": "Probează sincronizarea",
"test_description": "Această opțiune va testa conexiunea și comunicarea cu serverul de sincronizare. Dacă serverul de sincronizare nu este inițializat, acest lucru va rula și o sincronizare cu documentul local.",
"test_title": "Probează sincronizarea",
"timeout": "Timp limită de sincronizare"
"timeout": "Timp limită de sincronizare",
"timeout_unit": "milisecunde"
},
"table_of_contents": {
"description": "Cuprinsul va apărea în notițele de tip text atunci când notița are un număr de titluri mai mare decât cel definit. Acest număr se poate personaliza:",

View File

@@ -83,7 +83,10 @@
"notes_to_be_deleted": "Следующие заметки будут удалены ({{notesCount}})",
"no_note_to_delete": "Заметка не будет удалена (только клоны).",
"broken_relations_to_be_deleted": "Следующие отношения будут разорваны и удалены ({{relationCount}})",
"cancel": "Отмена"
"cancel": "Отмена",
"ok": "ОК",
"deleted_relation_text": "Примечание {{- note}} (подлежит удалению) ссылается на отношение {{- relation}}, происходящее из {{- source}}.",
"delete_notes_preview": "Предпросмотр удаляемых заметок"
},
"database_anonymization": {
"light_anonymization_description": "Это действие создаст новую копию базы данных и выполнит её лёгкую анонимизацию — в частности, будет удалён только контент всех заметок, но заголовки и атрибуты останутся. Кроме того, будут сохранены пользовательские заметки, содержащие JavaScript-скрипты frontend/backend и пользовательские виджеты. Это даёт больше контекста для отладки проблем.",
@@ -1416,6 +1419,7 @@
"no_results": "Не найдено ярлыков, соответствующих '{{filter}}'"
},
"sync_2": {
"timeout_unit": "миллисекунд",
"note": "Заметка",
"save": "Сохранить",
"help": "Помощь",

View File

@@ -76,6 +76,7 @@
"also_delete_note": "Takođe obriši belešku"
},
"delete_notes": {
"delete_notes_preview": "Obriši pregled beleške",
"close": "Zatvori",
"delete_all_clones_description": "Obriši i sve klonove (može biti poništeno u skorašnjim izmenama)",
"erase_notes_description": "Normalno (blago) brisanje samo označava beleške kao obrisane i one mogu biti vraćene (u dijalogu skorašnjih izmena) u određenom vremenskom periodu. Biranje ove opcije će momentalno obrisati beleške i ove beleške neće biti moguće vratiti.",
@@ -83,7 +84,9 @@
"notes_to_be_deleted": "Sledeće beleške će biti obrisane ({{- noteCount}})",
"no_note_to_delete": "Nijedna beleška neće biti obrisana (samo klonovi).",
"broken_relations_to_be_deleted": "Sledeći odnosi će biti prekinuti i obrisani ({{- relationCount}})",
"cancel": "Otkaži"
"cancel": "Otkaži",
"ok": "U redu",
"deleted_relation_text": "Beleška {{- note}} (za brisanje) je referencirana sa odnosom {{- relation}} koji potiče iz {{- source}}."
},
"export": {
"export_note_title": "Izvezi belešku",

View File

@@ -21,13 +21,16 @@
},
"delete_notes": {
"close": "Kapat",
"delete_notes_preview": "Not önizlemesini sil",
"delete_all_clones_description": "Tüm klonları da sil (son değişikliklerden geri alınabilir)",
"erase_notes_description": "Normal (yazılımsal) silme işlemi, notları yalnızca silinmiş olarak işaretler ve belirli bir süre içinde (son değişiklikler iletişim kutusunda) geri alınabilir. Bu seçeneği işaretlemek, notları hemen siler ve notların geri alınması mümkün olmaz.",
"erase_notes_warning": "Notları, tüm kopyaları da dahil olmak üzere kalıcı olarak silin (geri alınamaz). Bu işlem, uygulamanın yeniden yüklenmesine neden olacaktır.",
"notes_to_be_deleted": "Aşağıdaki notlar silinecektir. ({{notesCount}})",
"no_note_to_delete": "Hiçbir not silinmeyecek (sadece kopyaları silinecek).",
"broken_relations_to_be_deleted": "Aşağıdaki ilişkiler koparılacak ve silinecektir ({{ relationCount}})",
"cancel": "İptal"
"cancel": "İptal",
"ok": "Tamam",
"deleted_relation_text": "{{- note}} (silinecek) notu, {{- source}} kaynağından kaynaklanan {{- relation}} ilişkisi tarafından referans alınmaktadır."
},
"export": {
"close": "Kapat",

View File

@@ -88,6 +88,7 @@
"also_delete_note": "同時刪除筆記"
},
"delete_notes": {
"delete_notes_preview": "刪除筆記預覽",
"delete_all_clones_description": "同時刪除所有克隆(可以在最近修改中撤消)",
"erase_notes_description": "通常(軟)刪除僅標記筆記為已刪除,可以在一段時間內透過最近修改對話方塊撤消。勾選此選項將立即擦除筆記,無法撤銷。",
"erase_notes_warning": "永久擦除筆記(無法撤銷),包括所有克隆。這將強制應用程式重新載入。",
@@ -95,6 +96,8 @@
"no_note_to_delete": "沒有筆記將被刪除(僅克隆)。",
"broken_relations_to_be_deleted": "將刪除以下關聯並斷開連接 ({{ relationCount}})",
"cancel": "取消",
"ok": "確定",
"deleted_relation_text": "筆記 {{- note}}(將被刪除的筆記)被以下關聯 {{- relation}} 引用,來自 {{- source}}。",
"close": "關閉"
},
"export": {
@@ -800,10 +803,7 @@
"expand_first_level": "展開直接子級",
"expand_nth_level": "展開 {{depth}} 層",
"expand_all_levels": "展開所有層級",
"hide_child_notes": "隱藏樹中的子筆記",
"open_all_in_tabs": "全部打開",
"open_all_in_tabs_tooltip": "在新分頁中開啟所有結果",
"open_all_confirm": "這將在新分頁中開啟 {{count}} 則筆記。要繼續嗎?"
"hide_child_notes": "隱藏樹中的子筆記"
},
"edited_notes": {
"no_edited_notes_found": "今天還沒有編輯過的筆記...",
@@ -857,8 +857,7 @@
"collapse": "收摺到正常大小",
"title": "筆記地圖",
"fix-nodes": "固定節點",
"link-distance": "連結距離",
"too-many-notes": "此子樹包含 {{count}} 則筆記,已超過筆記地圖中可顯示的 {{max}} 則上限。"
"link-distance": "連結距離"
},
"note_paths": {
"title": "筆記路徑",
@@ -1063,8 +1062,7 @@
"note_already_in_diagram": "筆記 \"{{title}}\" 已經在圖中。",
"enter_title_of_new_note": "輸入新筆記的標題",
"default_new_note_title": "新筆記",
"click_on_canvas_to_place_new_note": "點擊畫布以放置新筆記",
"rename_relation": "重新命名關聯"
"click_on_canvas_to_place_new_note": "點擊畫布以放置新筆記"
},
"backend_log": {
"refresh": "重新整理"
@@ -1333,8 +1331,7 @@
"date-and-time": "日期和時間",
"path": "路徑",
"database_backed_up_to": "資料庫已備份至 {{backupFilePath}}",
"no_backup_yet": "尚無備份",
"download": "下載"
"no_backup_yet": "尚無備份"
},
"etapi": {
"title": "ETAPI",
@@ -1399,15 +1396,9 @@
"spellcheck": {
"title": "拼寫檢查",
"description": "這些選項僅適用於桌面版,瀏覽器將使用其原生的拼寫檢查功能。",
"enable": "拼寫檢查",
"language_code_label": "拼寫檢查語言",
"restart-required": "拼寫檢查選項的更改將在應用重啟後生效。",
"custom_dictionary_title": "自訂字典",
"custom_dictionary_description": "新增至字典的詞彙會同步至您所有的裝置。",
"custom_dictionary_edit": "自訂詞彙",
"custom_dictionary_edit_description": "編輯拼寫檢查器不應標記的詞彙清單。變更將於重新啟動後生效。",
"custom_dictionary_open": "編輯字典",
"related_description": "設定拼寫檢查語言及自訂字典。"
"enable": "啟用拼寫檢查",
"language_code_label": "語言代碼",
"restart-required": "拼寫檢查選項的更改將在應用重啟後生效。"
},
"sync_2": {
"config_title": "同步設定",
@@ -1423,7 +1414,7 @@
"test_description": "測試和同步伺服器之間的連接。如果同步伺服器沒有初始化,這會將本地文件同步至同步伺服器上。",
"test_button": "測試同步",
"handshake_failed": "同步伺服器握手失敗,錯誤:{{message}}",
"timeout_description": "在放棄慢速同步連線前應等待多久。若網路不穩定,請延長等待時間。"
"timeout_unit": "毫秒"
},
"api_log": {
"close": "關閉"
@@ -2294,7 +2285,7 @@
"ocr": {
"processing_complete": "OCR 處理已完成。",
"processing_failed": "無法啟動 OCR 處理",
"text_filtered_low_confidence": "OCR 偵測到的文字信賴度為 {{confidence}}%,但因您的最低閾值設定為 {{threshold}}%,故該結果已被捨棄。",
"text_filtered_low_confidence": "OCR 偵測到的信賴度為 {{confidence}}%,但因您的最低閾值設定為 {{threshold}}%,故該結果已被捨棄。",
"open_media_settings": "開啟設定",
"view_extracted_text": "檢視擷取的文字 (OCR)",
"extracted_text": "已擷取的文字 (OCR)",

View File

@@ -186,6 +186,7 @@
"also_delete_note": "Також видалити нотатку"
},
"delete_notes": {
"delete_notes_preview": "Видалити попередній перегляд нотаток",
"close": "Закрити",
"delete_all_clones_description": "Видалити також усі клони (можна скасувати в останніх змінах)",
"erase_notes_description": "Звичайне (м’яке) видалення лише позначає нотатки як видалені і їх можна відновити (у діалоговому вікні останніх змін) протягом певного періоду часу. Якщо позначити цю опцію, нотатки будуть видалені негайно і їх неможливо буде відновити.",
@@ -193,7 +194,9 @@
"notes_to_be_deleted": "Наступні нотатки будуть видалені ({{notesCount}})",
"no_note_to_delete": "Жодну нотатку не буде видалено (лише клони).",
"broken_relations_to_be_deleted": "Наступні зв'язки будуть розірвані та видалені ({{ relationCount}})",
"cancel": "Скасувати"
"cancel": "Скасувати",
"ok": "ОК",
"deleted_relation_text": "Нотатка {{- note}} (буде видалена) посилається на зв'язок {{- relation}}, що походить з {{- source}}."
},
"export": {
"export_note_title": "Експорт нотатки",
@@ -1747,6 +1750,7 @@
"config_title": "Конфігурація синхронізації",
"server_address": "Адреса екземпляра сервера",
"timeout": "Тайм-аут синхронізації",
"timeout_unit": "мілісекунди",
"proxy_label": "Синхронізація проксі-сервера (необов'язково)",
"note": "Нотатка",
"note_description": "Якщо залишити налаштування проксі-сервера порожнім, буде використано системний проксі-сервер (стосується лише збірки для ПК/електронної версії).",

View File

@@ -27,6 +27,7 @@
},
"delete_notes": {
"close": "Đóng",
"ok": "OK",
"cancel": "Huỷ"
},
"export": {

View File

@@ -87,7 +87,7 @@ function buildUserAttribute(attr: AttributeWithDefinitions): ComponentChildren {
content = <><Icon icon={value === "true" ? "bx bx-check-square" : "bx bx-square"} />{" "}<strong>{attr.friendlyName}</strong></>;
break;
case "url":
content = <a href={value} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>{attr.friendlyName}</a>;
content = <a href={value} target="_blank" rel="noopener noreferrer">{attr.friendlyName}</a>;
break;
case "color":
style = { backgroundColor: value, color: getReadableTextColor(value) };

View File

@@ -180,13 +180,11 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt
// Refresh on alterations to the note subtree.
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (note && (
loadResults.getNoteReorderings().includes(note.noteId)
|| loadResults.getBranchRows().some(branch =>
branch.parentNoteId === note.noteId
|| noteIds.includes(branch.parentNoteId ?? ""))
if (note && loadResults.getBranchRows().some(branch =>
branch.parentNoteId === note.noteId
|| noteIds.includes(branch.parentNoteId ?? ""))
|| loadResults.getAttributeRows().some(attr => attr.name === "archived" && attr.noteId && noteIds.includes(attr.noteId))
)) {
) {
refreshNoteIds();
}
});

View File

@@ -27,7 +27,7 @@ describe("Board data", () => {
froca.branches["note1_note2"] = branch;
froca.getNoteFromCache("note1")!.addChild("note2", "note1_note2", false);
const data = await getBoardData(parentNote, "status", {}, false);
const noteIds = [...data.byColumn.values()].flat().map(item => item.note.noteId);
const noteIds = Array.from(data.byColumn.values()).flat().map(item => item.note.noteId);
expect(noteIds.length).toBe(3);
});
});

View File

@@ -75,7 +75,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg)
if (dateNote.hasChildren()) {
const childNoteIds = dateNote.getChildNoteIds();
const childNoteIds = await dateNote.getSubtreeNoteIds();
for (const childNoteId of childNoteIds) {
childNoteToDateMapping[childNoteId] = startDate;
}

View File

@@ -144,12 +144,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps<CalendarVi
const event = api.getEventById(noteId);
const note = froca.getNoteFromCache(noteId);
if (!event || !note) continue;
// Only update the title if it has actually changed.
// setProp() triggers FullCalendar's eventChange callback, which would
// re-save the event's dates and cause unwanted side effects.
if (event.title !== note.title) {
event.setProp("title", note.title);
}
event.setProp("title", note.title);
}
});
@@ -304,12 +299,6 @@ function useEditing(note: FNote, isEditable: boolean, isCalendarRoot: boolean, c
}, [ note, componentId ]);
const onEventChange = useCallback(async (e: EventChangeArg) => {
// Only process actual date/time changes, not other property changes (e.g., title via setProp).
const datesChanged = e.oldEvent.start?.getTime() !== e.event.start?.getTime()
|| e.oldEvent.end?.getTime() !== e.event.end?.getTime()
|| e.oldEvent.allDay !== e.event.allDay;
if (!datesChanged) return;
const { startDate, endDate } = parseStartEndDateFromEvent(e.event);
if (!startDate) return;

View File

@@ -51,8 +51,6 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
if (type === "labels") {
if (typeof newValue === "boolean") {
newValue = newValue ? "true" : "false";
} else if (typeof newValue === "number") {
newValue = String(newValue);
}
setLabel(noteId, name, newValue);
} else if (type === "relations") {

View File

@@ -1,30 +0,0 @@
.delete-notes-dialog .tn-card {
margin-bottom: 16px;
}
.delete-notes-dialog .tn-card:last-child {
margin-bottom: 0;
}
.delete-notes-dialog .preview-list {
margin: 0;
padding: 0;
list-style: none;
max-height: 200px;
overflow: auto;
}
.delete-notes-dialog .preview-list li {
padding: 6px 16px;
border-bottom: 1px solid var(--main-border-color);
}
.delete-notes-dialog .preview-list li:last-child {
border-bottom: none;
}
.delete-notes-dialog .preview-list small {
margin-inline-start: 8px;
font-size: 0.8em;
color: var(--muted-text-color);
}

View File

@@ -1,22 +1,15 @@
import "./delete_notes.css";
import type { DeleteNotesPreview } from "@triliumnext/commons";
import { useEffect, useRef, useState } from "preact/hooks";
import froca from "../../services/froca.js";
import { useRef, useState, useEffect } from "preact/hooks";
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import Button from "../react/Button.jsx";
import { Card, CardSection } from "../react/Card.js";
import FormToggle from "../react/FormToggle.js";
import { useTriliumEvent } from "../react/hooks.jsx";
import FormCheckbox from "../react/FormCheckbox.js";
import Modal from "../react/Modal.js";
import NoteLink from "../react/NoteLink.js";
import OptionsRow from "../type_widgets/options/components/OptionsRow.js";
interface CloneInfo {
totalCloneCount: number;
}
import type { DeleteNotesPreview } from "@triliumnext/commons";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import FNote from "../../entities/fnote.js";
import link from "../../services/link.js";
import Button from "../react/Button.jsx";
import Alert from "../react/Alert.jsx";
import { useTriliumEvent } from "../react/hooks.jsx";
export interface ResolveOptions {
proceed: boolean;
@@ -31,9 +24,9 @@ interface ShowDeleteNotesDialogOpts {
}
interface BrokenRelationData {
noteId: string;
relationName: string;
sourceNoteId: string;
note: string;
relation: string;
source: string;
}
export default function DeleteNotesDialog() {
@@ -41,51 +34,20 @@ export default function DeleteNotesDialog() {
const [ deleteAllClones, setDeleteAllClones ] = useState(false);
const [ eraseNotes, setEraseNotes ] = useState(!!opts.forceDeleteAllClones);
const [ brokenRelations, setBrokenRelations ] = useState<DeleteNotesPreview["brokenRelations"]>([]);
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
const [ noteIdsToBeDeleted, setNoteIdsToBeDeleted ] = useState<DeleteNotesPreview["noteIdsToBeDeleted"]>([]);
const [ shown, setShown ] = useState(false);
const [ cloneInfo, setCloneInfo ] = useState<CloneInfo>({ totalCloneCount: 0 });
const okButtonRef = useRef<HTMLButtonElement>(null);
useTriliumEvent("showDeleteNotesDialog", (opts) => {
setOpts(opts);
setDeleteAllClones(false);
setEraseNotes(!!opts.forceDeleteAllClones);
setShown(true);
});
// Calculate clone information when branches change
useEffect(() => {
const { branchIdsToDelete } = opts;
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
setCloneInfo({ totalCloneCount: 0 });
return;
}
async function calculateCloneInfo() {
const branches = froca.getBranches(branchIdsToDelete!, true);
const uniqueNoteIds = [...new Set(branches.map(b => b.noteId))];
const notes = await froca.getNotes(uniqueNoteIds);
let totalCloneCount = 0;
for (const note of notes) {
const parentBranches = note.getParentBranches();
// Clones are additional parent branches beyond the one being deleted
const otherBranches = parentBranches.filter(b => !branchIdsToDelete!.includes(b.branchId));
totalCloneCount += otherBranches.length;
}
setCloneInfo({ totalCloneCount });
}
calculateCloneInfo();
}, [opts.branchIdsToDelete]);
})
useEffect(() => {
const { branchIdsToDelete, forceDeleteAllClones } = opts;
if (!branchIdsToDelete || branchIdsToDelete.length === 0) {
return;
}
}
server.post<DeleteNotesPreview>("delete-notes-preview", {
branchIdsToDelete,
@@ -101,16 +63,16 @@ export default function DeleteNotesDialog() {
className="delete-notes-dialog"
size="xl"
scrollable
title={t("delete_notes.title")}
title={t("delete_notes.delete_notes_preview")}
onShown={() => okButtonRef.current?.focus()}
onHidden={() => {
opts.callback?.({ proceed: false });
opts.callback?.({ proceed: false })
setShown(false);
}}
footer={<>
<Button text={t("delete_notes.cancel")}
onClick={() => setShown(false)} />
<Button text={t("delete_notes.delete")} kind="primary"
<Button text={t("delete_notes.ok")} kind="primary"
buttonRef={okButtonRef}
onClick={() => {
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });
@@ -119,117 +81,92 @@ export default function DeleteNotesDialog() {
</>}
show={shown}
>
<Card>
<CardSection>
<DeleteAllClonesOption
cloneInfo={cloneInfo}
deleteAllClones={deleteAllClones}
setDeleteAllClones={setDeleteAllClones}
/>
<OptionsRow
name="erase-notes"
label={t("delete_notes.erase_notes_label")}
description={t("delete_notes.erase_notes_description")}
>
<FormToggle
disabled={opts.forceDeleteAllClones}
currentValue={eraseNotes}
onChange={setEraseNotes}
/>
</OptionsRow>
</CardSection>
</Card>
<FormCheckbox name="delete-all-clones" label={t("delete_notes.delete_all_clones_description")}
currentValue={deleteAllClones} onChange={setDeleteAllClones}
/>
<FormCheckbox
name="erase-notes" label={t("delete_notes.erase_notes_warning")}
disabled={opts.forceDeleteAllClones}
currentValue={eraseNotes} onChange={setEraseNotes}
/>
<BrokenRelations brokenRelations={brokenRelations} />
<DeletedNotes noteIdsToBeDeleted={noteIdsToBeDeleted} />
<BrokenRelations brokenRelations={brokenRelations} />
</Modal>
);
}
interface DeleteAllClonesOptionProps {
cloneInfo: CloneInfo;
deleteAllClones: boolean;
setDeleteAllClones: (value: boolean) => void;
}
function DeleteAllClonesOption({ cloneInfo, deleteAllClones, setDeleteAllClones }: DeleteAllClonesOptionProps) {
const { totalCloneCount } = cloneInfo;
if (totalCloneCount === 0) {
return null;
}
return (
<OptionsRow
name="delete-all-clones"
label={t("delete_notes.clones_label")}
description={t("delete_notes.delete_clones_description", { count: totalCloneCount })}
>
<FormToggle
currentValue={deleteAllClones}
onChange={setDeleteAllClones}
/>
</OptionsRow>
);
}
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>
) : (
<span className="muted-text">{t("delete_notes.no_note_to_delete")}</span>
)}
</CardSection>
</Card>
);
const [ noteLinks, setNoteLinks ] = useState<string[]>([]);
useEffect(() => {
froca.getNotes(noteIdsToBeDeleted).then(async (notes: FNote[]) => {
const noteLinks: string[] = [];
for (const note of notes) {
noteLinks.push((await link.createLink(note.noteId, { showNotePath: true })).html());
}
setNoteLinks(noteLinks);
});
}, [noteIdsToBeDeleted]);
if (noteIdsToBeDeleted.length) {
return (
<div className="delete-notes-list-wrapper" style={{paddingTop: "16px"}}>
<h4>{t("delete_notes.notes_to_be_deleted", { notesCount: noteIdsToBeDeleted.length })}</h4>
<ul className="delete-notes-list" style={{ maxHeight: "200px", overflow: "auto"}}>
{noteLinks.map((link, index) => (
<li key={index} dangerouslySetInnerHTML={{ __html: link }} />
))}
</ul>
</div>
);
} else {
return (
<Alert type="info">
{t("delete_notes.no_note_to_delete")}
</Alert>
)
}
}
function BrokenRelations({ brokenRelations }: { brokenRelations: DeleteNotesPreview["brokenRelations"] }) {
if (!brokenRelations.length) {
return null;
const [ notesWithBrokenRelations, setNotesWithBrokenRelations ] = useState<BrokenRelationData[]>([]);
useEffect(() => {
const noteIds = brokenRelations
.map(relation => relation.noteId)
.filter(noteId => noteId) as string[];
froca.getNotes(noteIds).then(async () => {
const notesWithBrokenRelations: BrokenRelationData[] = [];
for (const attr of brokenRelations) {
notesWithBrokenRelations.push({
note: (await link.createLink(attr.value)).html(),
relation: `<code>${attr.name}</code>`,
source: (await link.createLink(attr.noteId)).html()
});
}
setNotesWithBrokenRelations(notesWithBrokenRelations);
});
}, [brokenRelations]);
if (brokenRelations.length) {
return (
<Alert type="danger" title={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
<ul className="broken-relations-list" style={{ maxHeight: "200px", overflow: "auto" }}>
{brokenRelations.map((_, index) => {
return (
<li key={index}>
<span dangerouslySetInnerHTML={{ __html: t("delete_notes.deleted_relation_text", notesWithBrokenRelations[index] as unknown as Record<string, string>) }} />
</li>
);
})}
</ul>
</Alert>
);
} else {
return <></>;
}
const relationsData: BrokenRelationData[] = brokenRelations
.filter((attr) => attr.value && attr.noteId)
.map((attr) => ({
noteId: attr.value!,
relationName: attr.name,
sourceNoteId: attr.noteId!
}));
return (
<Card heading={t("delete_notes.broken_relations_to_be_deleted", { relationCount: brokenRelations.length })}>
<CardSection noPadding>
<div style={{ overflowX: "auto" }}>
<table className="table table-striped">
<thead>
<tr>
<th>{t("delete_notes.table_note_with_relation")}</th>
<th>{t("delete_notes.table_relation")}</th>
<th>{t("delete_notes.table_points_to")}</th>
</tr>
</thead>
<tbody>
{relationsData.map((relation, index) => (
<tr key={index}>
<td><NoteLink notePath={relation.sourceNoteId} showNoteIcon /></td>
<td><code>{relation.relationName}</code></td>
<td><NoteLink notePath={relation.noteId} showNoteIcon /></td>
</tr>
))}
</tbody>
</table>
</div>
</CardSection>
</Card>
);
}

View File

@@ -8,7 +8,7 @@ import Button from "../react/Button";
import { Suggestion, triggerRecentNotes } from "../../services/note_autocomplete";
import tree from "../../services/tree";
import froca from "../../services/froca";
import { useTriliumEvent, useTriliumOption } from "../react/hooks";
import { useTriliumEvent } from "../react/hooks";
import { type BoxSize, CKEditorApi } from "../type_widgets/text/CKEditorWithWatchdog";
export interface IncludeNoteOpts {
@@ -18,13 +18,11 @@ export interface IncludeNoteOpts {
export default function IncludeNoteDialog() {
const editorApiRef = useRef<CKEditorApi>(null);
const [suggestion, setSuggestion] = useState<Suggestion | null>(null);
const [defaultBoxSize, setDefaultBoxSize] = useTriliumOption("includeNoteDefaultBoxSize");
const [boxSize, setBoxSize] = useState<string>(defaultBoxSize);
const [boxSize, setBoxSize] = useState<string>("medium");
const [shown, setShown] = useState(false);
useTriliumEvent("showIncludeNoteDialog", ({ editorApi }) => {
editorApiRef.current = editorApi;
setBoxSize(defaultBoxSize); // Reset to default when opening dialog
setShown(true);
});
@@ -37,14 +35,10 @@ export default function IncludeNoteDialog() {
size="lg"
onShown={() => triggerRecentNotes(autoCompleteRef.current)}
onHidden={() => setShown(false)}
onSubmit={async () => {
onSubmit={() => {
if (!suggestion?.notePath || !editorApiRef.current) return;
setShown(false);
await includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
// Save the selected box size as the new default
if (boxSize !== defaultBoxSize) {
setDefaultBoxSize(boxSize);
}
includeNote(suggestion.notePath, editorApiRef.current, boxSize as BoxSize);
}}
footer={<Button text={t("include_note.button_include")} keyboardShortcut="Enter" />}
show={shown}
@@ -69,7 +63,6 @@ export default function IncludeNoteDialog() {
{ label: t("include_note.box_size_small"), value: "small" },
{ label: t("include_note.box_size_medium"), value: "medium" },
{ label: t("include_note.box_size_full"), value: "full" },
{ label: t("include_note.box_size_expandable"), value: "expandable" },
]}
/>
</FormGroup>

View File

@@ -80,19 +80,9 @@ export default function JumpToNoteDialogComponent() {
break;
}
$autoComplete.trigger("focus");
if (mode === "commands") {
// In command mode, place caret at end instead of selecting all text
// This preserves the ">" prefix when the user starts typing
const input = autocompleteRef.current;
if (input) {
const len = input.value.length;
input.setSelectionRange(len, len);
}
} else {
$autoComplete.trigger("select");
}
$autoComplete
.trigger("focus")
.trigger("select");
// Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {

View File

@@ -9,6 +9,7 @@ import appContext, { type EventData } from "../components/app_context.js";
import type FNote from "../entities/fnote.js";
import attributeService from "../services/attributes.js";
import { t } from "../services/i18n.js";
import katex from "../services/math.js";
import options from "../services/options.js";
import OnClickButtonWidget from "./buttons/onclick_button.js";
import RightPanelWidget from "./right_panel_widget.js";
@@ -124,6 +125,77 @@ export default class HighlightsListWidget extends RightPanelWidget {
this.triggerCommand("reEvaluateRightPaneVisibility");
}
extractOuterTag(htmlStr: string | null) {
if (htmlStr === null) {
return null;
}
// Regular expressions that match only the outermost tag
const regex = /^<([a-zA-Z]+)([^>]*)>/;
const match = htmlStr.match(regex);
if (match) {
const tagName = match[1].toLowerCase(); // Extract tag name
const attributes = match[2].trim(); // Extract label attributes
return { tagName, attributes };
}
return null;
}
areOuterTagsConsistent(str1: string | null, str2: string | null) {
const tag1 = this.extractOuterTag(str1);
const tag2 = this.extractOuterTag(str2);
// If one of them has no label, returns false
if (!tag1 || !tag2) {
return false;
}
// Compare tag names and attributes to see if they are the same
return tag1.tagName === tag2.tagName && tag1.attributes === tag2.attributes;
}
/**
* Rendering formulas in strings using katex
*
* @param html Note's html content
* @returns The HTML content with mathematical formulas rendered by KaTeX.
*/
async replaceMathTextWithKatax(html: string) {
const mathTextRegex = /<span class="math-tex">\\\(([\s\S]*?)\\\)<\/span>/g;
const matches = [...html.matchAll(mathTextRegex)];
let modifiedText = html;
if (matches.length > 0) {
// Process all matches asynchronously
for (const match of matches) {
const latexCode = match[1];
let rendered;
try {
rendered = katex.renderToString(latexCode, {
throwOnError: false
});
} catch (e) {
if (e instanceof ReferenceError && e.message.includes("katex is not defined")) {
// Load KaTeX if it is not already loaded
try {
rendered = katex.renderToString(latexCode, {
throwOnError: false
});
} catch (renderError) {
console.error("KaTeX rendering error after loading library:", renderError);
rendered = match[0]; // Fall back to original if error persists
}
} else {
console.error("KaTeX rendering error:", e);
rendered = match[0]; // Fall back to original on error
}
}
// Replace the matched formula in the modified text
modifiedText = modifiedText.replace(match[0], rendered);
}
}
return modifiedText;
}
async getHighlightList(content: string, optionsHighlightsList: string[]) {
// matches a span containing background-color
const regex1 = /<span[^>]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi;
@@ -167,6 +239,9 @@ export default class HighlightsListWidget extends RightPanelWidget {
const $highlightsList = $("<ol>");
let prevEndIndex = -1,
hlLiCount = 0;
let prevSubHtml: string | null = null;
// Used to determine if a string is only a formula
const onlyMathRegex = /^<span class="math-tex">\\\([^\)]*?\)<\/span>(?:<span class="math-tex">\\\([^\)]*?\)<\/span>)*$/;
for (let match: RegExpMatchArray | null = null, hltIndex = 0; (match = combinedRegex.exec(content)) !== null; hltIndex++) {
const subHtml = match[0];
@@ -182,14 +257,25 @@ export default class HighlightsListWidget extends RightPanelWidget {
// If the previous element is connected to this element in HTML, then concatenate them into one.
$highlightsList.children().last().append(subHtml);
} else {
// TODO: can't be done with $(subHtml).text()?
//Cant remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
//const hasText = [...subHtml.matchAll(/(?<=^|>)[^><]+?(?=<|$)/g)].map(matchTmp => matchTmp[0]).join('').trim();
const hasText = $(subHtml).text().trim();
if (hasText) {
$highlightsList.append(
$("<li>")
.html(subHtml)
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
const substring = content.substring(prevEndIndex, startIndex);
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
const $lastLi = $highlightsList.children("li").last();
$lastLi.append(await this.replaceMathTextWithKatax(substring));
$lastLi.append(subHtml);
} else {
$highlightsList.append(
$("<li>")
.html(subHtml)
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
}
hlLiCount++;
} else {
@@ -198,6 +284,7 @@ export default class HighlightsListWidget extends RightPanelWidget {
}
}
prevEndIndex = endIndex;
prevSubHtml = subHtml;
}
return {
$highlightsList,

View File

@@ -2,13 +2,10 @@ import "./CollectionProperties.css";
import { t } from "i18next";
import { ComponentChildren } from "preact";
import { useRef, useState } from "preact/hooks";
import { useRef } from "preact/hooks";
import FNote from "../../entities/fnote";
import appContext from "../../components/app_context";
import dialogService from "../../services/dialog";
import { ViewTypeOptions } from "../collections/interface";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { useNoteProperty, useTriliumEvent } from "../react/hooks";
@@ -27,8 +24,6 @@ export const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
presentation: "bx bx-rectangle"
};
const MAX_OPEN_TABS = 50;
export default function CollectionProperties({ note, centerChildren, rightChildren }: {
note: FNote;
centerChildren?: ComponentChildren;
@@ -36,7 +31,6 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
}) {
const [ viewType, setViewType ] = useViewType(note);
const noteType = useNoteProperty(note, "type");
const [ isOpening, setIsOpening ] = useState(false);
return ([ "book", "search" ].includes(noteType ?? "") &&
<div className="collection-properties">
@@ -49,59 +43,11 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
</div>
<div className="right-container">
{rightChildren}
{noteType === "search" && (
<OpenAllButton note={note} isOpening={isOpening} setIsOpening={setIsOpening} />
)}
</div>
</div>
);
}
function OpenAllButton({ note, isOpening, setIsOpening }: {
note: FNote;
isOpening: boolean;
setIsOpening: (value: boolean) => void;
}) {
const noteIds = note.getChildNoteIds();
const count = noteIds.length;
const handleOpenAll = async () => {
if (count === 0) return;
if (count > MAX_OPEN_TABS) {
await dialogService.info(t("book_properties.open_all_too_many", { count, max: MAX_OPEN_TABS }));
return;
}
if (count > 10) {
const confirmed = await dialogService.confirm(t("book_properties.open_all_confirm", { count }));
if (!confirmed) return;
}
setIsOpening(true);
try {
for (let i = 0; i < noteIds.length; i++) {
const noteId = noteIds[i];
const isLast = i === noteIds.length - 1;
await appContext.tabManager.openTabWithNoteWithHoisting(noteId, {
activate: isLast
});
}
} finally {
setIsOpening(false);
}
};
return (
<ActionButton
icon={isOpening ? "bx bx-loader-alt bx-spin" : "bx bx-window-open"}
text={t("book_properties.open_all_in_tabs_tooltip")}
onClick={handleOpenAll}
disabled={count === 0 || isOpening}
/>
);
}
function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) {
// Keyboard shortcut
const dropdownContainerRef = useRef<HTMLDivElement>(null);

View File

@@ -42,11 +42,8 @@ export default function NoteIcon() {
setIcon(note?.getIcon());
}, [ note, iconClass, workspaceIconClass ]);
const isDisabled = viewScope?.viewMode !== "default"
|| note?.isMetadataReadOnly;
if (isMobile()) {
return <MobileNoteIconSwitcher note={note} icon={icon} disabled={isDisabled} />;
return <MobileNoteIconSwitcher note={note} icon={icon} />;
}
return (
@@ -58,17 +55,16 @@ export default function NoteIcon() {
dropdownOptions={{ autoClose: "outside" }}
buttonClassName={`note-icon tn-focusable-button ${icon ?? "bx bx-empty"}`}
hideToggleArrow
disabled={isDisabled}
disabled={viewScope?.viewMode !== "default"}
>
{ note && <NoteIconList note={note} onHide={() => dropdownRef?.current?.hide()} columnCount={12} /> }
</Dropdown>
);
}
function MobileNoteIconSwitcher({ note, icon, disabled }: {
function MobileNoteIconSwitcher({ note, icon }: {
note: FNote | null | undefined;
icon: string | null | undefined;
disabled?: boolean;
}) {
const [ modalShown, setModalShown ] = useState(false);
const { windowWidth } = useWindowSize();
@@ -80,7 +76,6 @@ function MobileNoteIconSwitcher({ note, icon, disabled }: {
icon={icon ?? "bx bx-empty"}
text={t("note_icon.change_note_icon")}
onClick={() => setModalShown(true)}
disabled={disabled}
/>
{createPortal((

View File

@@ -1,5 +1,5 @@
.note-detail-note-map {
height: 100%;
height: 100%;
overflow: hidden;
}
@@ -54,4 +54,4 @@
width: 10px;
}
/* End of styling the slider */
/* End of styling the slider */

View File

@@ -12,15 +12,11 @@ import { t } from "../../services/i18n";
import { getEffectiveThemeStyle } from "../../services/theme";
import ActionButton from "../react/ActionButton";
import { useElementSize, useNoteLabel } from "../react/hooks";
import NoItems from "../react/NoItems";
import Slider from "../react/Slider";
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
import { CssData, setupRendering } from "./rendering";
import { MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
/** Maximum number of notes to render in the note map before showing a warning. */
const MAX_NOTES_THRESHOLD = 1_000;
interface NoteMapProps {
note: FNote;
widgetMode: NoteMapWidgetMode;
@@ -38,7 +34,6 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
const containerSize = useElementSize(parentRef);
const [ fixNodes, setFixNodes ] = useState(false);
const [ linkDistance, setLinkDistance ] = useState(40);
const [ tooManyNotes, setTooManyNotes ] = useState<number | null>(null);
const notesAndRelationsRef = useRef<NotesAndRelationsData>();
const mapRootId = useMemo(() => {
@@ -66,14 +61,6 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
const includeRelations = labelValues("mapIncludeRelation");
loadNotesAndRelations(mapRootId, excludeRelations, includeRelations, mapType).then((notesAndRelations) => {
if (!containerRef.current || !styleResolverRef.current) return;
// Guard against rendering too many notes which would freeze the browser.
if (notesAndRelations.nodes.length > MAX_NOTES_THRESHOLD) {
setTooManyNotes(notesAndRelations.nodes.length);
return;
}
setTooManyNotes(null);
const cssData = getCssData(containerRef.current, styleResolverRef.current);
// Configure rendering properties.
@@ -132,12 +119,6 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
});
}, [ fixNodes, mapType ]);
if (tooManyNotes) {
return (
<NoItems icon="bx bx-error-circle" text={t("note_map.too-many-notes", { count: tooManyNotes, max: MAX_NOTES_THRESHOLD })} />
);
}
return (
<div className="note-map-widget">
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">

View File

@@ -1,16 +1,15 @@
import "./note_title.css";
import clsx from "clsx";
import { useEffect, useRef, useState } from "preact/hooks";
import appContext from "../components/app_context";
import branches from "../services/branches";
import { t } from "../services/i18n";
import protected_session_holder from "../services/protected_session_holder";
import server from "../services/server";
import { isIMEComposing } from "../services/shortcuts";
import FormTextBox from "./react/FormTextBox";
import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks";
import protected_session_holder from "../services/protected_session_holder";
import server from "../services/server";
import "./note_title.css";
import { isLaunchBarConfig } from "../services/utils";
import appContext from "../components/app_context";
import branches from "../services/branches";
import { isIMEComposing } from "../services/shortcuts";
import clsx from "clsx";
export default function NoteTitleWidget(props: {className?: string}) {
const { note, noteId, componentId, viewScope, noteContext, parentComponent } = useNoteContext();
@@ -26,7 +25,8 @@ export default function NoteTitleWidget(props: {className?: string}) {
const isReadOnly = note === null
|| note === undefined
|| (note.isProtected && !protected_session_holder.isProtectedSessionAvailable())
|| note.isMetadataReadOnly
|| isLaunchBarConfig(note.noteId)
|| note.noteId.startsWith("_help_")
|| viewScope?.viewMode !== "default";
setReadOnly(isReadOnly);
}, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]);
@@ -58,29 +58,11 @@ export default function NoteTitleWidget(props: {className?: string}) {
// Manage focus.
const textBoxRef = useRef<HTMLInputElement>(null);
const isNewNote = useRef<boolean>();
const pendingSelect = useRef<boolean>(false);
// Re-apply selection when title changes if we have a pending select.
// This handles the case where the server sends back entity changes after we've
// already called select(), which causes the controlled input to re-render and lose selection.
useEffect(() => {
if (pendingSelect.current && textBoxRef.current && document.activeElement === textBoxRef.current) {
textBoxRef.current.select();
pendingSelect.current = false;
}
}, [title]);
useTriliumEvents([ "focusOnTitle", "focusAndSelectTitle" ], (e, eventName) => {
if (noteContext?.isActive() && textBoxRef.current) {
// In the new layout, there are two NoteTitleWidget instances. Only handle if visible.
if (!textBoxRef.current.checkVisibility({ checkOpacity: true })) {
return;
}
textBoxRef.current.focus();
if (eventName === "focusAndSelectTitle") {
textBoxRef.current.select();
pendingSelect.current = true;
}
isNewNote.current = ("isNewNote" in e ? e.isNewNote : false);
}
@@ -101,9 +83,6 @@ export default function NoteTitleWidget(props: {className?: string}) {
spacedUpdate.scheduleUpdate();
}}
onKeyDown={(e) => {
// User started typing, stop re-applying selection
pendingSelect.current = false;
// Skip processing if IME is composing to prevent interference
// with text input in CJK languages
if (isIMEComposing(e)) {
@@ -122,7 +101,6 @@ export default function NoteTitleWidget(props: {className?: string}) {
}
}}
onBlur={() => {
pendingSelect.current = false;
spacedUpdate.updateNowIfNecessary();
isNewNote.current = false;
}}

View File

@@ -35,14 +35,6 @@
flex-direction: column;
gap: var(--card-section-gap);
.tn-card-section.tn-no-padding {
padding: 0;
& .table {
margin-bottom: 0;
}
}
.tn-card-section {
&:first-of-type {
border-top-left-radius: var(--card-border-radius);

View File

@@ -50,7 +50,6 @@ export interface CardSectionProps {
subSectionsVisible?: boolean;
highlightOnHover?: boolean;
onAction?: () => void;
noPadding?: boolean;
}
interface CardSectionContextType {
@@ -66,8 +65,7 @@ export function CardSection(props: {children: ComponentChildren} & CardSectionPr
return <>
<section className={clsx("tn-card-section", props.className, {
"tn-card-section-nested": nestingLevel > 0,
"tn-card-highlight-on-hover": props.highlightOnHover || props.onAction,
"tn-no-padding": props.noPadding
"tn-card-highlight-on-hover": props.highlightOnHover || props.onAction
})}
style={{"--tn-card-section-nesting-level": (nestingLevel) ? nestingLevel : null}}
onClick={props.onAction}>

View File

@@ -7,22 +7,17 @@ import { ComponentChildren } from "preact";
interface FormToggleProps {
currentValue: boolean | null;
onChange(newValue: boolean): void;
/** Label shown when toggle is off. If omitted along with switchOffName, no label is shown. */
switchOnName?: string;
switchOnName: string;
switchOnTooltip?: string;
/** Label shown when toggle is on. If omitted along with switchOnName, no label is shown. */
switchOffName?: string;
switchOffName: string;
switchOffTooltip?: string;
helpPage?: string;
disabled?: boolean;
afterName?: ComponentChildren;
/** ID for the input element, useful for accessibility with external labels */
id?: string;
}
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled, afterName, id }: FormToggleProps) {
export default function FormToggle({ currentValue, helpPage, switchOnName, switchOnTooltip, switchOffName, switchOffTooltip, onChange, disabled, afterName }: FormToggleProps) {
const [ disableTransition, setDisableTransition ] = useState(true);
const hasLabel = switchOnName || switchOffName;
useEffect(() => {
const timeout = setTimeout(() => {
@@ -33,7 +28,7 @@ export default function FormToggle({ currentValue, helpPage, switchOnName, switc
return (
<div className="switch-widget">
{hasLabel && <span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>}
<span className="switch-name">{ currentValue ? switchOffName : switchOnName }</span>
{ afterName }
<label>
@@ -42,7 +37,6 @@ export default function FormToggle({ currentValue, helpPage, switchOnName, switc
title={currentValue ? switchOffTooltip : switchOnTooltip }
>
<input
id={id}
className="switch-toggle"
type="checkbox"
checked={currentValue === true}

View File

@@ -15,7 +15,6 @@ import attributes from "../../services/attributes";
import froca from "../../services/froca";
import keyboard_actions from "../../services/keyboard_actions";
import { ViewScope } from "../../services/link";
import math from "../../services/math";
import options, { type OptionValue } from "../../services/options";
import protected_session_holder from "../../services/protected_session_holder";
import server from "../../services/server";
@@ -826,43 +825,13 @@ export function useWindowSize() {
return size;
}
// Workaround for https://github.com/twbs/bootstrap/issues/37474
// Bootstrap's dispose() sets ALL properties to null. But pending animation callbacks
// (scheduled via setTimeout) can still fire and crash when accessing null properties.
// We patch dispose() to set safe placeholder values instead of null.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TooltipProto = Tooltip.prototype as any;
const originalDispose = TooltipProto.dispose;
const disposedTooltipPlaceholder = {
activeTrigger: {},
element: document.createElement("noscript")
};
TooltipProto.dispose = function () {
originalDispose.call(this);
// After disposal, set safe values so pending callbacks don't crash
this._activeTrigger = disposedTooltipPlaceholder.activeTrigger;
this._element = disposedTooltipPlaceholder.element;
};
export function useTooltip(elRef: RefObject<HTMLElement>, config: Partial<Tooltip.Options>) {
useEffect(() => {
if (!elRef?.current) return;
const element = elRef.current;
const $el = $(element);
// Dispose any existing tooltip before creating a new one
Tooltip.getInstance(element)?.dispose();
const $el = $(elRef.current);
$el.tooltip("dispose");
$el.tooltip(config);
// Capture the tooltip instance now, since elRef.current may be null during cleanup.
const tooltip = Tooltip.getInstance(element);
return () => {
if (element.isConnected) {
tooltip?.dispose();
}
};
}, [ elRef, config ]);
const showTooltip = useCallback(() => {
@@ -897,14 +866,8 @@ export function useStaticTooltip(elRef: RefObject<Element>, config?: Partial<Too
const hasTooltip = config?.title || elRef.current?.getAttribute("title");
if (!elRef?.current || !hasTooltip) return;
// Capture element now, since elRef.current may be null during cleanup.
const element = elRef.current;
// Dispose any existing tooltip before creating a new one
Tooltip.getInstance(element)?.dispose();
const tooltip = new Tooltip(element, config);
element.addEventListener("show.bs.tooltip", () => {
const tooltip = Tooltip.getOrCreateInstance(elRef.current, config);
elRef.current.addEventListener("show.bs.tooltip", () => {
// Hide all the other tooltips.
for (const otherTooltip of tooltips) {
if (otherTooltip === tooltip) continue;
@@ -915,11 +878,12 @@ export function useStaticTooltip(elRef: RefObject<Element>, config?: Partial<Too
return () => {
tooltips.delete(tooltip);
if (element.isConnected) {
tooltip.dispose();
}
tooltip.dispose();
// workaround for https://github.com/twbs/bootstrap/issues/37474
(tooltip as any)._activeTrigger = {};
(tooltip as any)._element = document.createElement('noscript'); // placeholder with no behavior
// Remove any lingering tooltip popup elements from the DOM.
// Remove *all* tooltip elements from the DOM
document
.querySelectorAll('.tooltip')
.forEach(t => t.remove());
@@ -1436,38 +1400,3 @@ export function useColorScheme() {
return prefersDark ? "dark" : "light";
}
/**
* Renders math equations within elements that have the `.math-tex` class.
* Used by sidebar widgets like Table of Contents and Highlights list to display math content.
*
* @param containerRef - Ref to the container element that may contain math elements
* @param deps - Dependencies that trigger re-rendering (e.g., text content)
*/
export function useMathRendering(containerRef: RefObject<HTMLElement>, deps: unknown[]) {
useEffect(() => {
if (!containerRef.current) return;
// Support both read-only (.math-tex) and CKEditor editing view (.ck-math-tex) classes
const mathElements = containerRef.current.querySelectorAll(".math-tex, .ck-math-tex");
for (const mathEl of mathElements) {
// Skip if already rendered by KaTeX
if (mathEl.querySelector(".katex")) continue;
try {
let equation = mathEl.textContent || "";
// CKEditor widgets store equation without delimiters, add them for KaTeX
if (mathEl.classList.contains("ck-math-tex")) {
// Check if it's display mode or inline
const isDisplay = mathEl.classList.contains("ck-math-tex-display");
equation = isDisplay ? `\\[${equation}\\]` : `\\(${equation}\\)`;
}
math.render(equation, mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math:", e);
}
}
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import { extractHighlightsFromStaticHtml } from "./HighlightsList.js";
describe("extractHighlightsFromStaticHtml", () => {
it("extracts a single highlight containing text and math equation together", () => {
const container = document.createElement("div");
container.innerHTML = `<p>
<span style="background-color:hsl(30,75%,60%);">
Highlighted&nbsp;
<span class="math-tex">
\\(e=mc^2\\)
</span>
&nbsp;math
</span>
</p>`;
document.body.appendChild(container);
const highlights = extractHighlightsFromStaticHtml(container);
// Should extract 1 combined highlight, not 3 separate ones
expect(highlights.length).toBe(1);
// The highlight should contain the full innerHTML of the styled span
const highlight = highlights[0];
expect(highlight.text).toContain("Highlighted");
expect(highlight.text).toContain("math-tex");
expect(highlight.text).toContain("e=mc^2");
expect(highlight.text).toContain("math");
expect(highlight.attrs.background).toBeTruthy();
document.body.removeChild(container);
});
it("extracts separate highlights for differently styled spans", () => {
const container = document.createElement("div");
container.innerHTML = `<p>
<span style="background-color:yellow;">Yellow text</span>
normal text
<span style="background-color:red;">Red text</span>
</p>`;
document.body.appendChild(container);
const highlights = extractHighlightsFromStaticHtml(container);
// Should extract 2 separate highlights (yellow and red)
expect(highlights.length).toBe(2);
expect(highlights[0].text).toBe("Yellow text");
expect(highlights[1].text).toBe("Red text");
document.body.removeChild(container);
});
});

View File

@@ -1,12 +1,11 @@
import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5";
import { createPortal } from "preact/compat";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import { randomString } from "../../services/utils";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useMathRendering, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks";
import Modal from "../react/Modal";
import RawHtml from "../react/RawHtml";
import { HighlightsListOptions } from "../type_widgets/options/text_notes";
import RightPanelWidget from "./RightPanelWidget";
@@ -85,11 +84,20 @@ function AbstractHighlightsList<T extends RawHighlight>({ highlights, scrollToHi
{filteredHighlights.length > 0 ? (
<ol>
{filteredHighlights.map(highlight => (
<HighlightItem
<li
key={highlight.id}
highlight={highlight}
onClick={() => scrollToHighlight(highlight)}
/>
>
<span
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
>{highlight.text}</span>
</li>
))}
</ol>
) : (
@@ -104,31 +112,6 @@ function AbstractHighlightsList<T extends RawHighlight>({ highlights, scrollToHi
);
}
function HighlightItem<T extends RawHighlight>({ highlight, onClick }: {
highlight: T;
onClick(): void;
}) {
const contentRef = useRef<HTMLElement>(null);
useMathRendering(contentRef, [highlight.text]);
return (
<li onClick={onClick}>
<RawHtml
containerRef={contentRef}
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
html={highlight.text}
/>
</li>
);
}
//#region Editable text (CKEditor)
interface CKHighlight extends RawHighlight {
textNode: ModelText;
@@ -218,24 +201,9 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) {
};
if (Object.values(attrs).some(Boolean)) {
// Get HTML content from DOM (includes nested elements like math)
let html = item.data;
try {
const modelPos = editor.model.createPositionAt(item.textNode, "before");
const viewPos = editor.editing.mapper.toViewPosition(modelPos);
const domPos = editor.editing.view.domConverter.viewPositionToDom(viewPos);
if (domPos?.parent instanceof HTMLElement) {
// Get the formatting span's innerHTML (includes math elements)
html = domPos.parent.innerHTML;
}
} catch {
// During change:data events, the view may not be fully synchronized with the model.
// Fall back to using the raw text data.
}
result.push({
id: randomString(),
text: html,
text: item.data,
attrs,
textNode: item.textNode,
offset: item.startOffset
@@ -267,65 +235,47 @@ function ReadOnlyTextHighlightsList() {
/>;
}
export function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
if (!el) return [];
const { color: defaultColor, backgroundColor: defaultBackgroundColor } = getComputedStyle(el);
const walker = document.createTreeWalker(
el,
NodeFilter.SHOW_TEXT,
null
);
const highlights: DomHighlight[] = [];
const processedElements = new Set<Element>();
// Find all elements with inline background-color or color styles
const styledElements = el.querySelectorAll<HTMLElement>('[style*="background-color"], [style*="color"]');
let node: Node | null;
while ((node = walker.nextNode())) {
const el = node.parentElement;
if (!el || !node.textContent?.trim()) continue;
for (const styledEl of styledElements) {
if (processedElements.has(styledEl)) continue;
if (!styledEl.textContent?.trim()) continue;
const style = getComputedStyle(el);
const attrs: RawHighlight["attrs"] = {
bold: !!styledEl.closest("strong"),
italic: !!styledEl.closest("em"),
underline: !!styledEl.closest("u"),
background: styledEl.style.backgroundColor,
color: styledEl.style.color
};
if (
el.closest('strong, em, u') ||
style.color !== defaultColor ||
style.backgroundColor !== defaultBackgroundColor
) {
const attrs: RawHighlight["attrs"] = {
bold: !!el.closest("strong"),
italic: !!el.closest("em"),
underline: !!el.closest("u"),
background: el.style.backgroundColor,
color: el.style.color
};
if (Object.values(attrs).some(Boolean)) {
processedElements.add(styledEl);
highlights.push({
id: randomString(),
text: styledEl.innerHTML,
element: styledEl,
attrs
});
}
}
// Also find bold, italic, underline elements
const formattingElements = el.querySelectorAll<HTMLElement>("strong, em, u, b, i");
for (const formattedEl of formattingElements) {
// Skip if already processed or inside a processed element
if (processedElements.has(formattedEl)) continue;
if (Array.from(processedElements).some(processed => processed.contains(formattedEl))) continue;
if (!formattedEl.textContent?.trim()) continue;
const attrs: RawHighlight["attrs"] = {
bold: formattedEl.matches("strong, b"),
italic: formattedEl.matches("em, i"),
underline: formattedEl.matches("u"),
background: formattedEl.style.backgroundColor,
color: formattedEl.style.color
};
if (Object.values(attrs).some(Boolean)) {
processedElements.add(formattedEl);
highlights.push({
id: randomString(),
text: formattedEl.innerHTML,
element: formattedEl,
attrs
});
if (Object.values(attrs).some(Boolean)) {
highlights.push({
id: randomString(),
text: node.textContent,
element: el,
attrs
});
}
}
}

View File

@@ -5,8 +5,9 @@ import clsx from "clsx";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import math from "../../services/math";
import { randomString } from "../../services/utils";
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useMathRendering, useNoteProperty, useTextEditor } from "../react/hooks";
import { useActiveNoteContext, useContentElement, useGetContextData, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
import Icon from "../react/Icon";
import RawHtml from "../react/RawHtml";
import RightPanelWidget from "./RightPanelWidget";
@@ -83,7 +84,19 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: {
const isActive = heading.id === activeHeadingId;
const contentRef = useRef<HTMLElement>(null);
useMathRendering(contentRef, [heading.text]);
// Render math equations after component mounts/updates
useEffect(() => {
if (!contentRef.current) return;
const mathElements = contentRef.current.querySelectorAll(".ck-math-tex");
for (const mathEl of mathElements ?? []) {
try {
math.render(mathEl.textContent || "", mathEl as HTMLElement);
} catch (e) {
console.warn("Failed to render math in TOC:", e);
}
}
}, [heading.text]);
return (
<>
@@ -260,7 +273,7 @@ function extractTocFromStaticHtml(el: HTMLElement | null) {
headings.push({
id: randomString(),
level: parseInt(headingEl.tagName.substring(1), 10),
text: headingEl.innerHTML,
text: headingEl.textContent,
element: headingEl
});
}

View File

@@ -48,10 +48,6 @@
opacity: 0.4;
}
.llm-chat-stop-btn {
color: var(--danger-color, #dc3545);
}
/* Model selector */
.llm-chat-model-selector {
display: flex;

View File

@@ -228,11 +228,11 @@ export default function ChatInputBar({
)}
</div>
<ActionButton
icon={chat.isStreaming ? "bx bx-stop" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.stop") : t("llm_chat.send")}
onClick={chat.isStreaming ? chat.stopStreaming : handleSubmit}
disabled={!chat.isStreaming && !chat.input.trim()}
className={`llm-chat-send-btn ${chat.isStreaming ? "llm-chat-stop-btn" : ""}`}
icon={chat.isStreaming ? "bx bx-loader-alt bx-spin" : "bx bx-send"}
text={chat.isStreaming ? t("llm_chat.sending") : t("llm_chat.send")}
onClick={handleSubmit}
disabled={chat.isStreaming || !chat.input.trim()}
className="llm-chat-send-btn"
/>
</div>
</form>

View File

@@ -62,8 +62,6 @@ export interface UseLlmChatReturn {
clearMessages: () => void;
/** Refresh the provider/models list */
refreshModels: () => void;
/** Stop the current generation */
stopStreaming: () => void;
}
export function useLlmChat(
@@ -91,7 +89,6 @@ export function useLlmChat(
const [isCheckingProvider, setIsCheckingProvider] = useState<boolean>(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Refs to get fresh values in getContent (avoids stale closures)
const messagesRef = useRef(messages);
@@ -254,56 +251,6 @@ export function useLlmChat(
streamOptions.enableExtendedThinking = enableExtendedThinking;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
/** Shared cleanup: finalize collected content and reset streaming state. */
function finalizeStream() {
// Mark any in-progress tool calls as stopped so they don't show infinite spinners
for (const [i, block] of contentBlocks.entries()) {
if (block.type === "tool_call" && !block.toolCall.result) {
contentBlocks[i] = {
type: "tool_call",
toolCall: { ...block.toolCall, result: "[Stopped]", isError: true }
};
}
}
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
setMessages([...newMessages, ...finalNewMessages]);
}
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
abortControllerRef.current = null;
}
await streamChatCompletion(
apiMessages,
streamOptions,
@@ -373,19 +320,42 @@ export function useLlmChat(
setIsStreaming(false);
},
onDone: () => {
finalizeStream();
const finalNewMessages: StoredMessage[] = [];
if (thinkingContent) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: thinkingContent,
createdAt: new Date().toISOString(),
type: "thinking"
});
}
if (contentBlocks.length > 0) {
finalNewMessages.push({
id: randomString(),
role: "assistant",
content: contentBlocks,
createdAt: new Date().toISOString(),
citations: citations.length > 0 ? citations : undefined,
usage
});
}
if (finalNewMessages.length > 0) {
const allMessages = [...newMessages, ...finalNewMessages];
setMessages(allMessages);
}
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
}
},
abortController.signal
).catch((e) => {
// AbortError is expected when user stops generation
if (e instanceof DOMException && e.name === "AbortError") {
finalizeStream();
} else {
// Re-throw other errors so they are not swallowed
throw e;
}
});
);
}, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -395,13 +365,6 @@ export function useLlmChat(
}
}, [handleSubmit]);
/** Stop the current generation by aborting the SSE connection. */
const stopStreaming = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []);
return {
// State
messages,
@@ -439,7 +402,6 @@ export function useLlmChat(
loadFromContent,
getContent,
clearMessages,
refreshModels,
stopStreaming
refreshModels
};
}

View File

@@ -3,7 +3,6 @@ import "./appearance.css";
import { FontFamily, OptionNames } from "@triliumnext/commons";
import { useEffect, useState } from "preact/hooks";
import zoomService from "../../../components/zoom";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
@@ -15,10 +14,9 @@ import FormGroup from "../../react/FormGroup";
import FormRadioGroup from "../../react/FormRadioGroup";
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
import FormText from "../../react/FormText";
import { FormTextBoxWithUnit } from "../../react/FormTextBox";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Icon from "../../react/Icon";
import OptionsRow from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import PlatformIndicator from "./components/PlatformIndicator";
import RadioWithIllustration from "./components/RadioWithIllustration";
@@ -335,23 +333,20 @@ function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, font
}
function ElectronIntegration() {
const [ zoomFactor ] = useTriliumOption("zoomFactor");
const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor");
const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible");
const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects");
const zoomPercentage = Math.round(parseFloat(zoomFactor || "1") * 100);
return (
<OptionsSection title={t("electron_integration.desktop-application")}>
<OptionsRow name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBoxWithUnit
<FormGroup name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBox
type="number"
min={50} max={200} step={10}
currentValue={String(zoomPercentage)}
onChange={(v) => zoomService.setZoomFactorAndSave(parseInt(v, 10) / 100)}
unit={t("units.percentage")}
min="0.3" max="2.0" step="0.1"
currentValue={zoomFactor} onChange={setZoomFactor}
/>
</OptionsRow>
</FormGroup>
<hr/>
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
<FormCheckbox

View File

@@ -1,16 +1,15 @@
import { BackupDatabaseNowResponse, DatabaseBackup } from "@triliumnext/commons";
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { formatDateTime } from "../../../utils/formatters";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import { FormMultiGroup } from "../../react/FormGroup";
import FormText from "../../react/FormText";
import { useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import { useCallback, useEffect, useState } from "preact/hooks";
import { formatDateTime } from "../../../utils/formatters";
export default function BackupSettings() {
const [ backups, setBackups ] = useState<DatabaseBackup[]>([]);
@@ -36,7 +35,7 @@ export default function BackupSettings() {
<BackupNow refreshCallback={refreshBackups} />
<BackupList backups={backups} />
</>
);
)
}
export function AutomaticBackup() {
@@ -68,7 +67,7 @@ export function AutomaticBackup() {
<FormText>{t("backup.backup_recommendation")}</FormText>
</OptionsSection>
);
)
}
export function BackupNow({ refreshCallback }: { refreshCallback: () => void }) {
@@ -83,7 +82,7 @@ export function BackupNow({ refreshCallback }: { refreshCallback: () => void })
}}
/>
</OptionsSection>
);
)
}
export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
@@ -93,13 +92,11 @@ export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
<colgroup>
<col width="33%" />
<col />
<col width="1%" />
</colgroup>
<thead>
<tr>
<th>{t("backup.date-and-time")}</th>
<th>{t("backup.path")}</th>
<th />
</tr>
</thead>
<tbody>
@@ -108,20 +105,15 @@ export function BackupList({ backups }: { backups: DatabaseBackup[] }) {
<tr>
<td>{mtime ? formatDateTime(mtime) : "-"}</td>
<td className="selectable-text">{filePath}</td>
<td>
<a href={`api/database/backup/download?filePath=${encodeURIComponent(filePath)}`} download>
<Button text={t("backup.download")} />
</a>
</td>
</tr>
))
) : (
<tr>
<td className="empty-table-placeholder" colspan={3}>{t("backup.no_backup_yet")}</td>
<td className="empty-table-placeholder" colspan={2}>{t("backup.no_backup_yet")}</td>
</tr>
)}
</tbody>
</table>
</OptionsSection>
);
}
);
}

View File

@@ -46,16 +46,6 @@
justify-content: center;
}
.option-row.stacked {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.option-row.stacked .option-row-input {
width: 100%;
}
.option-row-link.use-tn-links {
text-decoration: none;
color: inherit;

View File

@@ -10,18 +10,14 @@ interface OptionsRowProps {
description?: string;
children: VNode;
centered?: boolean;
/** When true, stacks label above input with full-width input */
stacked?: boolean;
}
export default function OptionsRow({ name, label, description, children, centered, stacked }: OptionsRowProps) {
export default function OptionsRow({ name, label, description, children, centered }: OptionsRowProps) {
const id = useUniqueName(name);
const childWithId = cloneElement(children, { id });
const className = `option-row ${centered ? "centered" : ""} ${stacked ? "stacked" : ""}`;
return (
<div className={className}>
<div className={`option-row ${centered ? "centered" : ""}`}>
<div className="option-row-label">
{label && <label for={id}>{label}</label>}
{description && <small className="option-row-description">{description}</small>}

View File

@@ -1,19 +1,16 @@
import { SyncTestResponse } from "@triliumnext/commons";
import { useRef } from "preact/hooks";
import { t } from "../../../services/i18n";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { openInAppHelpFromUrl } from "../../../services/utils";
import Button from "../../react/Button";
import FormGroup from "../../react/FormGroup";
import FormText from "../../react/FormText";
import FormTextBox from "../../react/FormTextBox";
import { useTriliumOptions } from "../../react/hooks";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import RawHtml from "../../react/RawHtml";
import OptionsRow from "./components/OptionsRow";
import OptionsSection from "./components/OptionsSection";
import TimeSelector from "./components/TimeSelector";
import { useTriliumOptions } from "../../react/hooks";
import FormText from "../../react/FormText";
import server from "../../../services/server";
import toast from "../../../services/toast";
import { SyncTestResponse } from "@triliumnext/commons";
export default function SyncOptions() {
return (
@@ -21,12 +18,13 @@ export default function SyncOptions() {
<SyncConfiguration />
<SyncTest />
</>
);
)
}
export function SyncConfiguration() {
const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncProxy");
const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncServerTimeout", "syncProxy");
const syncServerHost = useRef(options.syncServerHost);
const syncServerTimeout = useRef(options.syncServerTimeout);
const syncProxy = useRef(options.syncProxy);
return (
@@ -34,12 +32,13 @@ export function SyncConfiguration() {
<form onSubmit={(e) => {
setOptions({
syncServerHost: syncServerHost.current,
syncServerTimeout: syncServerTimeout.current,
syncProxy: syncProxy.current
});
e.preventDefault();
}}>
<FormGroup name="sync-server-host" label={t("sync_2.server_address")}>
<FormTextBox
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncServerHost.current} onChange={(newValue) => syncServerHost.current = newValue}
/>
@@ -51,30 +50,27 @@ export function SyncConfiguration() {
<RawHtml html={t("sync_2.special_value_description")} />
</>}
>
<FormTextBox
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncProxy.current} onChange={(newValue) => syncProxy.current = newValue}
/>
</FormGroup>
<FormGroup name="sync-server-timeout" label={t("sync_2.timeout")}>
<FormTextBoxWithUnit
min={1} max={10000000} type="number"
unit={t("sync_2.timeout_unit")}
currentValue={syncServerTimeout.current} onChange={(newValue) => syncServerTimeout.current = newValue}
/>
</FormGroup>
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
<Button text={t("sync_2.save")} kind="primary" />
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
</div>
</form>
<hr/>
<OptionsRow name="sync-server-timeout" label={t("sync_2.timeout")} description={t("sync_2.timeout_description")}>
<TimeSelector
name="sync-server-timeout"
optionValueId="syncServerTimeout"
optionTimeScaleId="syncServerTimeoutTimeScale"
minimumSeconds={1}
/>
</OptionsRow>
</OptionsSection>
);
)
}
export function SyncTest() {
@@ -94,5 +90,5 @@ export function SyncTest() {
}}
/>
</OptionsSection>
);
}
)
}

View File

@@ -7,7 +7,7 @@ import link from "../../../services/link";
import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef, useTriliumOption } from "../../react/hooks";
import { buildConfig, BuildEditorOptions } from "./config";
export type BoxSize = "small" | "medium" | "full" | "expandable";
export type BoxSize = "small" | "medium" | "full";
export interface CKEditorApi {
/** returns true if user selected some text, false if there's no selection */

View File

@@ -55,14 +55,4 @@ body.mobile .note-detail-readonly-text {
.edit-text-note-button:hover {
border-color: var(--button-border-color);
}
/* Inline code click-to-copy */
.note-detail-readonly-text-content code.copyable-inline-code {
cursor: pointer;
transition: background-color 0.15s ease;
}
.note-detail-readonly-text-content code.copyable-inline-code:hover {
background-color: var(--accented-background-color);
}

View File

@@ -182,21 +182,9 @@ export async function buildConfig(opts: BuildEditorOptions): Promise<EditorConfi
marker: "@",
feed: (queryText: string) => noteAutocompleteService.autocompleteSourceForCKEditor(queryText),
itemRenderer: (item) => {
const suggestion = item as Suggestion;
const itemElement = document.createElement("button");
const iconElement = document.createElement("span");
// Choose appropriate icon based on action
let iconClass = suggestion.icon ?? "bx bx-note";
if (suggestion.action === "create-note") {
iconClass = "bx bx-plus";
}
iconElement.className = iconClass;
itemElement.append(iconElement, document.createTextNode(" "));
const titleContainer = document.createElement("span");
titleContainer.innerHTML = suggestion.highlightedNotePathTitle ?? "";
itemElement.append(...titleContainer.childNodes, document.createTextNode(" "));
itemElement.innerHTML = `${(item as Suggestion).highlightedNotePathTitle} `;
return itemElement;
},

View File

@@ -8,77 +8,17 @@ export async function loadIncludedNote(noteId: string, $el: JQuery<HTMLElement>)
const note = await froca.getNote(noteId);
if (!note) return;
// Get the box size from the parent section element
const $section = $el.closest('section.include-note');
const boxSize = $section.attr('data-box-size');
const isExpandable = boxSize === 'expandable';
const $wrapper = $('<div class="include-note-wrapper">');
const $link = await link.createLink(note.noteId, {
showTooltip: false
});
if (isExpandable) {
// Create expandable structure with toggle
const $titleRow = $('<div class="include-note-title-row">');
const $toggle = $('<button class="include-note-toggle bx bx-chevron-right" aria-expanded="false">');
const $title = $('<h4 class="include-note-title">').append($link);
$wrapper.empty().append($('<h4 class="include-note-title">').append($link));
$titleRow.append($toggle, $title);
$wrapper.append($titleRow);
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
const $content = $(`<div class="include-note-content type-${type}" style="display: none;">`).append($renderedContent);
$wrapper.append($content);
// Add toggle functionality
$toggle.on('click', (e) => {
e.stopPropagation();
const isExpanded = $toggle.attr('aria-expanded') === 'true';
$toggle.attr('aria-expanded', String(!isExpanded));
$toggle.toggleClass('expanded');
$content.slideToggle(200);
});
} else {
// Standard display
$wrapper.append($('<h4 class="include-note-title">').append($link));
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
}
const { $renderedContent, type } = await content_renderer.getRenderedContent(note);
$wrapper.append($(`<div class="include-note-content type-${type}">`).append($renderedContent));
$el.empty().append($wrapper);
// Watch for box-size attribute changes and re-render
setupBoxSizeObserver($section[0], noteId, $el);
}
// Track observers to avoid duplicates
const boxSizeObservers = new WeakMap<Element, MutationObserver>();
function setupBoxSizeObserver(section: Element, noteId: string, $el: JQuery<HTMLElement>) {
// Clean up existing observer if any
const existingObserver = boxSizeObservers.get(section);
if (existingObserver) {
existingObserver.disconnect();
}
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'data-box-size') {
// Re-render the included note with the new box size
loadIncludedNote(noteId, $el);
break;
}
}
});
observer.observe(section, {
attributes: true,
attributeFilter: ['data-box-size']
});
boxSizeObservers.set(section, observer);
}
export function refreshIncludedNote(container: HTMLDivElement, noteId: string) {

File diff suppressed because it is too large Load Diff

View File

@@ -538,9 +538,6 @@
<li><a href="root/Trilium%20Demo/Scripting%20examples/Statistics/Attribute%20count/template/js/renderPieChart.js"
target="detail">renderPieChart</a>
<ul>
<li><a href="root/Trilium%20Demo/Scripting%20examples/Weight%20Tracker/Implementation/JS%20code/chart.js"
target="detail">chart.js</a>
</li>
<li><a href="root/Trilium%20Demo/Scripting%20examples/Statistics/Attribute%20count/template/js/renderPieChart/chartjs-plugin-datalabe.min.js"
target="detail">chartjs-plugin-datalabels.min.js</a>
</li>

View File

@@ -25,7 +25,9 @@
You can play with it, and modify the note content and tree structure as
you wish.</p>
<p>If you need any help, visit <a href="https://triliumnotes.org">triliumnotes.org</a> or
our <a href="https://github.com/TriliumNext">GitHub repository</a>.</p>
our <a href="https://github.com/TriliumNext">GitHub repository</a>
</p>
<h2>Cleanup</h2>
<p>Once you're finished with experimenting and want to cleanup these pages,
@@ -33,7 +35,7 @@
<h2>Formatting</h2>
<p>Trilium supports classic formatting like <em>italic</em>, <strong>bold</strong>, <em><strong>bold and italic</strong></em>.
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;&nbsp;
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;
<a
class="reference-link" href="Trilium%20Demo/Formatting%20examples">Formatting examples</a>.</p>
<h3>Lists</h3>
@@ -73,8 +75,9 @@
<hr>
<p>See also other examples like <a href="Trilium%20Demo/Formatting%20examples/School%20schedule.html">tables</a>,
<a
href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists</a>, <a href="Trilium%20Demo/Formatting%20examples/Highlighting.html">highlighting</a>, <a href="Trilium%20Demo/Formatting%20examples/Code%20blocks.html">code blocks</a>,
and <a href="Trilium%20Demo/Formatting%20examples/Math.html">math examples</a>.</p>
href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists,</a> <a href="Trilium%20Demo/Formatting%20examples/Highlighting.html">highlighting</a>, <a href="Trilium%20Demo/Formatting%20examples/Code%20blocks.html">code blocks</a>and
<a
href="Trilium%20Demo/Formatting%20examples/Math.html">math examples</a>.</p>
</div>
</div>
</body>

View File

@@ -31,7 +31,7 @@
<h2>Similar books</h2>
<ul>
<li data-list-item-id="eebd9f297d5dc97dfc46579ba1f25d7bf"></li>
<li></li>
</ul>
</div>
</div>

View File

@@ -1,21 +0,0 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="../../../../../../../../style.css">
<base target="_parent">
<title data-trilium-title>chart.js</title>
</head>
<body>
<div class="content">
<h1 data-trilium-h1>chart.js</h1>
<div class="ck-content">
<p>This is a clone of a note. Go to its <a href="../../../../../Weight%20Tracker/Implementation/JS%20code/chart.js">primary location</a>.</p>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,9 @@
import { createZipFromDirectory, extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { extractZip, importData, initializeDatabase, startElectron } from "./utils.js";
import { initializeTranslations } from "@triliumnext/server/src/services/i18n.js";
import debounce from "@triliumnext/client/src/services/debounce.js";
import fs from "fs/promises";
import { join } from "path";
import cls from "@triliumnext/server/src/services/cls.js";
import type { NoteMetaFile } from "@triliumnext/server/src/services/meta/note_meta.js";
import type NoteMeta from "@triliumnext/server/src/services/meta/note_meta.js";
// Paths are relative to apps/edit-docs/dist.
const DEMO_ZIP_PATH = join(__dirname, "../../server/src/assets/db/demo.zip");
@@ -19,29 +17,20 @@ async function main() {
await initializeTranslations();
await initializeDatabase(true);
// Wait for becca to be loaded before importing data
const beccaLoader = await import("@triliumnext/server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
cls.init(async () => {
await importData(DEMO_ZIP_DIR_PATH);
setOptions();
initializedPromise.resolve();
});
initializedPromise.resolve();
}
async function setOptions() {
const optionsService = (await import("@triliumnext/server/src/services/options.js")).default;
const sql = (await import("@triliumnext/server/src/services/sql.js")).default;
optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10);
optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60);
optionsService.setOption("compressImages", "false");
// Set initial note to the first visible child of root (not _hidden)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root";
optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }]));
}
async function registerHandlers() {
@@ -52,10 +41,8 @@ async function registerHandlers() {
eraseService.eraseUnusedAttachmentsNow();
await exportData();
await fs.rm(DEMO_ZIP_DIR_PATH, { recursive: true }).catch(() => {});
await fs.rmdir(DEMO_ZIP_DIR_PATH, { recursive: true }).catch(() => {});
await extractZip(DEMO_ZIP_PATH, DEMO_ZIP_DIR_PATH);
await cleanUpMeta(DEMO_ZIP_DIR_PATH);
await createZipFromDirectory(DEMO_ZIP_DIR_PATH, DEMO_ZIP_PATH);
}, 10_000);
events.subscribe(events.ENTITY_CHANGED, async (e) => {
if (e.entityName === "options") {
@@ -72,28 +59,4 @@ async function exportData() {
await exportToZipFile("root", "html", DEMO_ZIP_PATH);
}
const EXPANDED_NOTE_IDS = new Set([
"root",
"rvaX6hEaQlmk" // Trilium Demo
]);
async function cleanUpMeta(dirPath: string) {
const metaPath = join(dirPath, "!!!meta.json");
const meta = JSON.parse(await fs.readFile(metaPath, "utf-8")) as NoteMetaFile;
for (const file of meta.files) {
file.notePosition = 1;
traverse(file);
}
function traverse(el: NoteMeta) {
el.isExpanded = EXPANDED_NOTE_IDS.has(el.noteId);
for (const child of el.children || []) {
traverse(child);
}
}
await fs.writeFile(metaPath, JSON.stringify(meta, null, 4));
}
main();

View File

@@ -141,15 +141,9 @@ async function main() {
async function setOptions() {
const optionsService = (await import("@triliumnext/server/src/services/options.js")).default;
const sql = (await import("@triliumnext/server/src/services/sql.js")).default;
optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10);
optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60);
optionsService.setOption("compressImages", "false");
// Set initial note to the first visible child of root (not _hidden)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root";
optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }]));
}
async function exportData(noteId: string, format: ExportFormat, outputPath: string, ignoredFiles?: Set<string>) {

View File

@@ -103,14 +103,6 @@ function waitForEnd(archive: Archiver, stream: WriteStream) {
});
}
export async function createZipFromDirectory(dirPath: string, zipPath: string) {
const archive = archiver("zip", { zlib: { level: 5 } });
const outputStream = fsExtra.createWriteStream(zipPath);
archive.directory(dirPath, false);
archive.pipe(outputStream);
await waitForEnd(archive, outputStream);
}
export async function extractZip(zipFilePath: string, outputPath: string, ignoredFiles?: Set<string>) {
const promise = deferred<void>();
setTimeout(async () => {

View File

@@ -30,11 +30,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.68",
"@ai-sdk/google": "3.0.60",
"@ai-sdk/openai": "3.0.52",
"@ai-sdk/anthropic": "3.0.66",
"@ai-sdk/google": "3.0.58",
"@ai-sdk/openai": "3.0.50",
"@modelcontextprotocol/sdk": "^1.12.1",
"ai": "6.0.153",
"ai": "6.0.146",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
"js-yaml": "4.1.1",
@@ -78,6 +78,7 @@
"@types/xml2js": "0.4.14",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"axios": "1.14.0",
"chardet": "2.1.1",
"cheerio": "1.2.0",
"chokidar": "5.0.0",
@@ -109,8 +110,8 @@
"ini": "6.0.0",
"is-animated": "2.0.2",
"is-svg": "6.1.0",
"jimp": "1.6.1",
"marked": "18.0.0",
"jimp": "1.6.0",
"marked": "17.0.5",
"mime-types": "3.0.2",
"multer": "2.1.1",
"normalize-strings": "1.1.1",
@@ -130,7 +131,7 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.7",
"vite": "8.0.5",
"ws": "8.20.0",
"xml2js": "0.6.2",
"yauzl": "3.3.0"

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -18,9 +18,6 @@
<p>Note that&nbsp;<a class="reference-link" href="#root/_help_cbkrhQjrkKrh">Synchronization</a>&nbsp;provides
also some backup capabilities by its nature of distributing the data to
other computers.</p>
<h2>Downloading backup</h2>
<p>You can download a existing backup by going to Settings &gt; Backup &gt;
Existing backups &gt; Download</p>
<h2>Restoring backup</h2>
<p>Let's assume you want to restore the weekly backup, here's how to do it:</p>
<ul>

View File

@@ -3,7 +3,7 @@
or accessing Trilium through a <strong>web browser</strong>.</p>
<h2>Desktop</h2>
<p>The desktop app uses Chromium's built-in spellchecker. You can configure
it from <em>Options</em> <em>Spell Check</em>.</p>
it from <em>Options</em><strong> </strong><em>Spell Check</em>.</p>
<h3>Enabling spell check</h3>
<p>Toggle <em>Check spelling</em> to enable or disable the spellchecker. A
restart is required for changes to take effect — use the restart button
@@ -14,7 +14,7 @@
by checking the boxes. The spellchecker will accept words that are valid
in <em>any</em> of the selected languages.</p>
<p>The available languages depend on your operating system's installed language
packs. For example, on Windows you can add languages through <em>Options</em> <em>Time &amp; Language</em> <em>Language &amp; Region</em> <em>Add a language</em>.</p>
packs. For example, on Windows you can add languages through <em>Options </em><em>Time &amp; Language </em><em>Language &amp; Region </em><em>Add a language</em>.</p>
<aside
class="admonition note">
<p>The changes take effect only after restarting the application.</p>
@@ -27,7 +27,7 @@ class="admonition note">
→ "Add to dictionary") are stored in a <strong>synced note</strong> inside
Trilium. This means your custom dictionary automatically syncs across all
your devices.</p>
<p>You can view and edit the dictionary directly from <em>Settings</em> <em>Spell Check</em> <em>Custom Dictionary</em> <em>Edit dictionary</em>.
<p>You can view and edit the dictionary directly from <em>Settings </em><em>Spell Check </em><em>Custom Dictionary </em><em>Edit dictionary</em>.
This opens the underlying note, which contains one word per line. You can
add, remove, or modify entries as you like.</p>
<aside class="admonition note">
@@ -48,7 +48,7 @@ class="admonition note">
(e.g. you removed them manually) are cleaned up from the local dictionary
on startup.</li>
</ul>
<h4>Known limitations</h4>
<h4>Known limitations<a id="known-limitations"></a></h4>
<p>On Windows and macOS, Electron delegates "Add to dictionary" to the operating
system's user dictionary. This means:</p>
<ul>

View File

@@ -1,3 +1,4 @@
<h2>v0.102.0: Upgrade to jQuery 4.0.0</h2>
<p>jQuery 4 removes legacy browser support (such as IE11 support), but it
also removes some APIs that are considered deprecated such as:</p>
<blockquote>

View File

@@ -1,28 +0,0 @@
<p>The <code spellcheck="false">api.axios</code> library has been removed from
the backend scripting API.</p>
<p>Scripts that attempt to use <code spellcheck="false">api.axios</code> will
now throw an error with migration instructions.</p>
<h2>Reasoning</h2>
<p>Axios was marked as deprecated at least since April 2024 in favor of the
native <code spellcheck="false">fetch()</code> API, which is available in
both browser and Node.js environments. After two years of deprecation,
the library was removed following the <a href="https://www.malwarebytes.com/blog/news/2026/03/axios-supply-chain-attack-chops-away-at-npm-trust">March 2026 npm supply chain compromise</a>,
where attackers published malicious versions that deployed a remote access
trojan. The Trilium's main developer almost got compromised, but <code spellcheck="false">pnpm</code> not
trusting unknown post-install scripts successfully avoided that.</p>
<h2>Migration</h2>
<p>Replace <code spellcheck="false">api.axios</code> calls with the native
<code
spellcheck="false">fetch()</code>API.</p>
<h3><code spellcheck="false">GET</code> calls</h3>
<p>Before (Axios):</p><pre><code class="language-application-javascript-env-backend">const response = await api.axios.get('https://api.example.com/data');
const data = response.data;</code></pre>
<p>After (<code spellcheck="false">fetch</code>):</p><pre><code class="language-application-javascript-env-backend">const response = await fetch('https://api.example.com/data');
const data = await response.json();</code></pre>
<h3><code spellcheck="false">POST</code> calls</h3>
<p>Before (Axios):</p><pre><code class="language-application-javascript-env-backend">await api.axios.post('https://api.example.com/data', { key: 'value' });</code></pre>
<p>After (fetch):</p><pre><code class="language-application-javascript-env-backend">await fetch('https://api.example.com/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'value' })
});</code></pre>

View File

@@ -1,14 +0,0 @@
<p>The <code spellcheck="false">api.cheerio</code> library is deprecated and
will be removed in a future version.</p>
<h2>Reasoning</h2>
<p>Cheerio is only used for the scripting API while the server internally
uses <code spellcheck="false">node-html-parser</code> for HTML parsing. Removing
<code
spellcheck="false">cheerio</code>reduces bundle size and maintenance overhead.</p>
<h2>Migration</h2>
<p>Before (<code spellcheck="false">cheerio</code>):</p><pre><code class="language-application-javascript-env-backend">const $ = api.cheerio.load(html);
const title = $('h1').text();
const links = $('a').map((i, el) =&gt; $(el).attr('href')).get();</code></pre>
<p>After (<code spellcheck="false">htmlParser</code>):</p><pre><code class="language-application-javascript-env-backend">const root = api.htmlParser.parse(html);
const title = root.querySelector('h1')?.textContent;
const links = root.querySelectorAll('a').map(a =&gt; a.getAttribute('href'));</code></pre>

View File

@@ -80,9 +80,6 @@ class WordCountWidget extends api.NoteContextAwareWidget {
module.exports = new WordCountWidget();</code></pre>
<p>After you make changes it is necessary to <a href="#root/_help_s8alTXmpFR61">restart Trilium</a> so
that the layout can be rebuilt.</p>
<p>The widget only activates on text notes that have the <code spellcheck="false">#wordCount</code> label.
This label can be a <a href="#root/pOsGYCXsbNQG/KSZ04uQ2D1St/iPIMuisry3hd/QEAPj01N5f7w/_help_hrZ1D00cLbal">reference link</a> to
enable the widget for an entire subtree.</p>
<p>At the bottom of the note you can see the resulting widget:</p>
<figure
class="image">

View File

@@ -43,19 +43,10 @@ Backend scripts run in Node.js on the server. They have direct access to notes i
- `api.getAppInfo()` - get application info
### Libraries
- `api.axios` - HTTP client
- `api.dayjs` - date manipulation
- `api.xml2js` - XML parser
- `api.htmlParser` - HTML parser (node-html-parser), use `api.htmlParser.parse(html)` to parse
- `api.cheerio` - **DEPRECATED**, use `api.htmlParser` instead
### HTTP Requests
Use the native `fetch()` API for HTTP requests:
```javascript
const response = await fetch('https://api.example.com/data');
const data = await response.json();
```
Note: `api.axios` was removed in 2026 due to a supply chain security incident. Use `fetch()` instead.
- `api.cheerio` - HTML/XML parser
### Advanced
- `api.transactional(func)` - wrap code in a database transaction

View File

@@ -243,7 +243,7 @@
"shortcuts-title": "快捷键",
"text-notes": "文本笔记",
"code-notes-title": "代码笔记",
"images-title": "媒体",
"images-title": "图片",
"spellcheck-title": "拼写检查",
"password-title": "密码",
"multi-factor-authentication-title": "多因素认证",
@@ -261,9 +261,7 @@
"zen-mode": "禅模式",
"tab-switcher-title": "标签切换器",
"llm-chat-history-title": "AI对话历史",
"sidebar-chat-title": "AI对话",
"custom-dictionary-title": "自定义词典",
"llm-title": "AI / LLM"
"sidebar-chat-title": "AI对话"
},
"notes": {
"new-note": "新建笔记",
@@ -406,10 +404,7 @@
"last-updated": "最后更新于 {{- date}}",
"subpages": "子页面:",
"on-this-page": "本页内容",
"expand": "展开",
"toggle-navigation": "切换导航",
"toggle-toc": "切换目录",
"logo-alt": "Logo"
"expand": "展开"
},
"hidden_subtree_templates": {
"text-snippet": "文本片段",
@@ -445,15 +440,5 @@
},
"desktop": {
"instance_already_running": "已经有一个运行中的实例,正在将焦点切换到该实例。"
},
"search": {
"error": {
"in-context": "{{-context}} 中出错:{{-message}}",
"reserved-keyword": "\"{{- token}}\" 是一个保留关键字。要搜索字面值,请使用引号:\"{{- token}}\"",
"cannot-compare-with": "无法与“{{- token}}”进行比较。要搜索字面值,请使用引号:“{{- token}}”",
"misplaced-expression": "位置错误或不完整的表达式“{{- token}}”",
"fulltext-after-expression": "“{{- token}}”不是有效表达式。要搜索文本,请将其放在属性筛选器之前(例如,“{{- token}} #label”而不是“#label {{- token}}”)。",
"unrecognized-expression": "无法识别的表达式“{{- token}}”"
}
}
}

View File

@@ -445,18 +445,5 @@
},
"desktop": {
"instance_already_running": "There's already an instance running, focusing that instance instead."
},
"script": {
"wrong-environment": "Cannot execute note \"{{- noteTitle}}\" ({{- noteId}}). This is a {{- actualEnv}} script, but execution was attempted in the {{- expectedEnv}}."
},
"search": {
"error": {
"in-context": "Error in {{- context}}: {{- message}}",
"reserved-keyword": "\"{{- token}}\" is a reserved keyword. To search for a literal value, use quotes: \"{{- token}}\"",
"cannot-compare-with": "cannot compare with \"{{- token}}\". To search for a literal value, use quotes: \"{{- token}}\"",
"misplaced-expression": "Misplaced or incomplete expression \"{{- token}}\"",
"fulltext-after-expression": "\"{{- token}}\" is not a valid expression. To search for text, place it before attribute filters (e.g., \"{{- token}} #label\" instead of \"#label {{- token}}\").",
"unrecognized-expression": "Unrecognized expression \"{{- token}}\""
}
}
}

View File

@@ -445,15 +445,5 @@
},
"desktop": {
"instance_already_running": "すでにインスタンスが実行されているので、代わりにそのインスタンスにフォーカスします。"
},
"search": {
"error": {
"in-context": "{{- context}} でエラーが発生しました: {{- message}}",
"reserved-keyword": "\"{{- token}}\" は予約語です。リテラル値を検索するには、引用符を使用してください: \"{{- token}}\"",
"cannot-compare-with": "\"{{- token}}\" と比較できません。リテラル値を検索するには、引用符を使用してください: \"{{- token}}\"",
"misplaced-expression": "\"{{- token}}\" という式が不適切または不完全です",
"fulltext-after-expression": "\"{{- token}}\" は有効な式ではありません。テキストを検索するには、属性フィルターの前に記述してください(例: \"#label {{- token}}\" ではなく \"{{- token}} #label\")。",
"unrecognized-expression": "認識されない式 \"{{- token}}\""
}
}
}

View File

@@ -359,8 +359,7 @@
"tab-switcher-title": "切換分頁",
"llm-chat-history-title": "AI 對話歷史",
"llm-title": "AI / LLM",
"sidebar-chat-title": "AI 對話",
"custom-dictionary-title": "自訂字典"
"sidebar-chat-title": "AI 對話"
},
"notes": {
"new-note": "新增筆記",
@@ -406,10 +405,7 @@
"last-updated": "最近於 {{- date}} 更新",
"subpages": "子頁面:",
"on-this-page": "本頁內容",
"expand": "展開",
"toggle-navigation": "切換導航",
"toggle-toc": "切換目錄",
"logo-alt": "標誌"
"expand": "展開"
},
"hidden_subtree_templates": {
"text-snippet": "文字片段",
@@ -445,15 +441,5 @@
},
"desktop": {
"instance_already_running": "已經有一個執行中的實例,正在將焦點切換到該實例。"
},
"search": {
"error": {
"in-context": "{{- context}} 發生錯誤:{{- message}}",
"reserved-keyword": "\"{{- token}}\" 是一個保留關鍵字。若要搜尋字面值,請使用引號:\"{{- token}}\"",
"cannot-compare-with": "無法與 \"{{- token}}\" 進行比較。若要搜尋字面值,請使用引號:\"{{- token}}\"",
"misplaced-expression": "\"{{- token}}\" 的表達式位置不當或不完整",
"fulltext-after-expression": "\"{{- token}}\" 不是有效的表達式。若要搜尋文字,請將其置於屬性篩選條件之前(例如:\"{{- token}} #label\",而非 \"#label {{- token}}\")。",
"unrecognized-expression": "無法辨識的表達式 \"{{- token}}\""
}
}
}

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