Working with Trilium's English UI strings
Trilium's UI is localized with i18next. English is the source of truth and the only catalogue you edit by hand — the other ~38 locales belong to Weblate. This skill is the everyday case: you are building or changing a feature and need to add a string, find one that already exists, or check that nothing dangles.
Every mechanical step is i18n.mjs.
S=.claude/skills/working-with-translations/i18n.mjs
node $S find "Cancel" # English text → catalogue + dotted key
node $S find totp_enroll --key # search key paths instead of values
node $S show about.channel # dump one section
node $S show delete_note.delete_note --locales # what the string says in every language
node $S add client dialog.my_thing "My text" # insert, preserving key order + formatting
node $S callers about.channel.nightly # where a key is referenced
node $S missing # keys used in code but absent from the catalogue
node $S unused # catalogue keys nothing references
Never read a whole catalogue to answer a question. apps/client/src/translations/en/translation.json is 226 KB / 4032 lines / 3384 keys across 304 top-level sections — reading it costs more than any question it answers. find and show exist so nothing has to.
Never hand-edit the JSON either. add writes through the file's own indent (2), EOL (LF) and trailing newline, and appends into the correct parent object. A round-trip is byte-identical, so a one-string change is a 3-line diff. A hand-edit gets the nesting or the trailing comma wrong; a naive JSON.parse → stringify at the wrong indent rewrites all 4032 lines.
Reading a string in other languages
show <key> --locales prints the value in every locale that has it, then the locales that don't
and the ones whose value is byte-identical to English:
--- client: delete_note.delete_note
en "Delete note"
de "Notiz löschen"
ro "Șterge notița"
…
22/39 locales translated
missing: az, bg, ca, el, en-GB, fa, fi, hr, hu, md, mr, nb-NO, nl, sl, sv, ur, vi
Use it to answer "what does this button say in German?", "has this been translated yet?", or
"why does the Romanian UI show that?". Identical-to-English is usually legitimate — a proper noun
(hidden-subtree.llm-title is "AI / LLM" nearly everywhere) or a word the language shares
(Italian login.password is "Password"). It works for all three catalogues.
This tool never writes a non-English file. add takes a catalogue (client / entry / server),
not a locale, so add ro … is rejected outright — there is no path by which it can touch a
Weblate-owned file. Editing a locale is the translating-locales skill's merge, and even then only
for a deliberate maintainer pass.
Changing an English string does not update the ~38 existing translations, which then read as
translations of text that no longer exists — show --locales makes that visible. Weblate flags them
for review on its next pull; you don't hand-edit them.
Which catalogue does a key go in?
| Catalogue | File | Read by |
|---|---|---|
client |
apps/client/src/translations/en/translation.json |
the browser UI — everything after login |
entry |
apps/client/src/translations/en/entry.json |
only the setup wizard, login and password-reset pages |
server |
apps/server/src/assets/translations/en/server.json |
apps/server, the Electron main process, packages/trilium-core, and the standalone worker |
Getting this wrong is the most common mistake, and add warns when a key's section lives in a different catalogue than the one you named.
entryexists for weight. The three pre-login pages callinitLocale(locale, "entry")and read ~12 KB instead of the app's 226 KB — which matters because the wizard reloads the catalogue every time the user picks a language. Put alogin.*/set_password.*/setup.*key inclientand the pre-login pages simply will not find it.- The app loads both
clientandentrywithfallbackNS: "entry", so a call site writest("login.password")without caring which file holds it. Only the new key has to go in the right place. server.jsonis misnamed — it is the catalogue for every runtime outside the browser UI. At()call added totrilium-coreresolves in server, desktop and standalone with no extra work (apps/standalone/vite.config.mtscopiesapps/server/src/assets/**and the worker loads it withns: "server"). Don't assume a core string needs a fallback to work in the browser build.
Client code imports t from ../services/i18n; server, desktop and core import it from i18next directly.
Adding a string
node $S find "Save changes" # 1. does it already exist?
node $S add client dialog.my_thing "Save changes"
add prints two advisories worth reading:
- Duplicate text — every existing key carrying that exact English string. Reuse one if it means the same thing; each new key costs ~38 locales a translation. But do add a separate key when the same English word differs by context — a language that inflects will need both.
- Catalogue routing — when the key's section doesn't exist in the catalogue you named but does in another.
add refuses to overwrite an existing key (--force to override) and refuses to nest under one that is already a string.
Then use it: t("dialog.my_thing"). Only en/ changes — Weblate carries the string to the other locales, and you should not hand-edit the other ~38 locale files.
Write US English, and mirror the difference into en-GB
The en catalogues are US English: color, center, meter, recognize, labeled, canceled,
organize, defense, gray. en-GB is a locale of its own, and unlike the ~38 translated locales it
is sparse on purpose — it carries only the strings whose British spelling differs ("color_type": "Colour", "centerContent": "Keep content centred"), and falls back to en for everything else.
So a string containing one of those words is two edits, not one:
node $S add client dialog.my_thing "Pick a color"
# then, only because the spelling differs:
printf '{"dialog.my_thing":"Pick a colour"}' > /tmp/en-GB.json
node .claude/skills/translating-locales/locale.mjs merge en-GB client /tmp/en-GB.json
Leave en-GB alone where the spelling is the same in both — an entry identical to English is noise
that measure then reports as untranslated. i18n.mjs writes en/ only, by design; locale.mjs merge is the sanctioned way into a locale file, and it preserves key order and formatting.
To check a catalogue, grep its values for the British halves of those pairs — colour, centre,
metre, -ise/-isation, -lled/-lling, defence, licence, grey, programme, whilst.
The English catalogues currently read 0 hits, so anything found is newly introduced.
Pluralization
i18next pluralizes only when the translation has _one / _other keys and the call site passes { count }. Add both forms and call t("key", { count }):
node $S add client space_usage.my_notes_one "{{count}} note"
node $S add client space_usage.my_notes_other "{{count}} notes"
There is no base key — find --key space_usage.my_notes shows only the suffixed pair, and that is correct. Converting an existing single-form English key to a plural pair removes the base key, so every other locale falls back to English until Weblate migrates them; call that out in the PR.
Interpolation
{{var}} normally, {{- var}} to skip HTML-escaping when the value contains quotes. When a string embeds components whose order varies by language (links, note references), use <Trans> from react-i18next rather than t(), so translators can reorder them.
Auditing
missing is the one that catches real bugs — a t("…") whose key is in no catalogue renders the raw key to the user:
node $S missing # currently 1 known hit, see below
node $S unused # advisory only
unused is advisory, not a delete list. It already suppresses three legitimate patterns (plural bases, dynamic prefixes, keys travelling as bare strings), but ~27 call sites build a key from a variable with no visible prefix, and keys can also be referenced from a test fixture or a docs note. Run callers <key> and confirm before deleting anything.
What the scanner does and doesn't see
missing / unused / callers scan apps/client/src (against client + entry) and apps/server/src, apps/desktop/src, packages/trilium-core/src (against server). Within those:
| Pattern | Handled |
|---|---|
t("some.key"), <Trans i18nKey="some.key"> |
resolved exactly |
t("pdf.annotations", { count }) → _one / _other |
plural bases bridged in both directions |
t(`about.channel.${channel}`) |
static prefix keeps every key under it live |
"security-dialog.lan-access" passed to a helper that calls t() |
counted as a soft reference |
t(labelKey) — a bare variable |
invisible; reported as a caveat count |
packages/ckeditor5 is deliberately not scanned. Its plugins pass the English text itself to CKEditor's t(), so the argument is a message, not a key — scanning it would produce nothing but noise. That mechanism has its own rules (a text-editor.ck entry keyed by the slug of the English text) and apps/client/src/services/i18n.spec.ts already enforces it in both directions, so a missing entry and a stale one both fail. See the CKEditor section of CLAUDE.md; don't reimplement that check here.
Validation
pnpm --filter client test src/services/i18n.spec.ts
That is the repo's only translation test: valid JSON, no duplicate keys, plus the CKEditor message-catalogue check. There is no locale-parity test and no typed i18next, so a missing key fails at runtime as a raw key on screen, not at build time — which is exactly why missing is worth running before you finish.
Known outstanding issue
packages/trilium-core/src/services/hidden_subtree_launcherbar.ts:136 calls t("hidden-subtree.llm-chat-title"), which does not exist in server.json (the nearby keys are llm-title, llm-chat-history-title, sidebar-chat-title). Low impact — that launcher entry is enforceDeleted: true, so the note is removed rather than displayed. Fix it or delete the dead entry if you are in that file anyway.
Related
- translating-locales — filling in a lagging non-English locale: measuring coverage, exporting work lists, placeholder validation, plural categories per locale, and how Weblate sync works. Use that skill, not this one, when the task is "bring
<locale>to 100%".