feat(i18n): migrate from i18next to Lingui + Tolgee (OTA, live dev regen, CI sync)#351
Open
NewtTheWolf wants to merge 24 commits into
Open
feat(i18n): migrate from i18next to Lingui + Tolgee (OTA, live dev regen, CI sync)#351NewtTheWolf wants to merge 24 commits into
NewtTheWolf wants to merge 24 commits into
Conversation
- 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.
491d50d to
a7cff06
Compare
- 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.
a7cff06 to
11ab32d
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
t,<Trans>,useLingui), with compiled PO catalogs undersrc/locales/<lng>/messages.{po,ts}.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.usePluginTranslation(pluginId)keeps its(key, options?) => stringsignature via a decoupledpluginI18nstore (both{{var}}and ICU{var}).@tabularis/create-pluginscaffolds Lingui-ready UI extensions (locales/<lang>.json, ICU) and fixes the dead--with-uimanifest wiring.Dev DX
scripts/i18n/vite-lingui-watch.mjs) regenerates the catalogs on every source save → new strings appear via HMR. Runs inside Vite's dev server, sotauri devneeds nothing extra (no separate watcher process).CI — catalogs + Tolgee round-trip
i18n-catalogs.yml— regenerates & commits catalogs on PRs (and onmainas a fork-PR fallback), covering contributors who never ran the dev server.i18n-tolgee-sync.yml— pushes source strings to Tolgee on every push tomain, 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 (neveren), TRANSLATED/REVIEWED only..tolgeerc.jsonforceMode: 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 thetstring, not in Tolgee.Docs
README+CONTRIBUTINGdocument the "author English macros, don't translate" workflow and the translator (Tolgee/OTA) flow; new.rules/i18n.md; rules index updated.TOLGEE_API_KEY(Tolgee PAT / project key with write access to project 32587).🤖 Generated with Claude Code