mirror of
https://github.com/zadam/trilium.git
synced 2026-05-07 10:15:33 +02:00
Easy fixes v1 (#9370)
This commit is contained in:
19
.github/copilot-instructions.md
vendored
19
.github/copilot-instructions.md
vendored
@@ -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", () => {
|
||||
|
||||
18
CLAUDE.md
18
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()?
|
||||
//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(
|
||||
$("<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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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((
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
2
apps/edit-docs/demo/!!!meta.json
vendored
2
apps/edit-docs/demo/!!!meta.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 2,
|
||||
"appVersion": "0.100.0",
|
||||
"appVersion": "0.102.2",
|
||||
"files": [
|
||||
{
|
||||
"isClone": false,
|
||||
|
||||
26
apps/edit-docs/demo/root/Trilium Demo.html
vendored
26
apps/edit-docs/demo/root/Trilium Demo.html
vendored
@@ -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
|
||||
You can add links pointing to <a href="https://triliumnotes.org/">external pages</a> or
|
||||
<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>
|
||||
|
||||
@@ -14,24 +14,19 @@
|
||||
|
||||
<div class="ck-content">
|
||||
<h2>Main characters</h2>
|
||||
|
||||
<p>… here put main characters …</p>
|
||||
<p> </p>
|
||||
<h2>Plot</h2>
|
||||
|
||||
<h2>Plot</h2>
|
||||
<p>… describe main plot lines …</p>
|
||||
<p> </p>
|
||||
<h2>Tone</h2>
|
||||
|
||||
<h2>Tone</h2>
|
||||
<p> </p>
|
||||
<h2>Genre</h2>
|
||||
|
||||
<h2>Genre</h2>
|
||||
<p>scifi / drama / romance</p>
|
||||
<p> </p>
|
||||
<h2>Similar books</h2>
|
||||
|
||||
<h2>Similar books</h2>
|
||||
<ul>
|
||||
<li>…</li>
|
||||
<li data-list-item-id="eebd9f297d5dc97dfc46579ba1f25d7bf">…</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
977
apps/edit-docs/demo/style.css
vendored
977
apps/edit-docs/demo/style.css
vendored
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
|
||||
@@ -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.
@@ -18,6 +18,9 @@
|
||||
<p>Note that <a class="reference-link" href="#root/_help_cbkrhQjrkKrh">Synchronization</a> 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 > Backup >
|
||||
Existing backups > 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}}\""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"codeNoteTheme",
|
||||
"syncServerHost",
|
||||
"syncServerTimeout",
|
||||
"syncServerTimeoutTimeScale",
|
||||
"syncProxy",
|
||||
"hoistedNoteId",
|
||||
"mainFontSize",
|
||||
|
||||
27
apps/server/src/services/options_init.spec.ts
Normal file
27
apps/server/src/services/options_init.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
48
apps/server/src/services/sync_options.spec.ts
Normal file
48
apps/server/src/services/sync_options.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
7
docs/User Guide/!!!meta.json
vendored
7
docs/User Guide/!!!meta.json
vendored
@@ -16459,6 +16459,13 @@
|
||||
"value": "word-count",
|
||||
"isInheritable": false,
|
||||
"position": 40
|
||||
},
|
||||
{
|
||||
"type": "relation",
|
||||
"name": "internalLink",
|
||||
"value": "hrZ1D00cLbal",
|
||||
"isInheritable": false,
|
||||
"position": 50
|
||||
}
|
||||
],
|
||||
"format": "markdown",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -23,6 +23,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
|
||||
theme: string;
|
||||
syncServerHost: string;
|
||||
syncServerTimeout: string;
|
||||
syncServerTimeoutTimeScale: number;
|
||||
syncProxy: string;
|
||||
mainFontFamily: FontFamily;
|
||||
treeFontFamily: FontFamily;
|
||||
|
||||
Reference in New Issue
Block a user