Back to Trilium

Trilium Frontend Scripting

apps/server/src/assets/llm/skills/frontend_scripting.md

0.104.118.1 KB
Original Source

Trilium Frontend Scripting

Frontend scripts run in the browser. They can manipulate the UI, navigate notes, show dialogs, and create custom widgets.

IMPORTANT: Always prefer Preact JSX widgets over legacy jQuery widgets. Use JSX code notes with import/export syntax.

CRITICAL: In JSX notes, always use top-level import statements (e.g. import { useState } from "trilium:preact"). NEVER use dynamic await import() for Preact imports — this will break hooks and components. Dynamic imports are not needed because JSX notes natively support ES module import/export syntax.

Creating a frontend script

  1. Create a Code note with language "JSX" (preferred) or "JavaScript (Trilium frontend)" (legacy only).
  2. Add #widget label for widgets, or #run=frontendStartup for auto-run scripts.
  3. For mobile, use #run=mobileStartup instead.

NOTE: #widget, #run and ~renderNote enable code execution, so set_attribute refuses them as dangerous. Create the code note yourself, then ask the user to add the activating attribute, telling them exactly which note to open and what to type into its attribute area.

Script types

TypeLanguageRequired attribute
Custom widgetJSX (preferred)#widget
Regular scriptJavaScript (Trilium frontend)#run=frontendStartup (optional)
Render noteJSXNone (used via ~renderNote relation)

Custom widgets (Preact JSX) — preferred

Basic widget

jsx
import { defineWidget } from "trilium:preact";
import { useState } from "trilium:preact";

export default defineWidget({
    parent: "center-pane",
    position: 10,
    render: () => {
        const [count, setCount] = useState(0);
        return (
            <div>
                <button onClick={() => setCount(c => c + 1)}>
                    Clicked {count} times
                </button>
            </div>
        );
    }
});

Note context aware widget (reacts to active note)

jsx
import { defineWidget, useNoteContext, useNoteProperty } from "trilium:preact";

export default defineWidget({
    parent: "note-detail-pane",
    position: 10,
    render: () => {
        const { note } = useNoteContext();
        const title = useNoteProperty(note, "title");
        return <span>Current note: {title}</span>;
    }
});

Right panel widget (sidebar)

jsx
import { defineWidget, RightPanelWidget, useState, useEffect } from "trilium:preact";

export default defineWidget({
    parent: "right-pane",
    position: 1,
    render() {
        const [time, setTime] = useState();
        useEffect(() => {
            const interval = setInterval(() => {
                setTime(new Date().toLocaleString());
            }, 1000);
            return () => clearInterval(interval);
        });
        return (
            <RightPanelWidget id="my-clock" title="Clock">
                <p>The time is: {time}</p>
            </RightPanelWidget>
        );
    }
});

Widget locations (parent values)

ValueDescriptionNotes
left-paneAlongside the note tree
center-paneContent area, spanning all splits
note-detail-paneInside a note, split-awareUse useNoteContext() hook
right-paneRight sidebar sectionWrap in <RightPanelWidget>

Preact imports

jsx
// API methods
import { showMessage, showError, getNote, searchForNotes, activateNote,
         runOnBackend, runAsyncOnBackendWithManualTransactionHandling,
         getActiveContextNote } from "trilium:api";

// Hooks and components
import { defineWidget, defineLauncherWidget,
         useState, useEffect, useCallback, useMemo, useRef,
         useNoteContext, useActiveNoteContext, useNoteProperty,
         RightPanelWidget } from "trilium:preact";

// Built-in UI components
import { ActionButton, Button, LinkButton, Modal,
         NoteAutocomplete, FormTextBox, FormToggle, FormCheckbox,
         FormDropdownList, FormGroup, FormText, FormTextArea,
         Icon, LoadingSpinner, Slider, Collapsible } from "trilium:preact";

Custom hooks

  • useNoteContext() - returns { note } for the current note context (use in note-detail-pane)
  • useActiveNoteContext() - returns { note, noteId } for the active note (works from any widget location)
  • useNoteProperty(note, propName) - reactively watches a note property (e.g. "title", "type")

Render notes (JSX)

For rendering custom content inside a note:

  1. Create a "render note" (type: Render Note) where you want the content to appear.
  2. Create a JSX code note as a child of the render note, exporting a default component.
  3. Ask the user to add a ~renderNote relation on the render note pointing to the child JSX note (you cannot set this dangerous attribute yourself).

IMPORTANT: Always create the JSX code note as a child of the render note, not as a sibling or at the root. This keeps them organized together.

IMPORTANT: To reference "this widget's note" from inside a render note component, use originEntity — for render notes it is the note being rendered (the one carrying the ~renderNote relation). Do NOT use getActiveContextNote() for this: it returns the note the user currently has open in the UI, which is the render note's host (e.g. a dashboard or a book view), not the render note itself.

jsx
import { originEntity } from "trilium:api";
// originEntity = the render note hosting this component

Stateless example:

jsx
export default function MyRenderNote() {
    return (
        <>
            <h1>Custom rendered content</h1>
            <p>This appears inside the note.</p>
        </>
    );
}

Stateful example — hooks MUST be imported from "trilium:preact":

jsx
import { useState } from "trilium:preact";

export default function CelsiusToFahrenheit() {
    const [celsius, setCelsius] = useState("");
    const fahrenheit = celsius === "" ? "" : (Number(celsius) * 9 / 5 + 32).toFixed(2);
    return (
        <div>
            <input
                type="number"
                value={celsius}
                onInput={e => setCelsius(e.currentTarget.value)}
            />
            <span>{fahrenheit} °F</span>
        </div>
    );
}

Anti-patterns (do NOT do this)

LLMs often invent syntax that does not exist in Trilium. Avoid these:

  • trilium.preact.useState(...)trilium is not a global object; there is no trilium.preact namespace
  • window.trilium.preact.useState(...) — same; no such global
  • React.useState(...) / import React from "react" — Trilium uses Preact, NOT React
  • const { useState } = await import("trilium:preact") — dynamic imports break hooks; always use top-level import
  • const { useState } = require("trilium:preact") — JSX notes are ES modules, not CommonJS

The ONLY correct way to use hooks or components is a top-level ES import:

jsx
import { useState, useEffect } from "trilium:preact";
import { showMessage } from "trilium:api";

Script API

In JSX, use import { method } from "trilium:api". In JavaScript (Trilium frontend), use the api global.

Script context

  • startNote - note where the script execution started (the entry point of the script bundle). All module notes loaded by the same execution share one startNote; log() messages are grouped under it.
  • currentNote - note containing the source code currently executing. Equal to startNote except inside child module notes. NOT the note open in the UI — that is getActiveContextNote().
  • originEntity - note whose event triggered this execution, or null. Usually null (scripts started by the user or the UI). It is set for render notes — the note being rendered, i.e. the one carrying the ~renderNote relation — and for api.runOnFrontend() calls from the backend (the backend's originEntity, if it was a note).

Concrete examples:

ScenariostartNotecurrentNoteoriginEntity
Widget script "Clock" (#widget)"Clock""Clock" (a child module note while its code runs)null
Startup script (#run=frontendStartup) or manual Execute on "Setup""Setup""Setup" (or module note)null
Render note "Stats" with ~renderNote → JSX note "StatsComponent""StatsComponent""StatsComponent" (or module note)"Stats" (the render note)
api.runOnFrontend() called from a backend scriptthe backend execution's currentNotesamethe backend's originEntity if it was a note, else null
  • activateNote(notePath) - navigate to a note
  • activateNewNote(notePath) - navigate and wait for sync
  • openTabWithNote(notePath, activate?) - open in new tab
  • openSplitWithNote(notePath, activate?) - open in new split
  • getActiveContextNote() - get currently active note
  • getActiveContextNotePath() - get path of active note
  • setHoistedNoteId(noteId) - hoist/unhoist note
  • getNote(noteId) - get note by ID
  • getNotes(noteIds) - bulk fetch notes
  • searchForNotes(searchString) - search with full query syntax
  • searchForNote(searchString) - search returning first result

Note creation

  • createNote(parentNotePath, opts?) - create a note under parentNotePath (a noteId or path) entirely on the frontend — no backend scripting required. opts accepts { title, content, type, mime, templateNoteId, isProtected, activate, focus, target, attributes } and returns { note, branch } from the cache. The new note is activated with its title focused by default; pass { activate: false } to create it silently. Prefer this over runOnBackend(() => api.createTextNote(...)) — the backend variant needs backend scripting, which is off by default.
jsx
const { note } = await createNote(parentNoteId, { title: "New task", type: "text" });

Calendar/date notes

  • getTodayNote() - get/create today's note
  • getDayNote(date) / getWeekNote(date) / getMonthNote(month) / getYearNote(year)

Editor access

  • getActiveContextTextEditor() - get CKEditor instance
  • getActiveContextCodeEditor() - get CodeMirror instance
  • addTextToActiveContextEditor(text) - insert text into active editor

Dialogs & notifications

  • showMessage(msg) - info toast
  • showError(msg) - error toast
  • showConfirmDialog(msg) - confirm dialog (returns boolean)
  • showPromptDialog(msg) - prompt dialog (returns user input)

Backend integration

Backend script execution is disabled by default (a security setting, since backend scripts have full server access). When it is off, runOnBackend and runAsyncOnBackendWithManualTransactionHandling reject with "Backend script execution is disabled". Prefer doing the work on the frontend whenever possible (most api.* note/search/date operations are available on the frontend too). When the backend is genuinely required, guard the call with the check below and tell the user to enable it in Options → Security.

  • isBackendScriptingEnabled() - returns whether backend script execution is enabled (the [Security] backendScriptingEnabled config toggle). Check this before calling runOnBackend / runAsyncOnBackendWithManualTransactionHandling so the script can degrade gracefully instead of throwing.
  • isSqlConsoleEnabled() - returns whether the SQL console is enabled (the [Security] sqlConsoleEnabled config toggle). Check this before running raw SQL on the backend (api.sql.*).
  • runOnBackend(func, params) - execute a function on the backend. The function MUST be synchronous — do NOT pass an async function. runOnBackend wraps the call in a SQL transaction, which does not support async; passing an async function triggers the warning "You're passing an async function to api.runOnBackend() which will likely not work as you intended" and the transaction will not behave correctly.
  • runAsyncOnBackendWithManualTransactionHandling(func, params) - use this instead when the backend function genuinely needs to be async (e.g. it awaits a fetch()). Automatic transaction management is disabled, so wrap any DB writes in api.transactional(...) yourself inside the function.
jsx
// Guard backend work so a disabled instance shows a friendly message instead of an error toast
import { isBackendScriptingEnabled, runOnBackend, showError } from "trilium:api";

if (!isBackendScriptingEnabled()) {
    showError("This feature needs backend scripting — enable it in Options → Security.");
    return;
}
const title = await runOnBackend((noteId) => api.getNote(noteId).title, [noteId]);
jsx
// ✅ Synchronous backend work — use runOnBackend
const title = await runOnBackend((noteId) => {
    return api.getNote(noteId).title;
}, [noteId]);

// ✅ Async backend work (e.g. fetch) — use the manual-transaction variant
const data = await runAsyncOnBackendWithManualTransactionHandling(async (url) => {
    const response = await fetch(url);
    return await response.json();
}, [url]);

UI interaction

  • triggerCommand(name, data) - trigger a command
  • bindGlobalShortcut(shortcut, handler, namespace?) - add keyboard shortcut

Utilities

  • formatDateISO(date) - format as YYYY-MM-DD
  • randomString(length) - generate random string
  • dayjs - day.js library
  • log(message) - log to script log pane

FNote object

Available via getNote(), getActiveContextNote(), useNoteContext(), etc.

Properties

  • note.noteId, note.title, note.type, note.mime
  • note.isProtected, note.isArchived

Content

  • note.getContent() - get note content
  • note.getJsonContent() - parse content as JSON

Hierarchy

  • note.getParentNotes() / note.getChildNotes()
  • note.hasChildren(), note.getSubtreeNoteIds()

Attributes

  • note.getAttributes(type?, name?) - all attributes (including inherited)
  • note.getOwnedAttributes(type?, name?) - only owned attributes
  • note.hasAttribute(type, name) - check for attribute

Electron API (desktop only)

Desktop-only functionality is exposed on window.electronApi by the preload script. It is not part of the api global. The global is undefined in the browser/server build and in the standalone (WASM) build, so always guard usage.

jsx
if (window.electronApi) {
    window.electronApi.window.setZoomFactor(1.2);
}

Use optional chaining for one-off calls: window.electronApi?.window.minimizeWindow().

Groups

GroupWhat it covers
windowZoom, theme, title bar, full screen, lifecycle, devtools, extra windows, global shortcut + open-in-tab events
clipboardcopyImageToClipboard(buffer) for raw PNG bytes
shellopenExternal, openPath, openFileUrl, downloadURL, openCustom — all validated in main process
contextMenuSubscribe to right-click events, dispatch cut/copy/paste/insertText
spellcheckaddWordToDictionary, getAvailableSpellCheckerLanguages
trayreloadTray()
printingPDF export/preview/save, printer list, print progress events
navigationBack/forward history accessors and navigation events

Common examples

js
// Open a URL in the user's default browser
window.electronApi?.shell.openExternal("https://example.com");

// Toggle full screen
const api = window.electronApi;
if (api) api.window.setFullScreen(!api.window.isFullScreen());

// Read & adjust zoom
const zoom = window.electronApi?.window.getZoomFactor() ?? 1;
window.electronApi?.window.setZoomFactor(zoom + 0.1);

// React to global shortcuts (configured in Trilium options)
window.electronApi?.window.onGlobalShortcut((actionName) => {
    console.log("shortcut fired:", actionName);
});

// Copy a PNG to the clipboard
const bytes = new Uint8Array(await blob.arrayBuffer());
window.electronApi?.clipboard.copyImageToClipboard(bytes);

Security notes (shell group)

Every shell.* call is validated in the main process and will throw on invalid input — the renderer is treated as untrusted:

  • openExternal(url): scheme is allowlisted. file:, data:, smb:, ldap:/ldaps:, jar:, view-source:, and Follina-class schemes are blocked.
  • openPath(path): must resolve under the Trilium data dir or tmp dir. Returns an empty string on success, an error message on failure.
  • openFileUrl(fileUrl): handles user-clicked file: links inside notes; resolves to any local path (no data/tmp sandbox — these links routinely point at arbitrary user documents). UNC file://host/share URLs are blocked (NTLM-leak prevention). Returns an empty string on success, an error message on failure.
  • downloadURL(url): pinned to the app's own origin — cross-origin downloads are rejected.
  • openCustom(filePath): must be a descendant of the tmp dir and the file must exist.

Cross-build patterns

Because window.electronApi is missing outside desktop, write code that degrades:

jsx
import { showMessage } from "trilium:api";

function openInBrowser(url) {
    if (window.electronApi) {
        window.electronApi.shell.openExternal(url);
    } else {
        window.open(url, "_blank", "noopener,noreferrer");
    }
}

Avoid require("electron"), @electron/remote, and processnodeIntegration is disabled and contextIsolation is enabled, so they aren't available in the renderer.

Legacy jQuery widgets (avoid if possible)

Only use legacy widgets if you specifically need jQuery or cannot use JSX.

javascript
// Language: JavaScript (Trilium frontend), Label: #widget
class MyWidget extends api.BasicWidget {
    get position() { return 1; }
    get parentWidget() { return "center-pane"; }

    doRender() {
        this.$widget = $("<div>");
        this.$widget.append($("<button>Click me</button>")
            .on("click", () => api.showMessage("Hello!")));
        return this.$widget;
    }
}
module.exports = new MyWidget();

Key differences from Preact:

  • Use api. global instead of imports
  • get parentWidget() instead of parent field
  • module.exports = new MyWidget() (instance) for most widgets
  • module.exports = MyWidget (class, no new) for note-detail-pane
  • Right pane: extend api.RightPanelWidget, override doRenderBody() instead of doRender()

Module system

For JSX, use import/export syntax between notes. For JavaScript (Trilium frontend), use module.exports and function parameters matching child note titles.