Easy fixes v1 (#9370)

This commit is contained in:
Elian Doran
2026-04-11 00:29:15 +03:00
committed by GitHub
44 changed files with 1075 additions and 696 deletions

View File

@@ -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,8 +333,16 @@ Trilium provides powerful user scripting capabilities:
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
- When adding a new locale, follow the step-by-step guide in `docs/Developer Guide/Developer Guide/Concepts/Internationalisation Translations/Adding a new locale.md`
#### Client vs Server Translation Usage
- **Client-side**: `import { t } from "../services/i18n"` with keys in `apps/client/src/translations/en/translation.json`
- **Server-side**: `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`
- **Interpolation**: Use `{{variable}}` for normal interpolation; use `{{- variable}}` (with hyphen) for **unescaped** interpolation when the value contains special characters like quotes that shouldn't be HTML-escaped
## Testing Conventions
- **Write concise tests**: Group related assertions together in a single test case rather than creating many one-shot tests
- **Extract and test business logic**: When adding pure business logic (e.g., data transformations, migrations, validations), extract it as a separate function and always write unit tests for it
```typescript
// ETAPI test pattern
describe("etapi/feature", () => {

View File

@@ -2,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.
@@ -66,6 +68,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/`)
@@ -108,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:
@@ -124,6 +137,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

View File

@@ -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;
}

View File

@@ -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
};
})
);

View File

@@ -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;

View File

@@ -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...",
@@ -860,7 +864,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",
@@ -1514,7 +1519,7 @@
"config_title": "Sync Configuration",
"server_address": "Server instance address",
"timeout": "Sync timeout",
"timeout_unit": "milliseconds",
"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).",

View File

@@ -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;
}

View File

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

View File

@@ -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", () => {

View File

@@ -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 = /<span class="math-tex">\\\(([\s\S]*?)\\\)<\/span>/g;
const matches = [...html.matchAll(mathTextRegex)];
let modifiedText = html;
if (matches.length > 0) {
// Process all matches asynchronously
for (const match of matches) {
const latexCode = match[1];
let rendered;
try {
rendered = katex.renderToString(latexCode, {
throwOnError: false
});
} catch (e) {
if (e instanceof ReferenceError && e.message.includes("katex is not defined")) {
// Load KaTeX if it is not already loaded
try {
rendered = katex.renderToString(latexCode, {
throwOnError: false
});
} catch (renderError) {
console.error("KaTeX rendering error after loading library:", renderError);
rendered = match[0]; // Fall back to original if error persists
}
} else {
console.error("KaTeX rendering error:", e);
rendered = match[0]; // Fall back to original on error
}
}
// Replace the matched formula in the modified text
modifiedText = modifiedText.replace(match[0], rendered);
}
}
return modifiedText;
}
async getHighlightList(content: string, optionsHighlightsList: string[]) {
// matches a span containing background-color
const regex1 = /<span[^>]*style\s*=\s*[^>]*background-color:[^>]*?>[\s\S]*?<\/span>/gi;
@@ -239,9 +167,6 @@ export default class HighlightsListWidget extends RightPanelWidget {
const $highlightsList = $("<ol>");
let prevEndIndex = -1,
hlLiCount = 0;
let prevSubHtml: string | null = null;
// Used to determine if a string is only a formula
const onlyMathRegex = /^<span class="math-tex">\\\([^\)]*?\)<\/span>(?:<span class="math-tex">\\\([^\)]*?\)<\/span>)*$/;
for (let match: RegExpMatchArray | null = null, hltIndex = 0; (match = combinedRegex.exec(content)) !== null; hltIndex++) {
const subHtml = match[0];
@@ -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()?
//Cant remember why regular expressions are used here, but modified to $(subHtml).text() works as expected
//const hasText = [...subHtml.matchAll(/(?<=^|>)[^><]+?(?=<|$)/g)].map(matchTmp => matchTmp[0]).join('').trim();
const hasText = $(subHtml).text().trim();
if (hasText) {
const substring = content.substring(prevEndIndex, startIndex);
//If the two elements have the same style and there are only formulas in between, append the formulas and the current element to the end of the previous element.
if (this.areOuterTagsConsistent(prevSubHtml, subHtml) && onlyMathRegex.test(substring)) {
const $lastLi = $highlightsList.children("li").last();
$lastLi.append(await this.replaceMathTextWithKatax(substring));
$lastLi.append(subHtml);
} else {
$highlightsList.append(
$("<li>")
.html(subHtml)
.on("click", () => this.jumpToHighlightsList(findSubStr, hltIndex))
);
}
$highlightsList.append(
$("<li>")
.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,

View File

@@ -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<ViewTypeOptions, string> = {
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 ?? "") &&
<div className="collection-properties">
@@ -43,11 +49,59 @@ export default function CollectionProperties({ note, centerChildren, rightChildr
</div>
<div className="right-container">
{rightChildren}
{noteType === "search" && (
<OpenAllButton note={note} isOpening={isOpening} setIsOpening={setIsOpening} />
)}
</div>
</div>
);
}
function OpenAllButton({ note, isOpening, setIsOpening }: {
note: FNote;
isOpening: boolean;
setIsOpening: (value: boolean) => void;
}) {
const noteIds = note.getChildNoteIds();
const count = noteIds.length;
const handleOpenAll = async () => {
if (count === 0) return;
if (count > MAX_OPEN_TABS) {
await dialogService.info(t("book_properties.open_all_too_many", { count, max: MAX_OPEN_TABS }));
return;
}
if (count > 10) {
const confirmed = await dialogService.confirm(t("book_properties.open_all_confirm", { count }));
if (!confirmed) return;
}
setIsOpening(true);
try {
for (let i = 0; i < noteIds.length; i++) {
const noteId = noteIds[i];
const isLast = i === noteIds.length - 1;
await appContext.tabManager.openTabWithNoteWithHoisting(noteId, {
activate: isLast
});
}
} finally {
setIsOpening(false);
}
};
return (
<ActionButton
icon={isOpening ? "bx bx-loader-alt bx-spin" : "bx bx-window-open"}
text={t("book_properties.open_all_in_tabs_tooltip")}
onClick={handleOpenAll}
disabled={count === 0 || isOpening}
/>
);
}
function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) {
// Keyboard shortcut
const dropdownContainerRef = useRef<HTMLDivElement>(null);

View File

@@ -42,8 +42,11 @@ export default function NoteIcon() {
setIcon(note?.getIcon());
}, [ note, iconClass, workspaceIconClass ]);
const isDisabled = viewScope?.viewMode !== "default"
|| note?.isMetadataReadOnly;
if (isMobile()) {
return <MobileNoteIconSwitcher note={note} icon={icon} />;
return <MobileNoteIconSwitcher note={note} icon={icon} disabled={isDisabled} />;
}
return (
@@ -55,16 +58,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 && <NoteIconList note={note} onHide={() => dropdownRef?.current?.hide()} columnCount={12} /> }
</Dropdown>
);
}
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 +80,7 @@ function MobileNoteIconSwitcher({ note, icon }: {
icon={icon ?? "bx bx-empty"}
text={t("note_icon.change_note_icon")}
onClick={() => setModalShown(true)}
disabled={disabled}
/>
{createPortal((

View File

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

View File

@@ -12,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<number | null>(null);
const notesAndRelationsRef = useRef<NotesAndRelationsData>();
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 (
<NoItems icon="bx bx-error-circle" text={t("note_map.too-many-notes", { count: tooManyNotes, max: MAX_NOTES_THRESHOLD })} />
);
}
return (
<div className="note-map-widget">
<div className="btn-group btn-group-sm map-type-switcher content-floating-buttons top-left" role="group">

View File

@@ -1,15 +1,16 @@
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 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();
@@ -25,8 +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.isMetadataReadOnly
|| viewScope?.viewMode !== "default";
setReadOnly(isReadOnly);
}, [ note, note?.noteId, note?.isProtected, viewScope?.viewMode ]);
@@ -58,11 +58,29 @@ export default function NoteTitleWidget(props: {className?: string}) {
// Manage focus.
const textBoxRef = useRef<HTMLInputElement>(null);
const isNewNote = useRef<boolean>();
const pendingSelect = useRef<boolean>(false);
// Re-apply selection when title changes if we have a pending select.
// This handles the case where the server sends back entity changes after we've
// already called select(), which causes the controlled input to re-render and lose selection.
useEffect(() => {
if (pendingSelect.current && textBoxRef.current && document.activeElement === textBoxRef.current) {
textBoxRef.current.select();
pendingSelect.current = false;
}
}, [title]);
useTriliumEvents([ "focusOnTitle", "focusAndSelectTitle" ], (e, eventName) => {
if (noteContext?.isActive() && textBoxRef.current) {
// In the new layout, there are two NoteTitleWidget instances. Only handle if visible.
if (!textBoxRef.current.checkVisibility({ checkOpacity: true })) {
return;
}
textBoxRef.current.focus();
if (eventName === "focusAndSelectTitle") {
textBoxRef.current.select();
pendingSelect.current = true;
}
isNewNote.current = ("isNewNote" in e ? e.isNewNote : false);
}
@@ -83,6 +101,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 +122,7 @@ export default function NoteTitleWidget(props: {className?: string}) {
}
}}
onBlur={() => {
pendingSelect.current = false;
spacedUpdate.updateNowIfNecessary();
isNewNote.current = false;
}}

View File

@@ -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<HTMLElement>, config: Partial<Tooltip.Options>) {
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<Element>, config?: Partial<Too
const hasTooltip = config?.title || elRef.current?.getAttribute("title");
if (!elRef?.current || !hasTooltip) return;
const tooltip = Tooltip.getOrCreateInstance(elRef.current, config);
elRef.current.addEventListener("show.bs.tooltip", () => {
// 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<Element>, config?: Partial<Too
return () => {
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());

View File

@@ -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<T extends RawHighlight>({ highlights, scrollToHi
{filteredHighlights.length > 0 ? (
<ol>
{filteredHighlights.map(highlight => (
<li
<HighlightItem
key={highlight.id}
highlight={highlight}
onClick={() => scrollToHighlight(highlight)}
>
<span
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
>{highlight.text}</span>
</li>
/>
))}
</ol>
) : (
@@ -112,6 +105,43 @@ function AbstractHighlightsList<T extends RawHighlight>({ highlights, scrollToHi
);
}
function HighlightItem<T extends RawHighlight>({ highlight, onClick }: {
highlight: T;
onClick(): void;
}) {
const contentRef = useRef<HTMLElement>(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 (
<li onClick={onClick}>
<RawHtml
containerRef={contentRef}
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
html={highlight.text}
/>
</li>
);
}
//#region Editable text (CKEditor)
interface CKHighlight extends RawHighlight {
textNode: ModelText;
@@ -201,9 +231,24 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) {
};
if (Object.values(attrs).some(Boolean)) {
// Get HTML content from DOM (includes nested elements like math)
let html = item.data;
try {
const modelPos = editor.model.createPositionAt(item.textNode, "before");
const viewPos = editor.editing.mapper.toViewPosition(modelPos);
const domPos = editor.editing.view.domConverter.viewPositionToDom(viewPos);
if (domPos?.parent instanceof HTMLElement) {
// Get the formatting span's innerHTML (includes math elements)
html = domPos.parent.innerHTML;
}
} catch {
// During change:data events, the view may not be fully synchronized with the model.
// Fall back to using the raw text data.
}
result.push({
id: randomString(),
text: item.data,
text: html,
attrs,
textNode: item.textNode,
offset: item.startOffset

View File

@@ -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 {

View File

@@ -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 (
<OptionsSection title={t("electron_integration.desktop-application")}>
<FormGroup name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBox
<OptionsRow name="zoom-factor" label={t("electron_integration.zoom-factor")} description={t("zoom_factor.description")}>
<FormTextBoxWithUnit
type="number"
min="0.3" max="2.0" step="0.1"
currentValue={zoomFactor} onChange={setZoomFactor}
min={50} max={200} step={10}
currentValue={String(zoomPercentage)}
onChange={(v) => zoomService.setZoomFactorAndSave(parseInt(v, 10) / 100)}
unit={t("units.percentage")}
/>
</FormGroup>
<hr/>
</OptionsRow>
<FormGroup name="native-title-bar" description={t("electron_integration.native-title-bar-description")}>
<FormCheckbox

View File

@@ -1,16 +1,19 @@
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 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 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";
export default function SyncOptions() {
return (
@@ -18,13 +21,12 @@ export default function SyncOptions() {
<SyncConfiguration />
<SyncTest />
</>
)
);
}
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 +34,12 @@ export function SyncConfiguration() {
<form onSubmit={(e) => {
setOptions({
syncServerHost: syncServerHost.current,
syncServerTimeout: syncServerTimeout.current,
syncProxy: syncProxy.current
});
e.preventDefault();
}}>
<FormGroup name="sync-server-host" label={t("sync_2.server_address")}>
<FormTextBox
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncServerHost.current} onChange={(newValue) => syncServerHost.current = newValue}
/>
@@ -50,27 +51,30 @@ export function SyncConfiguration() {
<RawHtml html={t("sync_2.special_value_description")} />
</>}
>
<FormTextBox
<FormTextBox
placeholder="https://<host>:<port>"
currentValue={syncProxy.current} onChange={(newValue) => syncProxy.current = newValue}
/>
</FormGroup>
<FormGroup name="sync-server-timeout" label={t("sync_2.timeout")}>
<FormTextBoxWithUnit
min={1} max={10000000} type="number"
unit={t("sync_2.timeout_unit")}
currentValue={syncServerTimeout.current} onChange={(newValue) => syncServerTimeout.current = newValue}
/>
</FormGroup>
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
<Button text={t("sync_2.save")} kind="primary" />
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
</div>
</form>
<hr/>
<OptionsRow name="sync-server-timeout" label={t("sync_2.timeout")} description={t("sync_2.timeout_description")}>
<TimeSelector
name="sync-server-timeout"
optionValueId="syncServerTimeout"
optionTimeScaleId="syncServerTimeoutTimeScale"
minimumSeconds={1}
/>
</OptionsRow>
</OptionsSection>
)
);
}
export function SyncTest() {
@@ -90,5 +94,5 @@ export function SyncTest() {
}}
/>
</OptionsSection>
)
}
);
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"formatVersion": 2,
"appVersion": "0.100.0",
"appVersion": "0.102.2",
"files": [
{
"isClone": false,

View File

@@ -18,30 +18,23 @@
width="150" height="150">
</figure>
<p><strong>Welcome to Trilium Notes!</strong>
</p>
<p>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.</p>
<p>If you need any help, visit <a href="https://triliumnotes.org">triliumnotes.org</a> or
our <a href="https://github.com/TriliumNext">GitHub repository</a>
</p>
<h2>Cleanup</h2>
our <a href="https://github.com/TriliumNext">GitHub repository</a>.</p>
<h2>Cleanup</h2>
<p>Once you're finished with experimenting and want to cleanup these pages,
you can simply delete them all.</p>
<h2>Formatting</h2>
<h2>Formatting</h2>
<p>Trilium supports classic formatting like <em>italic</em>, <strong>bold</strong>, <em><strong>bold and italic</strong></em>.
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or&nbsp;&nbsp;
<a
class="reference-link" href="Trilium%20Demo/Formatting%20examples">Formatting examples</a>.</p>
<h3>Lists</h3>
<h3>Lists</h3>
<p><strong>Ordered:</strong>
</p>
<ol>
<li data-list-item-id="e877cc655d0239b8bb0f38696ad5d8abb">First Item</li>
@@ -56,7 +49,6 @@
</li>
</ol>
<p><strong>Unordered:</strong>
</p>
<ul>
<li data-list-item-id="e68bf4b518a16671c314a72073c3d900a">Item</li>
@@ -66,8 +58,7 @@
</ul>
</li>
</ul>
<h3>Block quotes</h3>
<h3>Block quotes</h3>
<blockquote>
<p>Whereof one cannot speak, thereof one must be silent”</p>
<p> Ludwig Wittgenstein</p>
@@ -75,9 +66,8 @@
<hr>
<p>See also other examples like <a href="Trilium%20Demo/Formatting%20examples/School%20schedule.html">tables</a>,
<a
href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists,</a> <a href="Trilium%20Demo/Formatting%20examples/Highlighting.html">highlighting</a>, <a href="Trilium%20Demo/Formatting%20examples/Code%20blocks.html">code blocks</a>and
<a
href="Trilium%20Demo/Formatting%20examples/Math.html">math examples</a>.</p>
href="Trilium%20Demo/Formatting%20examples/Checkbox%20lists.html">checkbox lists</a>, <a href="Trilium%20Demo/Formatting%20examples/Highlighting.html">highlighting</a>, <a href="Trilium%20Demo/Formatting%20examples/Code%20blocks.html">code blocks</a>,
and <a href="Trilium%20Demo/Formatting%20examples/Math.html">math examples</a>.</p>
</div>
</div>
</body>

View File

@@ -14,24 +14,19 @@
<div class="ck-content">
<h2>Main characters</h2>
<p>… here put main characters …</p>
<p>&nbsp;</p>
<h2>Plot</h2>
<h2>Plot</h2>
<p>… describe main plot lines …</p>
<p>&nbsp;</p>
<h2>Tone</h2>
<h2>Tone</h2>
<p>&nbsp;</p>
<h2>Genre</h2>
<h2>Genre</h2>
<p>scifi / drama / romance</p>
<p>&nbsp;</p>
<h2>Similar books</h2>
<h2>Similar books</h2>
<ul>
<li></li>
<li data-list-item-id="eebd9f297d5dc97dfc46579ba1f25d7bf"></li>
</ul>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -17,20 +17,29 @@ async function main() {
await initializeTranslations();
await initializeDatabase(true);
// Wait for becca to be loaded before importing data
const beccaLoader = await import("@triliumnext/server/src/becca/becca_loader.js");
await beccaLoader.beccaLoaded;
cls.init(async () => {
await importData(DEMO_ZIP_DIR_PATH);
setOptions();
initializedPromise.resolve();
});
initializedPromise.resolve();
}
async function setOptions() {
const optionsService = (await import("@triliumnext/server/src/services/options.js")).default;
const sql = (await import("@triliumnext/server/src/services/sql.js")).default;
optionsService.setOption("eraseUnusedAttachmentsAfterSeconds", 10);
optionsService.setOption("eraseUnusedAttachmentsAfterTimeScale", 60);
optionsService.setOption("compressImages", "false");
// Set initial note to the first visible child of root (not _hidden)
const startNoteId = sql.getValue("SELECT noteId FROM branches WHERE parentNoteId = 'root' AND isDeleted = 0 AND noteId != '_hidden' ORDER BY notePosition") || "root";
optionsService.setOption("openNoteContexts", JSON.stringify([{ notePath: startNoteId, active: true }]));
}
async function registerHandlers() {

View File

@@ -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<string>) {

Binary file not shown.

View File

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

View File

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

View File

@@ -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}}\""
}
}
}

View File

@@ -32,6 +32,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"codeNoteTheme",
"syncServerHost",
"syncServerTimeout",
"syncServerTimeoutTimeScale",
"syncProxy",
"hoistedNoteId",
"mainFontSize",

View File

@@ -0,0 +1,27 @@
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)", () => {
expect(migrateSyncTimeoutFromMilliseconds(120)).toBeNull();
expect(migrateSyncTimeoutFromMilliseconds(500)).toBeNull();
expect(migrateSyncTimeoutFromMilliseconds(999)).toBeNull();
expect(migrateSyncTimeoutFromMilliseconds(NaN)).toBeNull();
});
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
// 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 });
// Rounds to nearest second
expect(migrateSyncTimeoutFromMilliseconds(120500)).toEqual({ value: 121, scale: 1 });
});
});

View File

@@ -66,14 +66,49 @@ 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", "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: (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 },

View File

@@ -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", () => {

View File

@@ -1,6 +1,5 @@
import { dayjs } from "@triliumnext/commons";
import { t } from "i18next";
import { removeDiacritic } from "../../utils.js";
import AncestorExp from "../expressions/ancestor.js";
@@ -98,7 +97,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"
? 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;
}
@@ -436,9 +438,15 @@ 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 {
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(t("search.error.fulltext-after-expression", { token }));
} else {
searchContext.addError(t("search.error.unrecognized-expression", { token }));
}
}
if (!op && expressions.length > 1) {

View File

@@ -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",

View File

@@ -0,0 +1,48 @@
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(() => {
(config as any).Sync = {};
});
afterEach(() => {
vi.clearAllMocks();
});
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);
vi.mocked(optionService.getOption).mockReturnValue("30"); // 30 seconds
expect(syncOptions.getSyncTimeout()).toBe(30000);
vi.mocked(optionService.getOption).mockReturnValue("3600"); // 3600 seconds = 1 hour
expect(syncOptions.getSyncTimeout()).toBe(3600000);
});
it("treats config override as raw milliseconds for backward compatibility", () => {
(config as any).Sync = { syncServerTimeout: "60000" }; // 60 seconds in ms
// 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 values", () => {
vi.mocked(optionService.getOption).mockReturnValue("");
expect(syncOptions.getSyncTimeout()).toBe(120000); // default 120 seconds
(config as any).Sync = { syncServerTimeout: "invalid" };
expect(syncOptions.getSyncTimeout()).toBe(120000); // fallback for invalid config
});
});

View File

@@ -1,7 +1,5 @@
"use strict";
import optionService from "./options.js";
import config from "./config.js";
import optionService from "./options.js";
import { normalizeUrl } from "./utils.js";
/*
@@ -29,6 +27,17 @@ 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 in seconds (TimeSelector saves displayed value × scale).
// Config file overrides are treated as raw milliseconds for backward compatibility.
getSyncTimeout: () => {
const configValue = config["Sync"]?.syncServerTimeout;
if (configValue) {
// Config override: treat as raw milliseconds (backward compatible)
return parseInt(configValue, 10) || 120000;
}
// Database option: stored in seconds, convert to milliseconds
const seconds = parseInt(optionService.getOption("syncServerTimeout"), 10) || 120;
return seconds * 1000;
},
getSyncProxy: () => get("syncProxy")
};

View File

@@ -1,5 +1,5 @@
# Documentation
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/P8lHe64WV7LD/Documentation_image.png" width="205" height="162">
There are multiple types of documentation for Trilium:<img class="image-style-align-right" src="api/images/sXF9YQCkQPQv/Documentation_image.png" width="205" height="162">
* The _User Guide_ represents the user-facing documentation. This documentation can be browsed by users directly from within Trilium, by pressing <kbd>F1</kbd>.
* The _Developer's Guide_ represents a set of Markdown documents that present the internals of Trilium, for developers.

View File

@@ -16459,6 +16459,13 @@
"value": "word-count",
"isInheritable": false,
"position": 40
},
{
"type": "relation",
"name": "internalLink",
"value": "hrZ1D00cLbal",
"isInheritable": false,
"position": 50
}
],
"format": "markdown",

View File

@@ -14,7 +14,7 @@ Note that <a class="reference-link" href="Synchronization.md">Synchronization</
## Downloading backup
You can download a existing backup by going to Settings > Backup > Existing backups > Download
You can download an existing backup by going to Settings > Backup > Existing backups > Download
## Restoring backup

View File

@@ -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:
<figure class="image"><img style="aspect-ratio:792/603;" src="Word count widget_image.png" width="792" height="603"></figure>

View File

@@ -23,6 +23,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
theme: string;
syncServerHost: string;
syncServerTimeout: string;
syncServerTimeoutTimeScale: number;
syncProxy: string;
mainFontFamily: FontFamily;
treeFontFamily: FontFamily;