From ee229bd0d777fd571af10b8771dd6104fbee44e6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:04:23 +0300 Subject: [PATCH 01/33] fix(client): note title doesn't get selected anymore when creating new note (closes #8407) --- apps/client/src/widgets/note_title.tsx | 39 ++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index f886d9f7db..76a86104c2 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -1,15 +1,17 @@ -import { useEffect, useRef, useState } from "preact/hooks"; -import { t } from "../services/i18n"; -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 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 clsx from "clsx"; +import { isLaunchBarConfig } from "../services/utils"; +import FormTextBox from "./react/FormTextBox"; +import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks"; export default function NoteTitleWidget(props: {className?: string}) { const { note, noteId, componentId, viewScope, noteContext, parentComponent } = useNoteContext(); @@ -58,11 +60,28 @@ export default function NoteTitleWidget(props: {className?: string}) { // Manage focus. const textBoxRef = useRef(null); const isNewNote = useRef(); + const pendingSelect = useRef(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(); + } + }, [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); } @@ -83,6 +102,9 @@ 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)) { @@ -101,6 +123,7 @@ export default function NoteTitleWidget(props: {className?: string}) { } }} onBlur={() => { + pendingSelect.current = false; spacedUpdate.updateNowIfNecessary(); isNewNote.current = false; }} From 540b607459d071e73d89a5050e59b882e9ebc696 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:13:00 +0300 Subject: [PATCH 02/33] fix(note_map): freezing the app if there are too many notes (closes #8916) --- .../src/translations/en/translation.json | 3 ++- apps/client/src/widgets/note_map/NoteMap.css | 4 ++-- apps/client/src/widgets/note_map/NoteMap.tsx | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 6006d665e4..a080456435 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -860,7 +860,8 @@ "collapse": "Collapse to normal size", "title": "Note Map", "fix-nodes": "Fix nodes", - "link-distance": "Link distance" + "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." }, "note_paths": { "title": "Note Paths", diff --git a/apps/client/src/widgets/note_map/NoteMap.css b/apps/client/src/widgets/note_map/NoteMap.css index fa49bb39c6..3188f09439 100644 --- a/apps/client/src/widgets/note_map/NoteMap.css +++ b/apps/client/src/widgets/note_map/NoteMap.css @@ -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 */ \ No newline at end of file +/* End of styling the slider */ diff --git a/apps/client/src/widgets/note_map/NoteMap.tsx b/apps/client/src/widgets/note_map/NoteMap.tsx index 2677e4aa59..a165d467bb 100644 --- a/apps/client/src/widgets/note_map/NoteMap.tsx +++ b/apps/client/src/widgets/note_map/NoteMap.tsx @@ -12,11 +12,15 @@ 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; @@ -34,6 +38,7 @@ 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(null); const notesAndRelationsRef = useRef(); const mapRootId = useMemo(() => { @@ -61,6 +66,14 @@ 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. @@ -119,6 +132,12 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) { }); }, [ fixNodes, mapType ]); + if (tooManyNotes) { + return ( + + ); + } + return (
From 1b8c234f308401ece3acf57ca93f92ba11fa4317 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:28:37 +0300 Subject: [PATCH 03/33] feat(search): clarify error message for use of unquoted note --- .../services/search/services/parse.spec.ts | 25 +++++++++++++++++-- .../src/services/search/services/parse.ts | 5 +++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/search/services/parse.spec.ts b/apps/server/src/services/search/services/parse.spec.ts index cd59ce7f09..ccf4925499 100644 --- a/apps/server/src/services/search/services/parse.spec.ts +++ b/apps/server/src/services/search/services/parse.spec.ts @@ -285,7 +285,7 @@ describe("Invalid expressions", () => { searchContext }); - expect(searchContext.error).toEqual(`Error near token "#second" in "#first = #second", it's possible to compare with constant only.`); + expect(searchContext.error).toEqual(`Error in "#first = #second": cannot compare with "#second". To search for a literal value, use quotes: "#second"`); searchContext = new SearchContext(); searchContext.originalQuery = "#first = note.relations.second"; @@ -296,7 +296,7 @@ describe("Invalid expressions", () => { searchContext }); - expect(searchContext.error).toEqual(`Error near token "note" in "#first = note.relations.second", it's possible to compare with constant only.`); + expect(searchContext.error).toEqual(`Error in "#first = note.relations.second": "note" is a reserved keyword. To search for a literal value, use quotes: "note"`); const rootExp = parse( { @@ -317,6 +317,27 @@ describe("Invalid expressions", () => { expect(labelComparisonExp.attributeType).toEqual("label"); expect(labelComparisonExp.attributeName).toEqual("first"); expect(labelComparisonExp.comparator).toBeTruthy(); + + // Verify that quoted "note" keyword works (issue #8850) + const rootExp2 = parse( + { + fulltextTokens: [], + expressionTokens: [ + { token: "#clipType", inQuotes: false }, + { token: "=", inQuotes: false }, + { token: "note", inQuotes: true } + ], + searchContext: new SearchContext() + }, + AndExp + ); + + assertIsArchived(rootExp2.subExpressions[0]); + + const labelComparisonExp2 = expectExpression(rootExp2.subExpressions[2], LabelComparisonExp); + expect(labelComparisonExp2.attributeType).toEqual("label"); + expect(labelComparisonExp2.attributeName).toEqual("cliptype"); + expect(labelComparisonExp2.comparator).toBeTruthy(); }); it("searching by relation without note property", () => { diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts index a117bf9928..504eeb4bea 100644 --- a/apps/server/src/services/search/services/parse.ts +++ b/apps/server/src/services/search/services/parse.ts @@ -98,7 +98,10 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level const operand = tokens[i]; if (!operand.inQuotes && (operand.token.startsWith("#") || operand.token.startsWith("~") || operand.token === "note")) { - searchContext.addError(`Error near token "${operand.token}" in ${context(i)}, it's possible to compare with constant only.`); + const hint = operand.token === "note" + ? `"${operand.token}" is a reserved keyword. To search for a literal value, use quotes: "${operand.token}"` + : `cannot compare with "${operand.token}". To search for a literal value, use quotes: "${operand.token}"`; + searchContext.addError(`Error in ${context(i)}: ${hint}`); return null; } From fc2d8452b558c92279dd3049f061825b32b23ee7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:31:23 +0300 Subject: [PATCH 04/33] feat(search): clarify error message for full-text search after expressions --- apps/server/src/services/search/services/parse.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts index 504eeb4bea..e4721feab4 100644 --- a/apps/server/src/services/search/services/parse.ts +++ b/apps/server/src/services/search/services/parse.ts @@ -1,5 +1,3 @@ - - import { dayjs } from "@triliumnext/commons"; import { removeDiacritic } from "../../utils.js"; @@ -441,7 +439,13 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level } else if (isOperator({ token })) { searchContext.addError(`Misplaced or incomplete expression "${token}"`); } else { - searchContext.addError(`Unrecognized expression "${token}"`); + // Check if this looks like a fulltext search term placed after attribute filters + const looksLikeFulltext = !token.startsWith("#") && !token.startsWith("~") && !token.startsWith("note."); + if (looksLikeFulltext) { + searchContext.addError(`"${token}" is not a valid expression. If you want to search for text, place it before attribute filters (e.g., "${token} #label" instead of "#label ${token}").`); + } else { + searchContext.addError(`Unrecognized expression "${token}"`); + } } if (!op && expressions.length > 1) { From 126ee27505ddaa21c6d9fb44390e7ffd7a0eaf0d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:38:13 +0300 Subject: [PATCH 05/33] feat(search): some error messages were not translated (closes #8850) --- apps/server/src/assets/translations/en/server.json | 10 ++++++++++ apps/server/src/services/search/services/parse.ts | 13 +++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/assets/translations/en/server.json b/apps/server/src/assets/translations/en/server.json index 95c5a74663..87e9f0bbcc 100644 --- a/apps/server/src/assets/translations/en/server.json +++ b/apps/server/src/assets/translations/en/server.json @@ -445,5 +445,15 @@ }, "desktop": { "instance_already_running": "There's already an instance running, focusing that instance instead." + }, + "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}}\"" + } } } diff --git a/apps/server/src/services/search/services/parse.ts b/apps/server/src/services/search/services/parse.ts index e4721feab4..2d6092280b 100644 --- a/apps/server/src/services/search/services/parse.ts +++ b/apps/server/src/services/search/services/parse.ts @@ -1,4 +1,5 @@ import { dayjs } from "@triliumnext/commons"; +import { t } from "i18next"; import { removeDiacritic } from "../../utils.js"; import AncestorExp from "../expressions/ancestor.js"; @@ -97,9 +98,9 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level if (!operand.inQuotes && (operand.token.startsWith("#") || operand.token.startsWith("~") || operand.token === "note")) { const hint = operand.token === "note" - ? `"${operand.token}" is a reserved keyword. To search for a literal value, use quotes: "${operand.token}"` - : `cannot compare with "${operand.token}". To search for a literal value, use quotes: "${operand.token}"`; - searchContext.addError(`Error in ${context(i)}: ${hint}`); + ? t("search.error.reserved-keyword", { token: operand.token }) + : t("search.error.cannot-compare-with", { token: operand.token }); + searchContext.addError(t("search.error.in-context", { context: context(i), message: hint })); return null; } @@ -437,14 +438,14 @@ function getExpression(tokens: TokenData[], searchContext: SearchContext, level searchContext.addError("Mixed usage of AND/OR - always use parenthesis to group AND/OR expressions."); } } else if (isOperator({ token })) { - searchContext.addError(`Misplaced or incomplete expression "${token}"`); + searchContext.addError(t("search.error.misplaced-expression", { token })); } else { // Check if this looks like a fulltext search term placed after attribute filters const looksLikeFulltext = !token.startsWith("#") && !token.startsWith("~") && !token.startsWith("note."); if (looksLikeFulltext) { - searchContext.addError(`"${token}" is not a valid expression. If you want to search for text, place it before attribute filters (e.g., "${token} #label" instead of "#label ${token}").`); + searchContext.addError(t("search.error.fulltext-after-expression", { token })); } else { - searchContext.addError(`Unrecognized expression "${token}"`); + searchContext.addError(t("search.error.unrecognized-expression", { token })); } } From 8d5df7e888cac68f5b063d758df8e51a5636ba99 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:42:33 +0300 Subject: [PATCH 06/33] chore(ai): update system prompt for reusing components and using translations --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 56073acda4..39b52cdae4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,15 @@ 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/`) @@ -124,6 +133,11 @@ 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 From 3af2b3278348cf9ba804e3dfb4c3fe216cc36b75 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:43:41 +0300 Subject: [PATCH 07/33] fix(react): workaround for bootstrap tooltip error (closes #8900) --- apps/client/src/widgets/react/hooks.tsx | 53 ++++++++++++++++++++----- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index a6f9c4595f..cf14c783d9 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -825,13 +825,43 @@ 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, config: Partial) { useEffect(() => { if (!elRef?.current) return; - const $el = $(elRef.current); - $el.tooltip("dispose"); + const element = elRef.current; + const $el = $(element); + + // Dispose any existing tooltip before creating a new one + Tooltip.getInstance(element)?.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(() => { @@ -866,8 +896,14 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial { + // 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", () => { // Hide all the other tooltips. for (const otherTooltip of tooltips) { if (otherTooltip === tooltip) continue; @@ -878,12 +914,11 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial { tooltips.delete(tooltip); - 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 + if (element.isConnected) { + tooltip.dispose(); + } - // Remove *all* tooltip elements from the DOM + // Remove any lingering tooltip popup elements from the DOM. document .querySelectorAll('.tooltip') .forEach(t => t.remove()); From ca7ab6105dcab4737deaa2e39abfde8c9833e77f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:48:15 +0300 Subject: [PATCH 08/33] chore(ai): keep system prompts in sync --- .github/copilot-instructions.md | 16 ++++++++++++++++ CLAUDE.md | 2 ++ 2 files changed, 18 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 017e5b6205..21a4171729 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,7 @@ # 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. @@ -115,6 +117,15 @@ 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 @@ -322,6 +333,11 @@ 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 `` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `" in "` vs `"in , "`) - 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 + ## Testing Conventions ```typescript diff --git a/CLAUDE.md b/CLAUDE.md index 39b52cdae4..d113a18da2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,8 @@ 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. From 08591650721b37bef9337de841fb2dfc5210c791 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 20:54:13 +0300 Subject: [PATCH 09/33] docs(scripting): missing step in word count widget (closes #8561) --- .../User Guide/User Guide/Installation & Setup/Backup.html | 3 +++ .../Frontend Basics/Custom Widgets/Word count widget.html | 3 +++ docs/Developer Guide/Developer Guide/Documentation.md | 2 +- docs/User Guide/!!!meta.json | 7 +++++++ .../Frontend Basics/Custom Widgets/Word count widget.md | 2 ++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html index c4677663ce..800b0a2db4 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Installation & Setup/Backup.html @@ -18,6 +18,9 @@

Note that Synchronization provides also some backup capabilities by its nature of distributing the data to other computers.

+

Downloading backup

+

You can download a existing backup by going to Settings > Backup > + Existing backups > Download

Restoring backup

Let's assume you want to restore the weekly backup, here's how to do it:

    diff --git a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html index 52a8a2d30d..589d0989b1 100644 --- a/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html +++ b/apps/server/src/assets/doc_notes/en/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.html @@ -80,6 +80,9 @@ class WordCountWidget extends api.NoteContextAwareWidget { module.exports = new WordCountWidget();

    After you make changes it is necessary to restart Trilium so that the layout can be rebuilt.

    +

    The widget only activates on text notes that have the #wordCount label. + This label can be a reference link to + enable the widget for an entire subtree.

    At the bottom of the note you can see the resulting widget:

    diff --git a/docs/Developer Guide/Developer Guide/Documentation.md b/docs/Developer Guide/Developer Guide/Documentation.md index 0614c5ab4e..0ebd70a6c6 100644 --- a/docs/Developer Guide/Developer Guide/Documentation.md +++ b/docs/Developer Guide/Developer Guide/Documentation.md @@ -1,5 +1,5 @@ # Documentation -There are multiple types of documentation for Trilium: +There are multiple types of documentation for Trilium: * The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing F1. * The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers. diff --git a/docs/User Guide/!!!meta.json b/docs/User Guide/!!!meta.json index df8794a49c..b04008c1a1 100644 --- a/docs/User Guide/!!!meta.json +++ b/docs/User Guide/!!!meta.json @@ -16459,6 +16459,13 @@ "value": "word-count", "isInheritable": false, "position": 40 + }, + { + "type": "relation", + "name": "internalLink", + "value": "hrZ1D00cLbal", + "isInheritable": false, + "position": 50 } ], "format": "markdown", diff --git a/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md b/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md index 8b6be5684f..137e7ec414 100644 --- a/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md +++ b/docs/User Guide/User Guide/Scripting/Frontend Basics/Custom Widgets/Word count widget.md @@ -84,6 +84,8 @@ module.exports = new WordCountWidget(); After you make changes it is necessary to [restart Trilium](../../../Troubleshooting/Refreshing%20the%20application.md) so that the layout can be rebuilt. +The widget only activates on text notes that have the `#wordCount` label. This label can be a [reference link](../../../Note%20Types/Text/Links/Internal%20\(reference\)%20links.md) to enable the widget for an entire subtree. + At the bottom of the note you can see the resulting widget:
    \ No newline at end of file From d3dbdd4ceb7cbadf77c71375ab5e99116c4b76c3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 21:02:05 +0300 Subject: [PATCH 10/33] docs(scripting): typos in "Trilium Demo" note (closes #8230) --- apps/edit-docs/demo/!!!meta.json | 2 +- apps/edit-docs/demo/root/Trilium Demo.html | 26 +- .../Trilium Demo/Books/Book template.html | 15 +- apps/edit-docs/demo/style.css | 977 +++++++++--------- apps/server/src/assets/db/demo.zip | Bin 917598 -> 917423 bytes 5 files changed, 515 insertions(+), 505 deletions(-) diff --git a/apps/edit-docs/demo/!!!meta.json b/apps/edit-docs/demo/!!!meta.json index d8c4710f93..50d1460e2b 100644 --- a/apps/edit-docs/demo/!!!meta.json +++ b/apps/edit-docs/demo/!!!meta.json @@ -1,6 +1,6 @@ { "formatVersion": 2, - "appVersion": "0.100.0", + "appVersion": "0.102.2", "files": [ { "isClone": false, diff --git a/apps/edit-docs/demo/root/Trilium Demo.html b/apps/edit-docs/demo/root/Trilium Demo.html index 59f6c6d557..3920159069 100644 --- a/apps/edit-docs/demo/root/Trilium Demo.html +++ b/apps/edit-docs/demo/root/Trilium Demo.html @@ -18,30 +18,23 @@ width="150" height="150">

    Welcome to Trilium Notes! -

    This is a "demo" document packaged with Trilium to showcase some of its features and also give you some ideas on how you might structure your notes. You can play with it, and modify the note content and tree structure as you wish.

    If you need any help, visit triliumnotes.org or - our GitHub repository - -

    -

    Cleanup

    - + our GitHub repository.

    +

    Cleanup

    Once you're finished with experimenting and want to cleanup these pages, you can simply delete them all.

    -

    Formatting

    - +

    Formatting

    Trilium supports classic formatting like italic, bold, bold and italic. - You can add links pointing to external pages or  + You can add links pointing to external pages or   Formatting examples.

    -

    Lists

    - +

    Lists

    Ordered: -

    1. First Item
    2. @@ -56,7 +49,6 @@

    Unordered: -

    • Item
    • @@ -66,8 +58,7 @@
-

Block quotes

- +

Block quotes

Whereof one cannot speak, thereof one must be silent”

– Ludwig Wittgenstein

@@ -75,9 +66,8 @@

See also other examples like tables, checkbox lists, highlighting, code blocksand - math examples.

+ href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists, highlighting, code blocks, + and math examples.

diff --git a/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html b/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html index b6ece231ed..e54ae932e5 100644 --- a/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html +++ b/apps/edit-docs/demo/root/Trilium Demo/Books/Book template.html @@ -14,24 +14,19 @@

Main characters

-

… here put main characters …

 

-

Plot

- +

Plot

… describe main plot lines …

 

-

Tone

- +

Tone

 

-

Genre

- +

Genre

scifi / drama / romance

 

-

Similar books

- +

Similar books

    -
  • +
diff --git a/apps/edit-docs/demo/style.css b/apps/edit-docs/demo/style.css index d1209630ab..2e961cdb7a 100644 --- a/apps/edit-docs/demo/style.css +++ b/apps/edit-docs/demo/style.css @@ -1,633 +1,658 @@ /** - * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. + * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ -:root{ - --ck-content-color-mention-background:hsla(341, 100%, 30%, 0.1); - --ck-content-color-mention-text:hsl(341, 100%, 30%); -} -.ck-content .mention{ - background:var(--ck-content-color-mention-background); - color:var(--ck-content-color-mention-text); -} - .ck-content code{ - background-color:hsla(0, 0%, 78%, 0.3); - padding:.15em; - border-radius:2px; + background-color:#c7c7c74d; + border-radius:2px; + padding:.15em; } .ck-content blockquote{ - overflow:hidden; - padding-right:1.5em; - padding-left:1.5em; - - margin-left:0; - margin-right:0; - font-style:italic; - border-left:solid 5px hsl(0, 0%, 80%); + border-left:5px solid #ccc; + margin-left:0; + margin-right:0; + padding-left:1.5em; + padding-right:1.5em; + font-style:italic; + overflow:hidden; } .ck-content[dir="rtl"] blockquote{ - border-left:0; - border-right:solid 5px hsl(0, 0%, 80%); + border-left:0; + border-right:5px solid #ccc; } .ck-content pre{ - padding:1em; - color:hsl(0, 0%, 20.8%); - background:hsla(0, 0%, 78%, 0.3); - border:1px solid hsl(0, 0%, 77%); - border-radius:2px; - text-align:left; - direction:ltr; - - tab-size:4; - white-space:pre-wrap; - font-style:normal; - min-width:200px; + color:#353535; + text-align:left; + tab-size:4; + white-space:pre-wrap; + direction:ltr; + background:#c7c7c74d; + border:1px solid #c4c4c4; + border-radius:2px; + min-width:200px; + margin:.9em 0; + padding:1em; + font-style:normal; } .ck-content pre code{ - background:unset; - padding:0; - border-radius:0; - } + background:unset; + border-radius:0; + padding:0; +} :root{ - --ck-content-font-family:Helvetica, Arial, Tahoma, Verdana, Sans-Serif; - --ck-content-font-size:medium; - --ck-content-font-color:#000; - --ck-content-line-height:1.5; - --ck-content-word-break:break-word; + --ck-content-font-family:Helvetica, Arial, Tahoma, Verdana, Sans-Serif; + --ck-content-font-size:medium; + --ck-content-font-color:#000; + --ck-content-line-height:1.5; + --ck-content-word-break:normal; + --ck-content-overflow-wrap:break-word; } .ck-content{ - font-family:var(--ck-content-font-family); - font-size:var(--ck-content-font-size); - color:var(--ck-content-font-color); - line-height:var(--ck-content-line-height); - word-break:var(--ck-content-word-break); + font-family:var(--ck-content-font-family); + font-size:var(--ck-content-font-size); + color:var(--ck-content-font-color); + line-height:var(--ck-content-line-height); + word-break:var(--ck-content-word-break); + overflow-wrap:var(--ck-content-overflow-wrap); } :root{ - --ck-content-font-size-tiny:0.7em; - --ck-content-font-size-small:0.85em; - --ck-content-font-size-big:1.4em; - --ck-content-font-size-huge:1.8em; + --ck-content-font-size-tiny:.7em; + --ck-content-font-size-small:.85em; + --ck-content-font-size-big:1.4em; + --ck-content-font-size-huge:1.8em; } + .ck-content .text-tiny{ - font-size:var(--ck-content-font-size-tiny); - } + font-size:var(--ck-content-font-size-tiny); +} + .ck-content .text-small{ - font-size:var(--ck-content-font-size-small); - } + font-size:var(--ck-content-font-size-small); +} + .ck-content .text-big{ - font-size:var(--ck-content-font-size-big); - } + font-size:var(--ck-content-font-size-big); +} + .ck-content .text-huge{ - font-size:var(--ck-content-font-size-huge); - } + font-size:var(--ck-content-font-size-huge); +} :root{ - --ck-content-highlight-marker-yellow:hsl(60, 97%, 73%); - --ck-content-highlight-marker-green:hsl(120, 93%, 68%); - --ck-content-highlight-marker-pink:hsl(345, 96%, 73%); - --ck-content-highlight-marker-blue:hsl(201, 97%, 72%); - --ck-content-highlight-pen-red:hsl(0, 85%, 49%); - --ck-content-highlight-pen-green:hsl(112, 100%, 27%); + --ck-content-highlight-marker-yellow:#fdfd77; + --ck-content-highlight-marker-green:#62f962; + --ck-content-highlight-marker-pink:#fc7899; + --ck-content-highlight-marker-blue:#72ccfd; + --ck-content-highlight-pen-red:#e71313; + --ck-content-highlight-pen-green:#128a00; } .ck-content .marker-yellow{ - background-color:var(--ck-content-highlight-marker-yellow); - } + background-color:var(--ck-content-highlight-marker-yellow); +} + .ck-content .marker-green{ - background-color:var(--ck-content-highlight-marker-green); - } + background-color:var(--ck-content-highlight-marker-green); +} + .ck-content .marker-pink{ - background-color:var(--ck-content-highlight-marker-pink); - } + background-color:var(--ck-content-highlight-marker-pink); +} + .ck-content .marker-blue{ - background-color:var(--ck-content-highlight-marker-blue); - } + background-color:var(--ck-content-highlight-marker-blue); +} .ck-content .pen-red{ - color:var(--ck-content-highlight-pen-red); - background-color:transparent; - } + color:var(--ck-content-highlight-pen-red); + background-color:#0000; +} + .ck-content .pen-green{ - color:var(--ck-content-highlight-pen-green); - background-color:transparent; - } + color:var(--ck-content-highlight-pen-green); + background-color:#0000; +} .ck-content hr{ - margin:15px 0; - height:4px; - background:hsl(0, 0%, 87%); - border:0; + background:#dedede; + border:0; + height:4px; + margin:15px 0; } :root{ - --ck-content-color-image-caption-background:hsl(0, 0%, 97%); - --ck-content-color-image-caption-text:hsl(0, 0%, 20%); + --ck-content-color-image-caption-background:#f7f7f7; + --ck-content-color-image-caption-text:#333; } + .ck-content .image > figcaption{ - display:table-caption; - caption-side:bottom; - word-break:normal; - overflow-wrap:anywhere; - break-before:avoid; - color:var(--ck-content-color-image-caption-text); - background-color:var(--ck-content-color-image-caption-background); - padding:.6em; - font-size:.75em; - outline-offset:-1px; + caption-side:bottom; + word-break:normal; + overflow-wrap:anywhere; + break-before:avoid; + color:var(--ck-content-color-image-caption-text); + background-color:var(--ck-content-color-image-caption-background); + outline-offset:-1px; + padding:.6em; + font-size:.75em; + display:table-caption; } + @media (forced-colors: active){ -.ck-content .image > figcaption{ - background-color:unset; - color:unset; + .ck-content .image > figcaption{ + background-color:unset; + color:unset; + } } - } + .ck-content img.image_resized{ - height:auto; + height:auto; } .ck-content .image.image_resized{ - max-width:100%; - display:block; - box-sizing:border-box; + box-sizing:border-box; + max-width:100%; + display:block; } .ck-content .image.image_resized img{ - width:100%; - } - -.ck-content .image.image_resized > figcaption{ - display:block; - } - -:root{ - --ck-content-image-style-spacing:1.5em; - --ck-content-inline-image-style-spacing:calc(var(--ck-content-image-style-spacing) / 2); + width:100%; } -.ck-content .image.image-style-block-align-left, - .ck-content .image.image-style-block-align-right{ - max-width:calc(100% - var(--ck-content-image-style-spacing)); - } - -.ck-content .image.image-style-align-left, - .ck-content .image.image-style-align-right{ - clear:none; - } - -.ck-content .image.image-style-side{ - float:right; - margin-left:var(--ck-content-image-style-spacing); - max-width:50%; - } - -.ck-content .image.image-style-align-left{ - float:left; - margin-right:var(--ck-content-image-style-spacing); - } - -.ck-content .image.image-style-align-right{ - float:right; - margin-left:var(--ck-content-image-style-spacing); - } - -.ck-content .image.image-style-block-align-right{ - margin-right:0; - margin-left:auto; - } - -.ck-content .image.image-style-block-align-left{ - margin-left:0; - margin-right:auto; - } - -.ck-content .image-style-align-center{ - margin-left:auto; - margin-right:auto; - } - -.ck-content .image-style-align-left{ - float:left; - margin-right:var(--ck-content-image-style-spacing); - } - -.ck-content .image-style-align-right{ - float:right; - margin-left:var(--ck-content-image-style-spacing); - } - -.ck-content p + .image.image-style-align-left, - .ck-content p + .image.image-style-align-right, - .ck-content p + .image.image-style-side{ - margin-top:0; - } - -.ck-content .image-inline.image-style-align-left, - .ck-content .image-inline.image-style-align-right{ - margin-top:var(--ck-content-inline-image-style-spacing); - margin-bottom:var(--ck-content-inline-image-style-spacing); - } - -.ck-content .image-inline.image-style-align-left{ - margin-right:var(--ck-content-inline-image-style-spacing); - } - -.ck-content .image-inline.image-style-align-right{ - margin-left:var(--ck-content-inline-image-style-spacing); - } - -.ck-content .image{ - display:table; - clear:both; - text-align:center; - margin:0.9em auto; - min-width:50px; - } - -.ck-content .image img{ - display:block; - margin:0 auto; - max-width:100%; - min-width:100%; - height:auto; - } - -.ck-content .image-inline{ - display:inline-flex; - max-width:100%; - align-items:flex-start; - } - -.ck-content .image-inline picture{ - display:flex; - } - -.ck-content .image-inline picture, - .ck-content .image-inline img{ - flex-grow:1; - flex-shrink:1; - max-width:100%; - } +.ck-content .image.image_resized > figcaption{ + display:block; +} :root{ - --ck-content-list-marker-color:var(--ck-content-font-color); - --ck-content-list-marker-font-family:var(--ck-content-font-family); - --ck-content-list-marker-font-size:var(--ck-content-font-size); + --ck-content-image-style-spacing:1.5em; + --ck-content-inline-image-style-spacing:calc(var(--ck-content-image-style-spacing) / 2); +} + +.ck-content .image.image-style-block-align-left, .ck-content .image.image-style-block-align-right{ + max-width:calc(100% - var(--ck-content-image-style-spacing)); +} + +.ck-content .image.image-style-align-left, .ck-content .image.image-style-align-right{ + clear:none; +} + +.ck-content .image.image-style-side{ + float:right; + margin-left:var(--ck-content-image-style-spacing); + max-width:50%; +} + +.ck-content .image.image-style-align-left{ + float:left; + margin-right:var(--ck-content-image-style-spacing); +} + +.ck-content .image.image-style-align-right{ + float:right; + margin-left:var(--ck-content-image-style-spacing); +} + +.ck-content .image.image-style-block-align-right{ + margin-left:auto; + margin-right:0; +} + +.ck-content .image.image-style-block-align-left{ + margin-left:0; + margin-right:auto; +} + +.ck-content .image-style-align-center{ + margin-left:auto; + margin-right:auto; +} + +.ck-content .image-style-align-left{ + float:left; + margin-right:var(--ck-content-image-style-spacing); +} + +.ck-content .image-style-align-right{ + float:right; + margin-left:var(--ck-content-image-style-spacing); +} + +.ck-content p + .image.image-style-align-left, .ck-content p + .image.image-style-align-right, .ck-content p + .image.image-style-side{ + margin-top:0; +} + +.ck-content .image-inline.image-style-align-left, .ck-content .image-inline.image-style-align-right{ + margin-top:var(--ck-content-inline-image-style-spacing); + margin-bottom:var(--ck-content-inline-image-style-spacing); +} + +.ck-content .image-inline.image-style-align-left{ + margin-right:var(--ck-content-inline-image-style-spacing); +} + +.ck-content .image-inline.image-style-align-right{ + margin-left:var(--ck-content-inline-image-style-spacing); +} + +.ck-content .image{ + clear:both; + text-align:center; + min-width:50px; + margin:.9em auto; + display:table; +} + +.ck-content .image img{ + min-width:100%; + max-width:100%; + height:auto; + margin:0 auto; + display:block; +} + +.ck-content .image-inline{ + align-items:flex-start; + max-width:100%; + display:inline-flex; +} + +.ck-content .image-inline picture{ + display:flex; +} + +.ck-content .image-inline picture, .ck-content .image-inline img{ + flex-grow:1; + flex-shrink:1; + max-width:100%; +} + +:root{ + --ck-content-list-marker-color:var(--ck-content-font-color); + --ck-content-list-marker-font-family:var(--ck-content-font-family); + --ck-content-list-marker-font-size:var(--ck-content-font-size); } .ck-content li > p:first-of-type{ - margin-top:0; - } + margin-top:0; +} .ck-content li > p:only-of-type{ - margin-top:0; - margin-bottom:0; - } + margin-top:0; + margin-bottom:0; +} .ck-content li.ck-list-marker-bold::marker{ - font-weight:bold; - } + font-weight:bold; +} .ck-content li.ck-list-marker-italic::marker{ - font-style:italic; - } + font-style:italic; +} .ck-content li.ck-list-marker-color::marker{ - color:var(--ck-content-list-marker-color); - } + color:var(--ck-content-list-marker-color); +} .ck-content li.ck-list-marker-font-family::marker{ - font-family:var(--ck-content-list-marker-font-family); - } + font-family:var(--ck-content-list-marker-font-family); +} .ck-content li.ck-list-marker-font-size::marker{ - font-size:var(--ck-content-list-marker-font-size); - } + font-size:var(--ck-content-list-marker-font-size); +} .ck-content li.ck-list-marker-font-size-tiny::marker{ - font-size:var(--ck-content-font-size-tiny); - } + font-size:var(--ck-content-font-size-tiny); +} .ck-content li.ck-list-marker-font-size-small::marker{ - font-size:var(--ck-content-font-size-small); - } + font-size:var(--ck-content-font-size-small); +} .ck-content li.ck-list-marker-font-size-big::marker{ - font-size:var(--ck-content-font-size-big); - } + font-size:var(--ck-content-font-size-big); +} .ck-content li.ck-list-marker-font-size-huge::marker{ - font-size:var(--ck-content-font-size-huge); - } + font-size:var(--ck-content-font-size-huge); +} .ck-content ol{ - list-style-type:decimal; + list-style-type:decimal; } .ck-content ol ol{ - list-style-type:lower-latin; - } + list-style-type:lower-latin; +} .ck-content ol ol ol{ - list-style-type:lower-roman; - } + list-style-type:lower-roman; +} .ck-content ol ol ol ol{ - list-style-type:upper-latin; - } + list-style-type:upper-latin; +} .ck-content ol ol ol ol ol{ - list-style-type:upper-roman; - } + list-style-type:upper-roman; +} .ck-content ul{ - list-style-type:disc; + list-style-type:disc; } .ck-content ul ul{ - list-style-type:circle; - } + list-style-type:circle; +} .ck-content ul ul ul{ - list-style-type:square; - } + list-style-type:square; +} .ck-content ul ul ul ul{ - list-style-type:square; - } + list-style-type:square; +} :root{ - --ck-content-todo-list-checkmark-size:16px; + --ck-content-todo-list-checkmark-size:16px; } -.ck-content .todo-list{ - list-style:none; + +.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input{ + -webkit-appearance:none; + width:var(--ck-content-todo-list-checkmark-size); + height:var(--ck-content-todo-list-checkmark-size); + vertical-align:middle; + border:0; + margin-left:0; + margin-right:-15px; + display:inline-block; + position:relative; + left:-25px; + right:0; } -.ck-content .todo-list li{ - position:relative; - margin-bottom:5px; - } -.ck-content .todo-list li .todo-list{ - margin-top:5px; - } -.ck-content .todo-list .todo-list__label > input{ - -webkit-appearance:none; - display:inline-block; - position:relative; - width:var(--ck-content-todo-list-checkmark-size); - height:var(--ck-content-todo-list-checkmark-size); - vertical-align:middle; - border:0; - left:-25px; - margin-right:-15px; - right:0; - margin-left:0; - } -.ck-content[dir=rtl] .todo-list .todo-list__label > input{ - left:0; - margin-right:0; - right:-25px; - margin-left:-15px; - } -.ck-content .todo-list .todo-list__label > input::before{ - display:block; - position:absolute; - box-sizing:border-box; - content:''; - width:100%; - height:100%; - border:1px solid hsl(0, 0%, 20%); - border-radius:2px; - transition:250ms ease-in-out box-shadow; - } + +[dir="rtl"]:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input){ + margin-left:-15px; + margin-right:0; + left:0; + right:-25px; +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):before{ + box-sizing:border-box; + content:""; + border:1px solid #333; + border-radius:2px; + width:100%; + height:100%; + transition:box-shadow .25s ease-in-out; + display:block; + position:absolute; +} + @media (prefers-reduced-motion: reduce){ -.ck-content .todo-list .todo-list__label > input::before{ - transition:none; - } - } -.ck-content .todo-list .todo-list__label > input::after{ - display:block; - position:absolute; - box-sizing:content-box; - pointer-events:none; - content:''; - left:calc( var(--ck-content-todo-list-checkmark-size) / 3); - top:calc( var(--ck-content-todo-list-checkmark-size) / 5.3); - width:calc( var(--ck-content-todo-list-checkmark-size) / 5.3); - height:calc( var(--ck-content-todo-list-checkmark-size) / 2.6); - border-style:solid; - border-color:transparent; - border-width:0 calc( var(--ck-content-todo-list-checkmark-size) / 8) calc( var(--ck-content-todo-list-checkmark-size) / 8) 0; - transform:rotate(45deg); - } -.ck-content .todo-list .todo-list__label > input[checked]::before{ - background:hsl(126, 64%, 41%); - border-color:hsl(126, 64%, 41%); - } -.ck-content .todo-list .todo-list__label > input[checked]::after{ - border-color:hsl(0, 0%, 100%); - } + :is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):before{ + transition:none; + } +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):after{ + box-sizing:content-box; + pointer-events:none; + content:""; + left:calc(var(--ck-content-todo-list-checkmark-size) / 3); + top:calc(var(--ck-content-todo-list-checkmark-size) / 5.3); + width:calc(var(--ck-content-todo-list-checkmark-size) / 5.3); + height:calc(var(--ck-content-todo-list-checkmark-size) / 2.6); + border-style:solid; + border-color:#0000; + border-width:0 calc(var(--ck-content-todo-list-checkmark-size) / 8) calc(var(--ck-content-todo-list-checkmark-size) / 8) 0; + display:block; + position:absolute; + transform:rotate(45deg); +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input)[checked]:before{ + background:#26ab33; + border-color:#26ab33; +} + +:is(.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input)[checked]:after{ + border-color:#fff; +} + +.ck-content .todo-list{ + list-style:none; +} + +.ck-content .todo-list li{ + margin-bottom:5px; + position:relative; +} + +.ck-content .todo-list li .todo-list{ + margin-top:5px; +} + .ck-content .todo-list .todo-list__label .todo-list__label__description{ - vertical-align:middle; - } -.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type=checkbox]{ - position:absolute; - } + vertical-align:middle; +} + +.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type="checkbox"]{ + position:absolute; +} + +.ck-editor__editable.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input{ + cursor:pointer; +} + +:is(.ck-editor__editable.ck-content .todo-list .todo-list__label > input, .ck-editor__editable.ck-content .todo-list .todo-list__label > span[contenteditable="false"] > input):hover:before{ + box-shadow:0 0 0 5px #0000001a; +} + +.ck-editor__editable.ck-content .todo-list .todo-list__label.todo-list__label_without-description input[type="checkbox"]{ + position:absolute; +} .ck-content .media{ - clear:both; - margin:0.9em 0; - display:block; - min-width:15em; + clear:both; + min-width:15em; + margin:.9em 0; + display:block; +} + +:root{ + --ck-content-color-mention-background:#9900301a; + --ck-content-color-mention-text:#990030; +} + +.ck-content .mention{ + background:var(--ck-content-color-mention-background); + color:var(--ck-content-color-mention-text); } .ck-content .page-break{ - position:relative; - clear:both; - padding:5px 0; - display:flex; - align-items:center; - justify-content:center; + clear:both; + justify-content:center; + align-items:center; + padding:5px 0; + display:flex; + position:relative; } -.ck-content .page-break::after{ - content:''; - position:absolute; - border-bottom:2px dashed hsl(0, 0%, 77%); - width:100%; - } +.ck-content .page-break:after{ + content:""; + border-bottom:2px dashed #c4c4c4; + width:100%; + position:absolute; +} .ck-content .page-break__label{ - position:relative; - z-index:1; - padding:.3em .6em; - display:block; - text-transform:uppercase; - border:1px solid hsl(0, 0%, 77%); - border-radius:2px; - font-size:0.75em; - font-weight:bold; - color:hsl(0, 0%, 20%); - background:hsl(0, 0%, 100%); - box-shadow:2px 2px 1px hsla(0, 0%, 0%, 0.15); - -webkit-user-select:none; - -moz-user-select:none; - -ms-user-select:none; - user-select:none; + z-index:1; + text-transform:uppercase; + color:#333; + -webkit-user-select:none; + user-select:none; + background:#fff; + border:1px solid #c4c4c4; + border-radius:2px; + padding:.3em .6em; + font-size:.75em; + font-weight:bold; + display:block; + position:relative; + box-shadow:2px 2px 1px #00000026; } -@media print{ - .ck-content .page-break{ - padding:0; - } - .ck-content .page-break::after{ - display:none; - } - .ck-content *:has(+ .page-break){ - margin-bottom:0; - } +@media print{ + .ck-content .page-break{ + padding:0; + } + + .ck-content .page-break:after{ + display:none; + } + + .ck-content :has( + .page-break){ + margin-bottom:0; + } +} + +.ck-content .table th{ + text-align:start; } .ck-content[dir="rtl"] .table th{ - text-align:right; - } + text-align:right; +} .ck-content[dir="ltr"] .table th{ - text-align:left; - } + text-align:left; +} .ck-content figure.table:not(.layout-table){ - display:table; - } + display:table; +} .ck-content figure.table:not(.layout-table) > table{ - width:100%; - height:100%; - } + width:100%; + height:100%; +} .ck-content .table:not(.layout-table){ - margin:0.9em auto; - } + margin:.9em auto; +} -.ck-content table.table:not(.layout-table), - .ck-content figure.table:not(.layout-table) > table{ - border-collapse:collapse; - border-spacing:0; - border:1px double hsl(0, 0%, 70%); - } +.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table{ + border-collapse:collapse; + border-spacing:0; + border:1px double #b3b3b3; +} -.ck-content table.table:not(.layout-table) > thead > tr > th, .ck-content figure.table:not(.layout-table) > table > thead > tr > th, .ck-content table.table:not(.layout-table) > tbody > tr > th, .ck-content figure.table:not(.layout-table) > table > tbody > tr > th{ - font-weight:bold; - background:hsla(0, 0%, 0%, 5%); - } +:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th{ + background:#0000000d; + font-weight:bold; +} -.ck-content table.table:not(.layout-table) > thead > tr > td, - .ck-content figure.table:not(.layout-table) > table > thead > tr > td, - .ck-content table.table:not(.layout-table) > tbody > tr > td, - .ck-content figure.table:not(.layout-table) > table > tbody > tr > td, - .ck-content table.table:not(.layout-table) > thead > tr > th, - .ck-content figure.table:not(.layout-table) > table > thead > tr > th, - .ck-content table.table:not(.layout-table) > tbody > tr > th, - .ck-content figure.table:not(.layout-table) > table > tbody > tr > th{ +:is(:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > td, :is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th) > p:first-of-type{ + margin-top:0; +} - min-width:2em; - padding:0.4em; - border:1px solid hsl(0, 0%, 75%); - } +:is(:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > td, :is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th) > p:last-of-type{ + margin-bottom:0; +} -.ck-content table.table:not(.layout-table) > thead > tr > td > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > td > p:first-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > td > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > td > p:first-of-type, .ck-content table.table:not(.layout-table) > thead > tr > th > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > th > p:first-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > th > p:first-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > th > p:first-of-type{ - margin-top:0; - } - -.ck-content table.table:not(.layout-table) > thead > tr > td > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > td > p:last-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > td > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > td > p:last-of-type, .ck-content table.table:not(.layout-table) > thead > tr > th > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > thead > tr > th > p:last-of-type, .ck-content table.table:not(.layout-table) > tbody > tr > th > p:last-of-type, .ck-content figure.table:not(.layout-table) > table > tbody > tr > th > p:last-of-type{ - margin-bottom:0; - } +:is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > td, :is(:is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > thead, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tfoot, :is(.ck-content table.table:not(.layout-table), .ck-content figure.table:not(.layout-table) > table) > tbody) > tr > th{ + border:1px solid #bfbfbf; + min-width:2em; + padding:.4em; +} @media print{ - .ck-content figure.table:not(.layout-table){ - width:fit-content; - height:fit-content; - } - .ck-content figure.table:not(.layout-table) > table{ - height:initial; - } + .ck-content figure.table:not(.layout-table){ + width:fit-content; + height:fit-content; + } + + .ck-content figure.table:not(.layout-table) > table{ + height:initial; + } } -.ck-content table.table.layout-table, - .ck-content figure.table.layout-table{ - margin-top:0; - margin-bottom:0; - } - -.ck-content table.table.layout-table, - .ck-content figure.table.layout-table > table{ - border-spacing:0; - } - -:root{ - --ck-content-color-table-caption-background:hsl(0, 0%, 97%); - --ck-content-color-table-caption-text:hsl(0, 0%, 20%); -} -.ck-content .table > figcaption, -.ck-content figure.table > table > caption{ - display:table-caption; - caption-side:top; - word-break:normal; - overflow-wrap:anywhere; - text-align:center; - color:var(--ck-content-color-table-caption-text); - background-color:var(--ck-content-color-table-caption-background); - padding:.6em; - font-size:.75em; - outline-offset:-1px; -} -@media (forced-colors: active){ - .ck-content .table > figcaption, .ck-content figure.table > table > caption{ - background-color:unset; - color:unset; - } - } - -.ck-content .table .ck-table-resized{ - table-layout:fixed; +.ck-content table.table.layout-table, .ck-content figure.table.layout-table{ + margin-top:0; + margin-bottom:0; } -.ck-content .table td, -.ck-content .table th{ - overflow-wrap:break-word; +.ck-content table.table.layout-table, .ck-content figure.table.layout-table > table{ + border-spacing:0; } :root{ - --ck-content-table-style-spacing:1.5em; + --ck-content-table-style-spacing:1.5em; } .ck-content .table.table-style-align-left{ - float:left; - margin-right:var(--ck-content-table-style-spacing); - } + float:left; + margin-right:var(--ck-content-table-style-spacing); +} .ck-content .table.table-style-align-right{ - float:right; - margin-left:var(--ck-content-table-style-spacing); - } + float:right; + margin-left:var(--ck-content-table-style-spacing); +} .ck-content .table.table-style-align-center{ - margin-left:auto; - margin-right:auto; - } + margin-left:auto; + margin-right:auto; +} .ck-content .table.table-style-block-align-left{ - margin-left:0; - margin-right:auto; - } + margin-left:0; + margin-right:auto; +} .ck-content .table.table-style-block-align-right{ - margin-left:auto; - margin-right:0; - } \ No newline at end of file + margin-left:auto; + margin-right:0; +} + +:root{ + --ck-content-color-table-caption-background:#f7f7f7; + --ck-content-color-table-caption-text:#333; +} + +.ck-content .table > figcaption, .ck-content figure.table > table > caption{ + caption-side:top; + word-break:normal; + overflow-wrap:anywhere; + text-align:center; + color:var(--ck-content-color-table-caption-text); + background-color:var(--ck-content-color-table-caption-background); + outline-offset:-1px; + padding:.6em; + font-size:.75em; + display:table-caption; +} + +@media (forced-colors: active){ + .ck-content .table > figcaption, .ck-content figure.table > table > caption{ + background-color:unset; + color:unset; + } +} + +.ck-content .table .ck-table-resized{ + table-layout:fixed; +} + +.ck-content .table td, .ck-content .table th{ + overflow-wrap:break-word; +} \ No newline at end of file diff --git a/apps/server/src/assets/db/demo.zip b/apps/server/src/assets/db/demo.zip index c84c99383a47e6f7d0409416e4a74432fb927a9d..acb4c966c36c166072ec80e640bdcb85de96bd59 100644 GIT binary patch delta 30302 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 m z*Dx`aD<|-dUjOcodHC*D2b!b8mM3#U4DJ+$;r@(VQ<8=uvMH!^n2@u_goNDr(!(Hr z98{^<6zk#~=<_FN+jef=a!_;Y-W{H!Z-uZPE^J`eHNe4-^@H`aE*MRg<<|&@KVzr; EKQOL^egFUf delta 30269 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 W01j%*A51?Hg^duh Date: Fri, 10 Apr 2026 21:09:29 +0300 Subject: [PATCH 11/33] fix(setup): trailing slash affects sync (closes #8045) --- apps/client/src/setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/setup.ts b/apps/client/src/setup.ts index 30232b85a3..67ad3e835d 100644 --- a/apps/client/src/setup.ts +++ b/apps/client/src/setup.ts @@ -99,7 +99,7 @@ class SetupController { } private async finish() { - const syncServerHost = this.syncServerHostInput.value.trim(); + const syncServerHost = this.syncServerHostInput.value.trim().replace(/\/+$/, ""); const syncProxy = this.syncProxyInput.value.trim(); const password = this.passwordInput.value; From 9f26d6efdcde4cbf163be2a61b17d10f4500d9b7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 21:11:49 +0300 Subject: [PATCH 12/33] feat(text): render note icons in autocompletion (closes #8188) --- apps/client/src/services/note_autocomplete.ts | 3 ++- apps/client/src/widgets/type_widgets/text/config.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb..18982307ac 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -68,7 +68,8 @@ async function autocompleteSourceForCKEditor(queryText: string) { name: row.notePathTitle || "", link: `#${row.notePath}`, notePath: row.notePath, - highlightedNotePathTitle: row.highlightedNotePathTitle + highlightedNotePathTitle: row.highlightedNotePathTitle, + icon: row.icon }; }) ); diff --git a/apps/client/src/widgets/type_widgets/text/config.ts b/apps/client/src/widgets/type_widgets/text/config.ts index 29b1a02699..f36eaadbd2 100644 --- a/apps/client/src/widgets/type_widgets/text/config.ts +++ b/apps/client/src/widgets/type_widgets/text/config.ts @@ -182,9 +182,16 @@ export async function buildConfig(opts: BuildEditorOptions): Promise noteAutocompleteService.autocompleteSourceForCKEditor(queryText), itemRenderer: (item) => { + const suggestion = item as Suggestion; const itemElement = document.createElement("button"); - itemElement.innerHTML = `${(item as Suggestion).highlightedNotePathTitle} `; + // Choose appropriate icon based on action + let iconClass = suggestion.icon ?? "bx bx-note"; + if (suggestion.action === "create-note") { + iconClass = "bx bx-plus"; + } + + itemElement.innerHTML = ` ${suggestion.highlightedNotePathTitle} `; return itemElement; }, From 19583cd84aef9c5782d0df8094ec6defe8a583a2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 21:14:39 +0300 Subject: [PATCH 13/33] fix(edit-demo): cloned notes lost due to async issue --- apps/edit-docs/src/edit-demo.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/edit-docs/src/edit-demo.ts b/apps/edit-docs/src/edit-demo.ts index 62ea490142..c2af297a21 100644 --- a/apps/edit-docs/src/edit-demo.ts +++ b/apps/edit-docs/src/edit-demo.ts @@ -17,13 +17,16 @@ 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() { From 878603c7b00b7bc22ff2e8ba255607aa043a16b8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 21:17:38 +0300 Subject: [PATCH 14/33] fix(jump_to_note): caret at the end when entering command mode (closes #7942) --- apps/client/src/widgets/dialogs/jump_to_note.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/dialogs/jump_to_note.tsx b/apps/client/src/widgets/dialogs/jump_to_note.tsx index 89c4388039..44c825e082 100644 --- a/apps/client/src/widgets/dialogs/jump_to_note.tsx +++ b/apps/client/src/widgets/dialogs/jump_to_note.tsx @@ -80,9 +80,19 @@ export default function JumpToNoteDialogComponent() { break; } - $autoComplete - .trigger("focus") - .trigger("select"); + $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"); + } // Add keyboard shortcut for full search shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => { From 112453355791d546e7f7e7086bb60742dd21a053 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 22:01:41 +0300 Subject: [PATCH 15/33] fix(edit-docs): wrong starting note --- apps/edit-docs/src/edit-demo.ts | 6 ++++++ apps/edit-docs/src/edit-docs.ts | 6 ++++++ apps/server/src/services/sql_init.ts | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/edit-docs/src/edit-demo.ts b/apps/edit-docs/src/edit-demo.ts index c2af297a21..f11d68685b 100644 --- a/apps/edit-docs/src/edit-demo.ts +++ b/apps/edit-docs/src/edit-demo.ts @@ -31,9 +31,15 @@ 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 registerHandlers() { diff --git a/apps/edit-docs/src/edit-docs.ts b/apps/edit-docs/src/edit-docs.ts index 40163330d0..49846b9eb6 100644 --- a/apps/edit-docs/src/edit-docs.ts +++ b/apps/edit-docs/src/edit-docs.ts @@ -141,9 +141,15 @@ 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) { diff --git a/apps/server/src/services/sql_init.ts b/apps/server/src/services/sql_init.ts index c6a084a72b..5212cfb6e5 100644 --- a/apps/server/src/services/sql_init.ts +++ b/apps/server/src/services/sql_init.ts @@ -141,7 +141,7 @@ async function createInitialDatabase(skipDemoDb?: boolean) { // the previous solution was to move option initialization here, but then the important parts of initialization // are not all in one transaction (because ZIP import is async and thus not transactional) - const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 ORDER BY notePosition"); + const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root"; optionService.setOption( "openNoteContexts", From 05da2d7a50a523a8c9741e61f8088d392776885e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 22:11:10 +0300 Subject: [PATCH 16/33] fix(collections/table): unable to set number cell to zero (closes #6555) --- apps/client/src/widgets/collections/table/row_editing.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/client/src/widgets/collections/table/row_editing.ts b/apps/client/src/widgets/collections/table/row_editing.ts index c4df69e7ae..03f6b8ff49 100644 --- a/apps/client/src/widgets/collections/table/row_editing.ts +++ b/apps/client/src/widgets/collections/table/row_editing.ts @@ -51,6 +51,8 @@ export default function useRowTableEditing(api: RefObject, 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") { From e29555a89b1735771fb10aaa09aecc537fb3973c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 22:17:55 +0300 Subject: [PATCH 17/33] fix(collections/calendar): displaying deep children (closes #7944) --- apps/client/src/widgets/collections/calendar/event_builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/collections/calendar/event_builder.ts b/apps/client/src/widgets/collections/calendar/event_builder.ts index dec64feee8..fd0ce3bbb2 100644 --- a/apps/client/src/widgets/collections/calendar/event_builder.ts +++ b/apps/client/src/widgets/collections/calendar/event_builder.ts @@ -75,7 +75,7 @@ export async function buildEventsForCalendar(note: FNote, e: EventSourceFuncArg) if (dateNote.hasChildren()) { - const childNoteIds = await dateNote.getSubtreeNoteIds(); + const childNoteIds = dateNote.getChildNoteIds(); for (const childNoteId of childNoteIds) { childNoteToDateMapping[childNoteId] = startDate; } From 626438d8f5a5423f36b9a998304d85210820207e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 22:33:39 +0300 Subject: [PATCH 18/33] fix(options): titles can be modified (closes #5371) --- apps/client/src/widgets/note_title.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index 76a86104c2..03e02beee2 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -29,6 +29,7 @@ export default function NoteTitleWidget(props: {className?: string}) { || (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) || isLaunchBarConfig(note.noteId) || note.noteId.startsWith("_help_") + || note.noteId.startsWith("_options") || viewScope?.viewMode !== "default"; setReadOnly(isReadOnly); }, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]); From 14f761de364feb7ead46d5e6ca2ff6aa50a6a7b8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 22:35:06 +0300 Subject: [PATCH 19/33] fix(options): icons can be modified --- apps/client/src/widgets/note_icon.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/note_icon.tsx b/apps/client/src/widgets/note_icon.tsx index 31ca7e65b4..b3d15d6889 100644 --- a/apps/client/src/widgets/note_icon.tsx +++ b/apps/client/src/widgets/note_icon.tsx @@ -13,7 +13,7 @@ import { CellComponentProps, Grid } from "react-window"; import FNote from "../entities/fnote"; import attributes from "../services/attributes"; import server from "../services/server"; -import { isDesktop, isMobile } from "../services/utils"; +import { isDesktop, isLaunchBarConfig, isMobile } from "../services/utils"; import ActionButton from "./react/ActionButton"; import Dropdown from "./react/Dropdown"; import { FormDropdownDivider, FormListItem } from "./react/FormList"; @@ -42,8 +42,13 @@ export default function NoteIcon() { setIcon(note?.getIcon()); }, [ note, iconClass, workspaceIconClass ]); + const isDisabled = viewScope?.viewMode !== "default" + || (note?.noteId && isLaunchBarConfig(note.noteId)) + || note?.noteId?.startsWith("_help_") + || note?.noteId?.startsWith("_options"); + if (isMobile()) { - return ; + return ; } return ( @@ -55,16 +60,17 @@ export default function NoteIcon() { dropdownOptions={{ autoClose: "outside" }} buttonClassName={`note-icon tn-focusable-button ${icon ?? "bx bx-empty"}`} hideToggleArrow - disabled={viewScope?.viewMode !== "default"} + disabled={isDisabled} > { note && dropdownRef?.current?.hide()} columnCount={12} /> } ); } -function MobileNoteIconSwitcher({ note, icon }: { +function MobileNoteIconSwitcher({ note, icon, disabled }: { note: FNote | null | undefined; icon: string | null | undefined; + disabled?: boolean; }) { const [ modalShown, setModalShown ] = useState(false); const { windowWidth } = useWindowSize(); @@ -76,6 +82,7 @@ function MobileNoteIconSwitcher({ note, icon }: { icon={icon ?? "bx bx-empty"} text={t("note_icon.change_note_icon")} onClick={() => setModalShown(true)} + disabled={disabled} /> {createPortal(( From c43505001876bf7aec88be91c4c0719b6af2234f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 22:36:13 +0300 Subject: [PATCH 20/33] refactor(client): deduplicate checks for title/icon editability --- apps/client/src/entities/fnote.ts | 10 ++++++++++ apps/client/src/widgets/note_icon.tsx | 6 ++---- apps/client/src/widgets/note_title.tsx | 5 +---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 5fe7caf33c..a30a83975a 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -236,6 +236,16 @@ 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; } diff --git a/apps/client/src/widgets/note_icon.tsx b/apps/client/src/widgets/note_icon.tsx index b3d15d6889..c6ed7e618b 100644 --- a/apps/client/src/widgets/note_icon.tsx +++ b/apps/client/src/widgets/note_icon.tsx @@ -13,7 +13,7 @@ import { CellComponentProps, Grid } from "react-window"; import FNote from "../entities/fnote"; import attributes from "../services/attributes"; import server from "../services/server"; -import { isDesktop, isLaunchBarConfig, isMobile } from "../services/utils"; +import { isDesktop, isMobile } from "../services/utils"; import ActionButton from "./react/ActionButton"; import Dropdown from "./react/Dropdown"; import { FormDropdownDivider, FormListItem } from "./react/FormList"; @@ -43,9 +43,7 @@ export default function NoteIcon() { }, [ note, iconClass, workspaceIconClass ]); const isDisabled = viewScope?.viewMode !== "default" - || (note?.noteId && isLaunchBarConfig(note.noteId)) - || note?.noteId?.startsWith("_help_") - || note?.noteId?.startsWith("_options"); + || note?.isMetadataReadOnly; if (isMobile()) { return ; diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index 03e02beee2..dac6a498e0 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -9,7 +9,6 @@ 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 { isLaunchBarConfig } from "../services/utils"; import FormTextBox from "./react/FormTextBox"; import { useNoteContext, useNoteProperty, useSpacedUpdate, useTriliumEvent, useTriliumEvents } from "./react/hooks"; @@ -27,9 +26,7 @@ export default function NoteTitleWidget(props: {className?: string}) { const isReadOnly = note === null || note === undefined || (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) - || isLaunchBarConfig(note.noteId) - || note.noteId.startsWith("_help_") - || note.noteId.startsWith("_options") + || note.isMetadataReadOnly || viewScope?.viewMode !== "default"; setReadOnly(isReadOnly); }, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]); From 3d2fa578739cefe1394dc471f7876d9ef40ce3fd Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:01:07 +0300 Subject: [PATCH 21/33] fix(toc): equations sometimes duplicated --- apps/client/src/widgets/sidebar/TableOfContents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index fdf616ee26..eef19100eb 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -87,7 +87,7 @@ function TableOfContentsHeading({ heading, scrollToHeading, activeHeadingId }: { // Render math equations after component mounts/updates useEffect(() => { if (!contentRef.current) return; - const mathElements = contentRef.current.querySelectorAll(".ck-math-tex"); + const mathElements = contentRef.current.querySelectorAll(".math-tex"); for (const mathEl of mathElements ?? []) { try { From d3c596aaa04e6a3402e4133fd4a43cbc33e7f30a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:03:30 +0300 Subject: [PATCH 22/33] feat(highlights): render highlighted equations in new layout --- .../src/widgets/sidebar/HighlightsList.tsx | 72 +++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 401554203d..580591bd63 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,11 +1,13 @@ import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5"; import { createPortal } from "preact/compat"; -import { useCallback, useEffect, useState } from "preact/hooks"; +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, 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"; @@ -84,20 +86,11 @@ function AbstractHighlightsList({ highlights, scrollToHi {filteredHighlights.length > 0 ? (
    {filteredHighlights.map(highlight => ( -
  1. scrollToHighlight(highlight)} - > - {highlight.text} -
  2. + /> ))}
) : ( @@ -112,6 +105,43 @@ function AbstractHighlightsList({ highlights, scrollToHi ); } +function HighlightItem({ highlight, onClick }: { + highlight: T; + onClick(): void; +}) { + const contentRef = useRef(null); + + // Render math equations after component mounts/updates + useEffect(() => { + if (!contentRef.current) return; + const mathElements = contentRef.current.querySelectorAll(".math-tex"); + + for (const mathEl of mathElements ?? []) { + try { + math.render(mathEl.textContent || "", mathEl as HTMLElement); + } catch (e) { + console.warn("Failed to render math in highlights:", e); + } + } + }, [highlight.text]); + + return ( +
  • + +
  • + ); +} + //#region Editable text (CKEditor) interface CKHighlight extends RawHighlight { textNode: ModelText; @@ -201,9 +231,23 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { }; if (Object.values(attrs).some(Boolean)) { + // Get HTML content from DOM (includes nested elements like math) + let html = item.data; + 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; + } + + // Skip if we already have this exact content (same parent element) + const prev = result[result.length - 1]; + if (prev?.text === html) continue; + result.push({ id: randomString(), - text: item.data, + text: html, attrs, textNode: item.textNode, offset: item.startOffset From 97256ba291d3e2c8bba32d33921e34923510453d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:12:07 +0300 Subject: [PATCH 23/33] feat(options): add nicer sync timeout selector (closes #5513) --- .../src/widgets/type_widgets/options/sync.tsx | 47 ++++++++++--------- apps/server/src/routes/api/options.ts | 1 + apps/server/src/services/options_init.spec.ts | 30 ++++++++++++ apps/server/src/services/options_init.ts | 33 ++++++++++++- apps/server/src/services/sync_options.ts | 11 +++-- packages/commons/src/lib/options_interface.ts | 1 + 6 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/services/options_init.spec.ts diff --git a/apps/client/src/widgets/type_widgets/options/sync.tsx b/apps/client/src/widgets/type_widgets/options/sync.tsx index 1ffb40f373..18137b344f 100644 --- a/apps/client/src/widgets/type_widgets/options/sync.tsx +++ b/apps/client/src/widgets/type_widgets/options/sync.tsx @@ -1,16 +1,18 @@ +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 FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; +import FormText from "../../react/FormText"; +import FormTextBox from "../../react/FormTextBox"; +import { useTriliumOptions } from "../../react/hooks"; import RawHtml from "../../react/RawHtml"; import OptionsSection from "./components/OptionsSection"; -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"; +import TimeSelector from "./components/TimeSelector"; export default function SyncOptions() { return ( @@ -18,13 +20,12 @@ export default function SyncOptions() { - ) + ); } export function SyncConfiguration() { - const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncServerTimeout", "syncProxy"); + const [ options, setOptions ] = useTriliumOptions("syncServerHost", "syncProxy"); const syncServerHost = useRef(options.syncServerHost); - const syncServerTimeout = useRef(options.syncServerTimeout); const syncProxy = useRef(options.syncProxy); return ( @@ -32,13 +33,12 @@ export function SyncConfiguration() {
    { setOptions({ syncServerHost: syncServerHost.current, - syncServerTimeout: syncServerTimeout.current, syncProxy: syncProxy.current }); e.preventDefault(); }}> - syncServerHost.current = newValue} /> @@ -50,27 +50,28 @@ export function SyncConfiguration() { } > - syncProxy.current = newValue} /> - - syncServerTimeout.current = newValue} - /> - -
    + + + + - ) + ); } export function SyncTest() { @@ -90,5 +91,5 @@ export function SyncTest() { }} /> - ) -} \ No newline at end of file + ); +} diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index 9e21dcb7b7..912fef055e 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -32,6 +32,7 @@ const ALLOWED_OPTIONS = new Set([ "codeNoteTheme", "syncServerHost", "syncServerTimeout", + "syncServerTimeoutTimeScale", "syncProxy", "hoistedNoteId", "mainFontSize", diff --git a/apps/server/src/services/options_init.spec.ts b/apps/server/src/services/options_init.spec.ts new file mode 100644 index 0000000000..b133c6ded8 --- /dev/null +++ b/apps/server/src/services/options_init.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { migrateSyncTimeoutFromMilliseconds } from "./options_init.js"; + +describe("migrateSyncTimeoutFromMilliseconds", () => { + it("returns null when no migration is needed", () => { + // Values < 1000 are already in seconds/minutes format + expect(migrateSyncTimeoutFromMilliseconds(120)).toBeNull(); + expect(migrateSyncTimeoutFromMilliseconds(500)).toBeNull(); + expect(migrateSyncTimeoutFromMilliseconds(999)).toBeNull(); + expect(migrateSyncTimeoutFromMilliseconds(NaN)).toBeNull(); + }); + + it("migrates to minutes when divisible by 60", () => { + expect(migrateSyncTimeoutFromMilliseconds(60000)).toEqual({ value: 1, scale: 60 }); // 1 minute + expect(migrateSyncTimeoutFromMilliseconds(120000)).toEqual({ value: 2, scale: 60 }); // 2 minutes + expect(migrateSyncTimeoutFromMilliseconds(300000)).toEqual({ value: 5, scale: 60 }); // 5 minutes + expect(migrateSyncTimeoutFromMilliseconds(3600000)).toEqual({ value: 60, scale: 60 }); // 60 minutes + }); + + it("migrates to seconds when not divisible by 60", () => { + expect(migrateSyncTimeoutFromMilliseconds(1000)).toEqual({ value: 1, scale: 1 }); // 1 second + expect(migrateSyncTimeoutFromMilliseconds(45000)).toEqual({ value: 45, scale: 1 }); // 45 seconds + expect(migrateSyncTimeoutFromMilliseconds(90000)).toEqual({ value: 90, scale: 1 }); // 90 seconds + expect(migrateSyncTimeoutFromMilliseconds(150000)).toEqual({ value: 150, scale: 1 }); // 150 seconds + }); + + it("rounds milliseconds to nearest second", () => { + expect(migrateSyncTimeoutFromMilliseconds(120500)).toEqual({ value: 121, scale: 1 }); + }); +}); diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index 92912222d0..bb6336b1f7 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -66,7 +66,7 @@ async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = optionService.createOption("textNoteEditorType", "ckeditor-classic", true); optionService.createOption("syncServerHost", opts.syncServerHost || "", false); - optionService.createOption("syncServerTimeout", "120000", false); + optionService.createOption("syncServerTimeout", "2", false); // 2 minutes (with default scale of 60) optionService.createOption("syncProxy", opts.syncProxy || "", false); } @@ -74,6 +74,7 @@ async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized. */ const defaultOptions: DefaultOption[] = [ + { name: "syncServerTimeoutTimeScale", value: "60", isSynced: false }, // default to Minutes { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true }, { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true }, @@ -255,6 +256,36 @@ function initStartupOptions() { ]) ); } + + // Migrate syncServerTimeout from milliseconds to seconds/minutes (for existing installations) + const syncTimeout = parseInt(optionsMap.syncServerTimeout, 10); + const migrated = migrateSyncTimeoutFromMilliseconds(syncTimeout); + if (migrated) { + optionService.setOption("syncServerTimeout", String(migrated.value)); + optionService.setOption("syncServerTimeoutTimeScale", String(migrated.scale)); + const unit = migrated.scale === 60 ? "minutes" : "seconds"; + log.info(`Migrated syncServerTimeout from ${syncTimeout}ms to ${migrated.value} ${unit}`); + } +} + +/** + * Migrates a sync timeout value from milliseconds to a value/scale pair. + * Values >= 1000 are assumed to be in milliseconds (since 1000+ seconds = 16+ minutes is unlikely). + * + * @returns The migrated value and scale, or null if no migration is needed. + */ +export function migrateSyncTimeoutFromMilliseconds(milliseconds: number): { value: number; scale: number } | null { + if (isNaN(milliseconds) || milliseconds < 1000) { + return null; + } + + const seconds = Math.round(milliseconds / 1000); + + // If divisible by 60, store as minutes; otherwise store as seconds + if (seconds >= 60 && seconds % 60 === 0) { + return { value: seconds / 60, scale: 60 }; + } + return { value: seconds, scale: 1 }; } function getKeyboardDefaultOptions() { diff --git a/apps/server/src/services/sync_options.ts b/apps/server/src/services/sync_options.ts index 657c1b2149..5b6c19cb8a 100644 --- a/apps/server/src/services/sync_options.ts +++ b/apps/server/src/services/sync_options.ts @@ -1,7 +1,7 @@ -"use strict"; -import optionService from "./options.js"; + import config from "./config.js"; +import optionService from "./options.js"; import { normalizeUrl } from "./utils.js"; /* @@ -29,6 +29,11 @@ export default { // and we need to override it with config from config.ini return !!syncServerHost && syncServerHost !== "disabled"; }, - getSyncTimeout: () => parseInt(get("syncServerTimeout")) || 120000, + // Value is stored with a time scale, convert to milliseconds for use + getSyncTimeout: () => { + const value = parseInt(get("syncServerTimeout"), 10) || 2; + const scale = parseInt(optionService.getOption("syncServerTimeoutTimeScale"), 10) || 60; + return value * scale * 1000; + }, getSyncProxy: () => get("syncProxy") }; diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 590e3436dc..08ab1ea45f 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -23,6 +23,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions Date: Fri, 10 Apr 2026 23:17:09 +0300 Subject: [PATCH 24/33] chore(ai): update system prompt regarding tests --- .github/copilot-instructions.md | 3 +++ CLAUDE.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 21a4171729..8f87f6a166 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -340,6 +340,9 @@ Trilium provides powerful user scripting capabilities: ## 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", () => { diff --git a/CLAUDE.md b/CLAUDE.md index d113a18da2..ab6067e058 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,6 +119,8 @@ 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: From 84cfa0a9f7a24896395a9f0862f95f5a7345c717 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:17:47 +0300 Subject: [PATCH 25/33] fix(server): overriding sync_options affected by the timeScale --- apps/server/src/services/sync_options.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/sync_options.ts b/apps/server/src/services/sync_options.ts index 5b6c19cb8a..f946bc52ba 100644 --- a/apps/server/src/services/sync_options.ts +++ b/apps/server/src/services/sync_options.ts @@ -1,5 +1,3 @@ - - import config from "./config.js"; import optionService from "./options.js"; import { normalizeUrl } from "./utils.js"; @@ -29,9 +27,16 @@ export default { // and we need to override it with config from config.ini return !!syncServerHost && syncServerHost !== "disabled"; }, - // Value is stored with a time scale, convert to milliseconds for use + // Value is stored with a time scale, convert to milliseconds for use. + // Config file overrides are treated as raw milliseconds for backward compatibility. getSyncTimeout: () => { - const value = parseInt(get("syncServerTimeout"), 10) || 2; + const configValue = config["Sync"]?.syncServerTimeout; + if (configValue) { + // Config override: treat as raw milliseconds (backward compatible) + return parseInt(configValue, 10) || 120000; + } + // Database option: use value × scale (with safe defaults) + const value = parseInt(optionService.getOption("syncServerTimeout"), 10) || 2; const scale = parseInt(optionService.getOption("syncServerTimeoutTimeScale"), 10) || 60; return value * scale * 1000; }, From 01b6926054dfd78b6badc6cfceb921e15e2eee2a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:20:24 +0300 Subject: [PATCH 26/33] test(server): sync options with various scenarios --- apps/server/src/services/sync_options.spec.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/server/src/services/sync_options.spec.ts diff --git a/apps/server/src/services/sync_options.spec.ts b/apps/server/src/services/sync_options.spec.ts new file mode 100644 index 0000000000..7f24d19f93 --- /dev/null +++ b/apps/server/src/services/sync_options.spec.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the dependencies before importing the module +vi.mock("./config.js", () => ({ default: { Sync: {} } })); +vi.mock("./options.js", () => ({ default: { getOption: vi.fn() } })); + +import config from "./config.js"; +import optionService from "./options.js"; +import syncOptions from "./sync_options.js"; + +describe("syncOptions.getSyncTimeout", () => { + beforeEach(() => { + // Reset config to empty + (config as any).Sync = {}; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("uses database value × scale when no config override", () => { + vi.mocked(optionService.getOption).mockImplementation((name: string) => { + if (name === "syncServerTimeout") return "2"; + if (name === "syncServerTimeoutTimeScale") return "60"; + return ""; + }); + + expect(syncOptions.getSyncTimeout()).toBe(120000); // 2 × 60 × 1000 = 2 minutes + }); + + it("supports different time scales from database", () => { + // 30 seconds + vi.mocked(optionService.getOption).mockImplementation((name: string) => { + if (name === "syncServerTimeout") return "30"; + if (name === "syncServerTimeoutTimeScale") return "1"; + return ""; + }); + expect(syncOptions.getSyncTimeout()).toBe(30000); + + // 1 hour + vi.mocked(optionService.getOption).mockImplementation((name: string) => { + if (name === "syncServerTimeout") return "1"; + if (name === "syncServerTimeoutTimeScale") return "3600"; + return ""; + }); + expect(syncOptions.getSyncTimeout()).toBe(3600000); + }); + + it("treats config override as raw milliseconds (ignores db scale)", () => { + (config as any).Sync = { syncServerTimeout: "60000" }; + + // Even if db has a different scale, config value is treated as raw ms + vi.mocked(optionService.getOption).mockImplementation((name: string) => { + if (name === "syncServerTimeout") return "5"; + if (name === "syncServerTimeoutTimeScale") return "3600"; // hours + return ""; + }); + + expect(syncOptions.getSyncTimeout()).toBe(60000); // 60 seconds, not 5 hours + }); + + it("uses safe defaults for invalid database values", () => { + vi.mocked(optionService.getOption).mockImplementation(() => ""); + + // Defaults: value=2, scale=60 → 120000ms + expect(syncOptions.getSyncTimeout()).toBe(120000); + }); + + it("uses safe default for invalid config override", () => { + (config as any).Sync = { syncServerTimeout: "invalid" }; + + expect(syncOptions.getSyncTimeout()).toBe(120000); // fallback to 120000 + }); +}); From 36c31dac141f2287bf331a62d9d9535ec5af1ba2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:20:35 +0300 Subject: [PATCH 27/33] refactor(client): remove unused translation --- apps/client/src/translations/en/translation.json | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index a080456435..c47debcdfe 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1515,7 +1515,6 @@ "config_title": "Sync Configuration", "server_address": "Server instance address", "timeout": "Sync timeout", - "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).", From bb381c134911077f62bf1110b3441dec6ce7f92a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:21:00 +0300 Subject: [PATCH 28/33] refactor(highlights): remove unnecessary logic in old layout (closes #5375) --- apps/client/src/widgets/highlights_list.ts | 97 ++-------------------- 1 file changed, 5 insertions(+), 92 deletions(-) diff --git a/apps/client/src/widgets/highlights_list.ts b/apps/client/src/widgets/highlights_list.ts index b6e3f2e6f0..512e8eff30 100644 --- a/apps/client/src/widgets/highlights_list.ts +++ b/apps/client/src/widgets/highlights_list.ts @@ -9,7 +9,6 @@ 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"; @@ -125,77 +124,6 @@ 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 = /\\\(([\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 = /]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi; @@ -239,9 +167,6 @@ export default class HighlightsListWidget extends RightPanelWidget { const $highlightsList = $("
      "); let prevEndIndex = -1, hlLiCount = 0; - let prevSubHtml: string | null = null; - // Used to determine if a string is only a formula - const onlyMathRegex = /^\\\([^\)]*?\)<\/span>(?:\\\([^\)]*?\)<\/span>)*$/; for (let match: RegExpMatchArray | null = null, hltIndex = 0; (match = combinedRegex.exec(content)) !== null; hltIndex++) { const subHtml = match[0]; @@ -257,25 +182,14 @@ 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()? - //Can’t 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) { - 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( - $("
    1. ") - .html(subHtml) - .on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex)) - ); - } + $highlightsList.append( + $("
    2. ") + .html(subHtml) + .on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex)) + ); hlLiCount++; } else { @@ -284,7 +198,6 @@ export default class HighlightsListWidget extends RightPanelWidget { } } prevEndIndex = endIndex; - prevSubHtml = subHtml; } return { $highlightsList, From 524f8df8661e284f389e91b80ba2996465e92344 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:36:29 +0300 Subject: [PATCH 29/33] feat(search): add an option to open all results (closes #5376) --- .../src/translations/en/translation.json | 6 +- .../note_bars/CollectionProperties.tsx | 56 ++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index c47debcdfe..af8a606f1c 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -806,7 +806,11 @@ "board": "Board", "presentation": "Presentation", "include_archived_notes": "Show archived notes", - "hide_child_notes": "Hide child notes in tree" + "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}}." }, "edited_notes": { "no_edited_notes_found": "No edited notes on this day yet...", diff --git a/apps/client/src/widgets/note_bars/CollectionProperties.tsx b/apps/client/src/widgets/note_bars/CollectionProperties.tsx index 66bfb32c45..14f8685eaa 100644 --- a/apps/client/src/widgets/note_bars/CollectionProperties.tsx +++ b/apps/client/src/widgets/note_bars/CollectionProperties.tsx @@ -2,10 +2,13 @@ import "./CollectionProperties.css"; import { t } from "i18next"; import { ComponentChildren } from "preact"; -import { useRef } from "preact/hooks"; +import { useRef, useState } 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"; @@ -24,6 +27,8 @@ export const ICON_MAPPINGS: Record = { presentation: "bx bx-rectangle" }; +const MAX_OPEN_TABS = 50; + export default function CollectionProperties({ note, centerChildren, rightChildren }: { note: FNote; centerChildren?: ComponentChildren; @@ -31,6 +36,7 @@ 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 ?? "") &&
      @@ -43,11 +49,59 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
      {rightChildren} + {noteType === "search" && ( + + )}
      ); } +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 ( + + ); +} + function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) { // Keyboard shortcut const dropdownContainerRef = useRef(null); From 4787f644a6c14670ae144d51bb1007d9cdb3a413 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 10 Apr 2026 23:38:29 +0300 Subject: [PATCH 30/33] feat(options): friendlier zoom factor selection (closes #5444) --- .../type_widgets/options/appearance.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/options/appearance.tsx b/apps/client/src/widgets/type_widgets/options/appearance.tsx index 94fc1f1fbd..dc4a3bdc26 100644 --- a/apps/client/src/widgets/type_widgets/options/appearance.tsx +++ b/apps/client/src/widgets/type_widgets/options/appearance.tsx @@ -3,6 +3,7 @@ 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"; @@ -14,9 +15,10 @@ import FormGroup from "../../react/FormGroup"; import FormRadioGroup from "../../react/FormRadioGroup"; import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect"; import FormText from "../../react/FormText"; -import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; +import { 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"; @@ -333,20 +335,23 @@ function Font({ title, fontFamilyOption, fontSizeOption }: { title: string, font } function ElectronIntegration() { - const [ zoomFactor, setZoomFactor ] = useTriliumOption("zoomFactor"); + const [ zoomFactor ] = useTriliumOption("zoomFactor"); const [ nativeTitleBarVisible, setNativeTitleBarVisible ] = useTriliumOptionBool("nativeTitleBarVisible"); const [ backgroundEffects, setBackgroundEffects ] = useTriliumOptionBool("backgroundEffects"); + const zoomPercentage = Math.round(parseFloat(zoomFactor || "1") * 100); + return ( - - + zoomService.setZoomFactorAndSave(parseInt(v, 10) / 100)} + unit={t("units.percentage")} /> - -
      + Date: Fri, 10 Apr 2026 23:54:27 +0300 Subject: [PATCH 31/33] chore(client): address requested changes --- apps/client/src/widgets/note_title.tsx | 1 + .../src/widgets/sidebar/HighlightsList.tsx | 21 ++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/client/src/widgets/note_title.tsx b/apps/client/src/widgets/note_title.tsx index dac6a498e0..34b27c9298 100644 --- a/apps/client/src/widgets/note_title.tsx +++ b/apps/client/src/widgets/note_title.tsx @@ -66,6 +66,7 @@ export default function NoteTitleWidget(props: {className?: string}) { useEffect(() => { if (pendingSelect.current && textBoxRef.current && document.activeElement === textBoxRef.current) { textBoxRef.current.select(); + pendingSelect.current = false; } }, [title]); diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 580591bd63..d0c447c283 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -233,18 +233,19 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { if (Object.values(attrs).some(Boolean)) { // Get HTML content from DOM (includes nested elements like math) let html = item.data; - 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; + 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. } - // Skip if we already have this exact content (same parent element) - const prev = result[result.length - 1]; - if (prev?.text === html) continue; - result.push({ id: randomString(), text: html, From 18aec84be5addcef2a2f0aed61fc378665647bef Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 11 Apr 2026 00:16:10 +0300 Subject: [PATCH 32/33] chore(client): address requested changes --- .../src/widgets/type_widgets/text/config.ts | 7 +- apps/server/src/services/options_init.spec.ts | 27 ++++---- apps/server/src/services/options_init.ts | 68 ++++++++++--------- apps/server/src/services/sync_options.spec.ts | 58 +++++----------- apps/server/src/services/sync_options.ts | 9 ++- .../User Guide/Installation & Setup/Backup.md | 2 +- 6 files changed, 75 insertions(+), 96 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/text/config.ts b/apps/client/src/widgets/type_widgets/text/config.ts index f36eaadbd2..e4812156e4 100644 --- a/apps/client/src/widgets/type_widgets/text/config.ts +++ b/apps/client/src/widgets/type_widgets/text/config.ts @@ -185,13 +185,18 @@ export async function buildConfig(opts: BuildEditorOptions): Promise ${suggestion.highlightedNotePathTitle} `; + itemElement.append(iconElement, document.createTextNode(" ")); + const titleContainer = document.createElement("span"); + titleContainer.innerHTML = suggestion.highlightedNotePathTitle ?? ""; + itemElement.append(...titleContainer.childNodes, document.createTextNode(" ")); return itemElement; }, diff --git a/apps/server/src/services/options_init.spec.ts b/apps/server/src/services/options_init.spec.ts index b133c6ded8..ddad092446 100644 --- a/apps/server/src/services/options_init.spec.ts +++ b/apps/server/src/services/options_init.spec.ts @@ -2,29 +2,26 @@ import { describe, expect, it } from "vitest"; import { migrateSyncTimeoutFromMilliseconds } from "./options_init.js"; describe("migrateSyncTimeoutFromMilliseconds", () => { - it("returns null when no migration is needed", () => { - // Values < 1000 are already in seconds/minutes format + it("returns null when no migration is needed (values < 1000 are already in seconds)", () => { expect(migrateSyncTimeoutFromMilliseconds(120)).toBeNull(); expect(migrateSyncTimeoutFromMilliseconds(500)).toBeNull(); expect(migrateSyncTimeoutFromMilliseconds(999)).toBeNull(); expect(migrateSyncTimeoutFromMilliseconds(NaN)).toBeNull(); }); - it("migrates to minutes when divisible by 60", () => { - expect(migrateSyncTimeoutFromMilliseconds(60000)).toEqual({ value: 1, scale: 60 }); // 1 minute - expect(migrateSyncTimeoutFromMilliseconds(120000)).toEqual({ value: 2, scale: 60 }); // 2 minutes - expect(migrateSyncTimeoutFromMilliseconds(300000)).toEqual({ value: 5, scale: 60 }); // 5 minutes - expect(migrateSyncTimeoutFromMilliseconds(3600000)).toEqual({ value: 60, scale: 60 }); // 60 minutes - }); + it("converts milliseconds to seconds and sets display scale", () => { + // Value is always stored in seconds; scale is for display only + // Divisible by 60 → display as minutes + expect(migrateSyncTimeoutFromMilliseconds(60000)).toEqual({ value: 60, scale: 60 }); // 60s, display as 1 min + expect(migrateSyncTimeoutFromMilliseconds(120000)).toEqual({ value: 120, scale: 60 }); // 120s, display as 2 min + expect(migrateSyncTimeoutFromMilliseconds(3600000)).toEqual({ value: 3600, scale: 60 }); // 3600s, display as 60 min - it("migrates to seconds when not divisible by 60", () => { - expect(migrateSyncTimeoutFromMilliseconds(1000)).toEqual({ value: 1, scale: 1 }); // 1 second - expect(migrateSyncTimeoutFromMilliseconds(45000)).toEqual({ value: 45, scale: 1 }); // 45 seconds - expect(migrateSyncTimeoutFromMilliseconds(90000)).toEqual({ value: 90, scale: 1 }); // 90 seconds - expect(migrateSyncTimeoutFromMilliseconds(150000)).toEqual({ value: 150, scale: 1 }); // 150 seconds - }); + // Not divisible by 60 → display as seconds + expect(migrateSyncTimeoutFromMilliseconds(1000)).toEqual({ value: 1, scale: 1 }); + expect(migrateSyncTimeoutFromMilliseconds(45000)).toEqual({ value: 45, scale: 1 }); + expect(migrateSyncTimeoutFromMilliseconds(90000)).toEqual({ value: 90, scale: 1 }); - it("rounds milliseconds to nearest second", () => { + // Rounds to nearest second expect(migrateSyncTimeoutFromMilliseconds(120500)).toEqual({ value: 121, scale: 1 }); }); }); diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index bb6336b1f7..22c39fbe2a 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -66,15 +66,49 @@ async function initNotSyncedOptions(initialized: boolean, opts: NotSyncedOpts = optionService.createOption("textNoteEditorType", "ckeditor-classic", true); optionService.createOption("syncServerHost", opts.syncServerHost || "", false); - optionService.createOption("syncServerTimeout", "2", false); // 2 minutes (with default scale of 60) + optionService.createOption("syncServerTimeout", "120", false); // 120 seconds (2 minutes) optionService.createOption("syncProxy", opts.syncProxy || "", false); } +/** + * Migrates a sync timeout value from milliseconds to seconds. + * Values >= 1000 are assumed to be in milliseconds (since 1000+ seconds = 16+ minutes is unlikely). + * TimeSelector stores values in seconds; the scale is only used for display. + * + * @returns The value in seconds and preferred display scale, or null if no migration is needed. + */ +export function migrateSyncTimeoutFromMilliseconds(milliseconds: number): { value: number; scale: number } | null { + if (isNaN(milliseconds) || milliseconds < 1000) { + return null; + } + + const seconds = Math.round(milliseconds / 1000); + + // Value is always stored in seconds; scale determines display unit + if (seconds >= 60 && seconds % 60 === 0) { + return { value: seconds, scale: 60 }; // display as minutes + } + return { value: seconds, scale: 1 }; // display as seconds +} + /** * Contains all the default options that must be initialized on new and existing databases (at startup). The value can also be determined based on other options, provided they have already been initialized. */ const defaultOptions: DefaultOption[] = [ - { name: "syncServerTimeoutTimeScale", value: "60", isSynced: false }, // default to Minutes + { + name: "syncServerTimeoutTimeScale", + value: (optionsMap) => { + const timeout = parseInt(optionsMap.syncServerTimeout || "120", 10); + const migrated = migrateSyncTimeoutFromMilliseconds(timeout); + if (migrated) { + optionService.setOption("syncServerTimeout", String(migrated.value)); + log.info(`Migrated syncServerTimeout from ${timeout}ms to ${migrated.value}s`); + return String(migrated.scale); + } + return "60"; // default to minutes + }, + isSynced: false + }, { name: "revisionSnapshotTimeInterval", value: "600", isSynced: true }, { name: "revisionSnapshotTimeIntervalTimeScale", value: "60", isSynced: true }, // default to Minutes { name: "revisionSnapshotNumberLimit", value: "-1", isSynced: true }, @@ -256,36 +290,6 @@ function initStartupOptions() { ]) ); } - - // Migrate syncServerTimeout from milliseconds to seconds/minutes (for existing installations) - const syncTimeout = parseInt(optionsMap.syncServerTimeout, 10); - const migrated = migrateSyncTimeoutFromMilliseconds(syncTimeout); - if (migrated) { - optionService.setOption("syncServerTimeout", String(migrated.value)); - optionService.setOption("syncServerTimeoutTimeScale", String(migrated.scale)); - const unit = migrated.scale === 60 ? "minutes" : "seconds"; - log.info(`Migrated syncServerTimeout from ${syncTimeout}ms to ${migrated.value} ${unit}`); - } -} - -/** - * Migrates a sync timeout value from milliseconds to a value/scale pair. - * Values >= 1000 are assumed to be in milliseconds (since 1000+ seconds = 16+ minutes is unlikely). - * - * @returns The migrated value and scale, or null if no migration is needed. - */ -export function migrateSyncTimeoutFromMilliseconds(milliseconds: number): { value: number; scale: number } | null { - if (isNaN(milliseconds) || milliseconds < 1000) { - return null; - } - - const seconds = Math.round(milliseconds / 1000); - - // If divisible by 60, store as minutes; otherwise store as seconds - if (seconds >= 60 && seconds % 60 === 0) { - return { value: seconds / 60, scale: 60 }; - } - return { value: seconds, scale: 1 }; } function getKeyboardDefaultOptions() { diff --git a/apps/server/src/services/sync_options.spec.ts b/apps/server/src/services/sync_options.spec.ts index 7f24d19f93..16d048b683 100644 --- a/apps/server/src/services/sync_options.spec.ts +++ b/apps/server/src/services/sync_options.spec.ts @@ -10,7 +10,6 @@ import syncOptions from "./sync_options.js"; describe("syncOptions.getSyncTimeout", () => { beforeEach(() => { - // Reset config to empty (config as any).Sync = {}; }); @@ -18,57 +17,32 @@ describe("syncOptions.getSyncTimeout", () => { vi.clearAllMocks(); }); - it("uses database value × scale when no config override", () => { - vi.mocked(optionService.getOption).mockImplementation((name: string) => { - if (name === "syncServerTimeout") return "2"; - if (name === "syncServerTimeoutTimeScale") return "60"; - return ""; - }); + it("converts database value from seconds to milliseconds", () => { + // TimeSelector stores value in seconds (displayed value × scale) + // Scale is UI-only, not used in backend calculation + vi.mocked(optionService.getOption).mockReturnValue("120"); // 120 seconds = 2 minutes + expect(syncOptions.getSyncTimeout()).toBe(120000); - expect(syncOptions.getSyncTimeout()).toBe(120000); // 2 × 60 × 1000 = 2 minutes - }); - - it("supports different time scales from database", () => { - // 30 seconds - vi.mocked(optionService.getOption).mockImplementation((name: string) => { - if (name === "syncServerTimeout") return "30"; - if (name === "syncServerTimeoutTimeScale") return "1"; - return ""; - }); + vi.mocked(optionService.getOption).mockReturnValue("30"); // 30 seconds expect(syncOptions.getSyncTimeout()).toBe(30000); - // 1 hour - vi.mocked(optionService.getOption).mockImplementation((name: string) => { - if (name === "syncServerTimeout") return "1"; - if (name === "syncServerTimeoutTimeScale") return "3600"; - return ""; - }); + vi.mocked(optionService.getOption).mockReturnValue("3600"); // 3600 seconds = 1 hour expect(syncOptions.getSyncTimeout()).toBe(3600000); }); - it("treats config override as raw milliseconds (ignores db scale)", () => { - (config as any).Sync = { syncServerTimeout: "60000" }; + it("treats config override as raw milliseconds for backward compatibility", () => { + (config as any).Sync = { syncServerTimeout: "60000" }; // 60 seconds in ms - // Even if db has a different scale, config value is treated as raw ms - vi.mocked(optionService.getOption).mockImplementation((name: string) => { - if (name === "syncServerTimeout") return "5"; - if (name === "syncServerTimeoutTimeScale") return "3600"; // hours - return ""; - }); - - expect(syncOptions.getSyncTimeout()).toBe(60000); // 60 seconds, not 5 hours + // Config value takes precedence, db value is ignored + vi.mocked(optionService.getOption).mockReturnValue("9999"); + expect(syncOptions.getSyncTimeout()).toBe(60000); }); - it("uses safe defaults for invalid database values", () => { - vi.mocked(optionService.getOption).mockImplementation(() => ""); + it("uses safe defaults for invalid values", () => { + vi.mocked(optionService.getOption).mockReturnValue(""); + expect(syncOptions.getSyncTimeout()).toBe(120000); // default 120 seconds - // Defaults: value=2, scale=60 → 120000ms - expect(syncOptions.getSyncTimeout()).toBe(120000); - }); - - it("uses safe default for invalid config override", () => { (config as any).Sync = { syncServerTimeout: "invalid" }; - - expect(syncOptions.getSyncTimeout()).toBe(120000); // fallback to 120000 + expect(syncOptions.getSyncTimeout()).toBe(120000); // fallback for invalid config }); }); diff --git a/apps/server/src/services/sync_options.ts b/apps/server/src/services/sync_options.ts index f946bc52ba..1e98d105da 100644 --- a/apps/server/src/services/sync_options.ts +++ b/apps/server/src/services/sync_options.ts @@ -27,7 +27,7 @@ export default { // and we need to override it with config from config.ini return !!syncServerHost && syncServerHost !== "disabled"; }, - // Value is stored with a time scale, convert to milliseconds for use. + // Value is stored in seconds (TimeSelector saves displayed value × scale). // Config file overrides are treated as raw milliseconds for backward compatibility. getSyncTimeout: () => { const configValue = config["Sync"]?.syncServerTimeout; @@ -35,10 +35,9 @@ export default { // Config override: treat as raw milliseconds (backward compatible) return parseInt(configValue, 10) || 120000; } - // Database option: use value × scale (with safe defaults) - const value = parseInt(optionService.getOption("syncServerTimeout"), 10) || 2; - const scale = parseInt(optionService.getOption("syncServerTimeoutTimeScale"), 10) || 60; - return value * scale * 1000; + // Database option: stored in seconds, convert to milliseconds + const seconds = parseInt(optionService.getOption("syncServerTimeout"), 10) || 120; + return seconds * 1000; }, getSyncProxy: () => get("syncProxy") }; diff --git a/docs/User Guide/User Guide/Installation & Setup/Backup.md b/docs/User Guide/User Guide/Installation & Setup/Backup.md index 0204da0b2d..dca45fbff7 100644 --- a/docs/User Guide/User Guide/Installation & Setup/Backup.md +++ b/docs/User Guide/User Guide/Installation & Setup/Backup.md @@ -14,7 +14,7 @@ Note that Synchronization Backup > Existing backups > Download +You can download an existing backup by going to Settings > Backup > Existing backups > Download ## Restoring backup From adbe8f6c42a9ba3e3de6ea725577aac444c4ba0e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 11 Apr 2026 00:16:41 +0300 Subject: [PATCH 33/33] feat(options/sync): improve timeout layout --- apps/client/src/translations/en/translation.json | 1 + apps/client/src/widgets/type_widgets/options/sync.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index af8a606f1c..5eef6b9c69 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1519,6 +1519,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.", "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).", diff --git a/apps/client/src/widgets/type_widgets/options/sync.tsx b/apps/client/src/widgets/type_widgets/options/sync.tsx index 18137b344f..f583ba5496 100644 --- a/apps/client/src/widgets/type_widgets/options/sync.tsx +++ b/apps/client/src/widgets/type_widgets/options/sync.tsx @@ -11,6 +11,7 @@ import FormText from "../../react/FormText"; import FormTextBox from "../../react/FormTextBox"; import { useTriliumOptions } from "../../react/hooks"; import RawHtml from "../../react/RawHtml"; +import OptionsRow from "./components/OptionsRow"; import OptionsSection from "./components/OptionsSection"; import TimeSelector from "./components/TimeSelector"; @@ -62,14 +63,16 @@ export function SyncConfiguration() { - +
      + + -
      +
      ); }