Skip to content

feat(i18n): migrate from i18next to Lingui + Tolgee (OTA, live dev regen, CI sync)#351

Open
NewtTheWolf wants to merge 24 commits into
TabularisDB:mainfrom
NewtTheWolf:feat/integrate-tolgee
Open

feat(i18n): migrate from i18next to Lingui + Tolgee (OTA, live dev regen, CI sync)#351
NewtTheWolf wants to merge 24 commits into
TabularisDB:mainfrom
NewtTheWolf:feat/integrate-tolgee

Conversation

@NewtTheWolf

@NewtTheWolf NewtTheWolf commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Full migration of the app's localization from i18next to Lingui v6, with Tolgee as the translation-management platform delivered over-the-air (OTA) via PO Content Delivery. End state is native-only Lingui — i18next is removed.

Highlights

  • Native Lingui macros at every call site (t, <Trans>, useLingui), with compiled PO catalogs under src/locales/<lng>/messages.{po,ts}.
  • Runtime OTA (src/i18n/ota.ts): overlays the latest Tolgee CDN PO on top of the bundled catalog at startup + on an interval, so corrected translations reach users without an app release. Plurals stay bundled. Toggle + interval in Settings → Localization.
  • Plugin API stays backwards-compatible: usePluginTranslation(pluginId) keeps its (key, options?) => string signature via a decoupled pluginI18n store (both {{var}} and ICU {var}).
  • @tabularis/create-plugin scaffolds Lingui-ready UI extensions (locales/<lang>.json, ICU) and fixes the dead --with-ui manifest wiring.

Dev DX

  • A dev-only Vite plugin (scripts/i18n/vite-lingui-watch.mjs) regenerates the catalogs on every source save → new strings appear via HMR. Runs inside Vite's dev server, so tauri dev needs nothing extra (no separate watcher process).

CI — catalogs + Tolgee round-trip

  • i18n-catalogs.yml — regenerates & commits catalogs on PRs (and on main as a fork-PR fallback), covering contributors who never ran the dev server.
  • i18n-tolgee-sync.yml — pushes source strings to Tolgee on every push to main, so machine translation starts promptly.
  • i18n-tolgee-backsync.yml — pulls finished translations (human + machine translation) back into the bundled catalogs on a daily schedule (MT lands asynchronously, so we poll). Pulls target languages only (never en), TRANSLATED/REVIEWED only.
  • .tolgeerc.json forceMode: OVERRIDE → KEEP (+ projectId): a push only adds keys and never overwrites translations edited in Tolgee. English/source text is owned by the code — fix source typos in the t string, not in Tolgee.

Docs

  • README + CONTRIBUTING document the "author English macros, don't translate" workflow and the translator (Tolgee/OTA) flow; new .rules/i18n.md; rules index updated.

⚠️ Required before the Tolgee jobs run

  • Add repo secret TOLGEE_API_KEY (Tolgee PAT / project key with write access to project 32587).

🤖 Generated with Claude Code

- Remove 110 unused translation keys across all 8 locales
- Add 36 keys to en (33 were English-only via inline defaultValue,
  3 fixed broken refs: createFk.nameRequired, editor.notebook.exportError,
  views.alterView)
- Add missing MySQL sslModes set (disabled/preferred/required/
  verify_ca/verify_identity); coexists with the PostgreSQL set
- Align all locales to en's key-set with English placeholders for gaps;
  preserve Russian's richer plural forms (_few/_many)
- Fix t("general.error") -> t("common.error") in DataGrid and Editor
  (general namespace never existed; rendered the raw key to users)
Now that every key exists in en.json (the fallback locale), the inline
defaultValue passed to t() merely duplicated the JSON value in code.
Remove 110 single-option defaultValue fallbacks across 16 files so the
JSON stays the single source of truth.

Left intact: newConnection.browseFile (code "Browse" vs json "Browse file")
and 4 calls whose object also carries interpolation variables.
Prepare for Tolgee import (base language en, sparse targets):
- Drop the English placeholders that were filled into non-en locales
  during normalization; Tolgee tracks them as untranslated instead of
  importing English text as a real translation. Runtime is unaffected
  (i18next falls back to en). Genuinely English pre-existing values
  (CPU, RAM, OK, ...) are kept.
- Pluralize 9 stems that were plain in en but plural in ru
  (clipboardImport.success, dataGrid.deleteRows, connections.connectionCount,
  ...) so the key shape is consistent across languages on import; also
  fixes the latent English plural bug (proper _one/_other forms).
Load translations through a chained backend (localStorage cache -> Tolgee
Content Delivery -> bundled JSON). The CDN overrides the shipped bundle when
reachable, so translation fixes reach users without a release; the bundle
remains an offline-safe fallback. returnEmptyString:false guarantees a missing
or empty translation falls back to en instead of rendering blank.

OTA is toggleable via localStorage (otaEnabled, default on; otaIntervalMinutes,
default 15 — matching Tolgee's CDN propagation window).
- Install Lingui v6 (@lingui/core, react, message-utils, cli, vite-plugin, babel-plugin-lingui-macro, format-po)
- Add lingui.config.ts with PO format (formatter() API required in v6, not "po" string)
- Wire @lingui/vite-plugin + babel-plugin-lingui-macro in vite.config.ts
- Create src/i18n/lingui.ts (i18n instance + dynamicActivate)
- Mount <I18nProvider> alongside i18next in main.tsx (async IIFE, await dynamicActivate("en"))
- Hand-convert SchemaModal.tsx from useTranslation → useLingui (@lingui/react/macro)
- Extract (9 messages) + compile (--typescript --namespace es → .ts catalogs)
- Add scripts/i18n/verify-id-bridge.mjs: generateMessageId("Close")=yz7wBu ✓
- Fix pre-existing tsc error in src/i18n/config.ts (bundledLoader callback type → ReadCallback)
…utput

Task 1 review fixup: 7 stale src/locales/*/messages.js (default-CJS compile
output from before --typescript --namespace es) were committed by accident and
unused (dynamicActivate imports .ts only). Remove them and gitignore *.js under
src/locales so they can't recur.
Plain (key->[originalKeys]) manifest could not drive the backfill: a plural()
macro extracts to ONE compound ICU msgid, but the manifest recorded the two raw
i18next strings, so no catalog entry would join — and it lacked the i18next stem
needed to gather a target locale's own _one/_few/_many/_other forms.

Each entry is now { kind, context, message, stem, forms, originalKeys }, keyed
by the Lingui message identity (msgid + gettext context delimiter). Plural pairs
collapse into one entry carrying the stem + en forms. Transform output is
unchanged; only the manifest contract changed.
Dry-run across all 138 real src files surfaced two issues:
- Multi-named-import react-i18next (e.g. { useTranslation, Trans }) crashed:
  ImportDeclaration has no removeNamedImport. Use the specifier's .remove(),
  leaving sibling imports (a leftover <Trans>) for Task 3 manual conversion.
- A plain string containing a backtick (dataGrid.copyColumnNameQuoted =
  "Copy as `column`") was punted to review; now emits the object descriptor
  form t({ message: ... }) instead of a tagged template.

Fixtures extended to lock both. Dry-run now: 0 crashes, 31 review sites
(30 dynamic-key registries for Task 3 + 1 genuine broken app ref
sidebar.actions in ExplorerSidebar.tsx).
Post-Task-3 audit (compared every interpolated call site against its pre-codemod
i18next binding) found 4 sites where the codemod emitted the placeholder NAME but
the original value was a different in-scope expression — compiled clean but
rendered the wrong value:
- ExplorerSidebar import-confirm: ${file} -> ${file.split(/[\\/]/).pop()} (basename, not full path)
- ExplorerSidebar delete-query: ${name} -> the looked-up favourite's name
- DataGrid open-referenced: ${table} -> ${fkForContextPreview.ref_table}
- Connections fail-connect: ${name} -> ${conn.name}

The other 55 renamed-value bindings were already correct.
scripts/i18n/backfill.mjs fills each non-en PO msgstr from the old
src/i18n/locales/<loc>.json, joining the extracted catalog to the codemod
manifest by a placeholder-insensitive SKELETON + context (Lingui derives
different placeholder names than i18next: simple idents keep their name,
everything else goes positional {0}). Naive msgid-equality could not join these.

- 0 unmatched catalog entries across all 7 locales (verified).
- Translations rewritten to the catalog's placeholder names by positional
  correspondence of the English source (handles locale reordering by name).
- Plurals rebuilt per target locale from the i18next stem (ru keeps one/few/
  many/other); the 2 extra-variable plurals preserve their positional tokens.
- Context split preserved: generateSQL DELETE vs common.delete Löschen.
- SchemaModal's 5 spike messages (hand-converted in Task 1, never in the
  manifest) bridged via SPIKE_EXTRAS.
- All 'empty' msgstr confirmed genuinely-untranslated in source (no drops).

Known: 12 same-namespace duplicate-English keys collapse to one entry with a
minor translation-nuance divergence in some locale (spec-anticipated risk;
English correct everywhere). Listed for an optional finer-context follow-up.
Native-only gate: no i18next refs in src, no i18next deps, build green.
Main bundle drops ~470 kB (2361 -> 1892 kB gzip 624 -> 475).

- Delete src/i18n/config.ts + src/i18n/locales/*.json (old i18next runtime).
- Relocate SUPPORTED_LANGUAGES/AppLanguage + add detectLocale() to lingui.ts;
  main.tsx activates the detected locale, drops the i18next side-effect import.
- Port PluginSlotProvider plugin translations from i18next addResourceBundle to
  Lingui i18n.load (flat keys = message ids); usePluginTranslation returns the
  runtime i18n._ (plugins are pre-built, can't use the t macro).
- Remove 7 i18next packages; .tolgeerc -> PO catalogs; vite manualChunks ->
  @lingui/*; refresh the open-source-libraries listing.
- Tests: vitest.config now applies the Lingui macro plugin; setup.ts mocks
  @lingui/react(/macro) so components render source English without a provider
  (replaces the dead react-i18next mock). Cuts the suite's pre-existing
  (since Task 3) failures from 188 to 69 assertion drifts, fixed next.
The i18next->Lingui migration (Tasks 3-5) silently broke 18 test files (the
build-only gate missed them). With the macro plugin + provider mock now in
place, the residual 69 failures were assertion drift — tests asserting old
i18next keys that components no longer render. Updated them to the source
English the components now emit (verified against component/registry source).

Notes:
- QuerySelectionModal uses the standalone plural macro (resolves via the
  @lingui/core singleton, not useLingui), so it mocks @lingui/core locally.
- SettingsProvider test now asserts dynamicActivate (not changeLanguage).
- openSourceLibraries counts updated for the i18next->lingui dependency swap.

Suite: 132 files / 2584 tests green.
The native-only Lingui port routed plugin translations through i18n.load +
i18n._, which broke two long-standing guarantees of the public PluginTranslator
contract: per-plugin key namespacing (flat load -> cross-plugin collisions) and
{{var}} interpolation (Lingui only does ICU {var}).

Restore BC with a self-contained, Lingui-independent plugin store (src/i18n/
pluginI18n.ts): keys scoped per plugin id, active-language -> English -> key
fallback, and {{var}} interpolation. As an additive bonus for new authors it
also interpolates ICU {var}, so plugins can author Lingui-style placeholders.
usePluginTranslation re-renders on locale change (reads the active Lingui locale).

Signature unchanged: (key, options?) => string. Docs (types/hooks/README) updated.
plugin-api check:sync OK; app build + 2584 tests green.
New plugins generated with --with-ui now ship working i18n out of the box:
- locales/en.json + de.json at the plugin root (host reads locales/<lang>.json),
  authored Lingui/ICU-style with {var} placeholders.
- ui/src/index.tsx uses usePluginTranslation(pluginId) for its label + toast.
- justfile dev-install copies locales/ into the installed plugin (all 3 OSes).
- READMEs document the i18n workflow: ICU {var} for new keys, legacy {{var}}
  still supported, active-lang -> en -> key fallback.

Also fixes a pre-existing bug that made --with-ui non-functional: scaffold.ts
computed UI_EXTENSIONS_ENTRY but the manifest template never referenced it, so
the built UI bundle was installed yet never declared -> the host never loaded it
(nor its locales). The manifest now includes ui_extensions when --with-ui; the
no-UI manifest stays unchanged. Smoke test asserts both the locales file and the
manifest ui_extensions wiring.

create-plugin unit tests green; scaffold verified for --with-ui and no-UI.
The context=top-level-namespace strategy could not distinguish two divergent
keys that share both English text AND namespace, so they collapsed to one
catalog entry and one (arbitrary) translation. Give each its full key as a
distinct context so they stay separate. Examples now correct:
- ja "View Definition": sidebar.viewDefinition ビュー定義 vs viewTriggerDefinition 定義を表示
- ru "Export Connections": exportTitle (noun) vs export (verb)
- it "Installed": filterInstalled Installati vs installed Installato

24 call sites re-contexted; manifest split accordingly. Backfilled from the
(git-restored, then re-removed) old locale json. Divergence audit: 0 collisions.
extract --clean drops the now-orphaned namespace-context entries (1207->1205
real msgids). 0 unmatched across all 7 locales; build + 2584 tests green.
Task 6 (Tolgee clear + reimport, project 32587): backed up the old project
(~/tolgee-backup-32587-pre-reimport-2026-06-19.zip), deleted all 1343 old
namespaced keys, imported the 1205 source-text PO catalogs. Per-language
counts match the backfill (en 1205; fr/de 1129; ru 1152; ja 1154; zh 1088;
es 1102; it 1122). CDN content-delivery switched to messageFormat ICU and
republished at the same URL so served placeholders/plurals match Lingui.
.tolgeerc format PO -> PO_ICU (the CLI's enum value for ICU PO).
src/i18n/ota.ts: refreshFromCdn(locale) fetches the Tolgee CDN <lng>.po, maps
each entry to its runtime id via generateMessageId(msgid, msgctxt) (bridge
verified for plain + context + plural ids), and i18n.load()s the overlay on top
of the bundled compiled catalog (Lingui load merges), then re-activates so
consumers re-render. Offline / not-yet-published / untranslated entries are
skipped (bundled value stays).

Plurals are intentionally NOT overlaid: the CDN serializes them as gettext
msgstr[n] whose per-locale CLDR reconstruction is brittle; the build-time
bundled catalog already carries correct plurals, so plurals update with releases.

main.tsx kicks an initial refresh after activate (non-blocking) and a
self-rescheduling poll that re-reads the enabled flag + interval each tick.
LocalizationTab gains a 'Translation updates' toggle (default on) + interval
(default 15 min), persisted to localStorage. pofile promoted to a runtime dep.

Unit tests cover overlay, context-split isolation, plural/untranslated skip, and
offline/non-ok no-ops. Build + 2587 tests green.
Dev-only Vite plugin (scripts/i18n/vite-lingui-watch.mjs) that regenerates the
Lingui catalogs on every source save, so new t/<Trans> strings show up via HMR.
Runs inside Vite's dev server, so `tauri dev` needs nothing beyond its usual
beforeDevCommand — no extra process.
@NewtTheWolf NewtTheWolf force-pushed the feat/integrate-tolgee branch from 491d50d to a7cff06 Compare June 22, 2026 14:33
- i18n-catalogs.yml regenerates and commits catalogs on PRs (and on main as a
  fallback for fork PRs), covering contributors who never ran the dev server.
- i18n-tolgee-sync.yml pushes source strings to Tolgee on every push to main, so
  machine translation can start promptly.
- i18n-tolgee-backsync.yml pulls finished translations (human + MT) back into the
  bundled catalogs on a daily schedule — MT lands asynchronously, so we poll.
- .tolgeerc.json forceMode OVERRIDE -> KEEP (+ projectId): a push never overwrites
  translations edited in Tolgee; it only adds new keys.
README + CONTRIBUTING explain the 'author English macros, don't translate'
contributor flow and the translator (Tolgee/OTA) flow; new .rules/i18n.md rule;
fixed the stale src/i18n/locales reference; rules index lists frontend.md + i18n.md.
@NewtTheWolf NewtTheWolf force-pushed the feat/integrate-tolgee branch from a7cff06 to 11ab32d Compare June 22, 2026 16:04
Reconciles the i18next->Lingui migration against upstream's new MCP "approval
attention" feature, which added strings to the old src/i18n/locales/*.json
catalogs that this branch removes.

- Kept the i18next JSON catalogs deleted; ported upstream's 6 new strings to
  Lingui macros (McpSafetySection approval rows + AiApprovalGate notification).
- Ported SettingsProvider's new language-settle state machine (isLanguageReady /
  isLanguageSettled) from i18next changeLanguage/resolvedLanguage to Lingui
  dynamicActivate / i18n.locale.
- Migrated AiApprovalGate to useLingui; moved its test + the SettingsProvider
  test onto the Lingui mock bridge.
- Regenerated catalogs (6 new keys), reconciled pnpm-lock (plugin-notification),
  added a .d.mts so vite.config typechecks under tsc -b.

Build + 2602 tests green.
Picks up the social-links feature (97576d1). Kept the i18next JSON catalogs
deleted; ported its 2 new strings (settings.followUs / followUsDesc) to Lingui
macros in InfoTab + UpdateNotificationModal. Regenerated catalogs.

Build + 2608 tests green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant