Skip to content

i18n: complete all 32 locales and fix the defects shipping in every language - #936

Merged
h4yfans merged 21 commits into
mainfrom
i18n-locale-completion
Aug 3, 2026
Merged

i18n: complete all 32 locales and fix the defects shipping in every language#936
h4yfans merged 21 commits into
mainfrom
i18n-locale-completion

Conversation

@h4yfans

@h4yfans h4yfans commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Completes localisation across all 32 locales, and fixes several i18n defects found on the way that were shipping to users in every language — English included.

What users see today that they shouldn't

Raw HTML entities in dialogs. The i18n codemod cut entities in half: the & stayed in the JSX and the tail (ldquo;) became a translation key. JSX does not reassemble an entity across an expression boundary, so the delete dialog literally read:

“Task name” will be permanently deleted.

Confirmed by rendering it in jsdom. 12 surfaces were affected — delete task/subtasks, bulk due-date and priority, duplicate-with-subtasks, unsaved changes, quick capture, move-to-folder, formula editor and its delete confirmation, command palette, capture input, sync history.

A raw template on the vault picker. settings.setup.linking.vaultRow used i18next double-brace under the ICU formatter. IntlMessageFormat throws on {{count}}, IcuFormatter catches it and returns the template unchanged, so every user in every locale saw Vault — {{count}} items · {{date}} while choosing which vault to link. The repo's own guard test allowlisted the key, so CI stayed green.

A menu that read like broken software. Arabic Quit was استقال ("he resigned"), German Hör auf ("stop it!"), Japanese やめる ("give up"), the Indonesian File menu Mengajukan ("to submit a claim"). One Vietnamese value contained a literal newline. Danish Paste was Indsæt (Insert), colliding with the Insert menu beside it.

Coverage

Missing locale keys 292 → 0 across all 32 locales
Entries translated 9 052 + 1 705 + 14 043 + 1 304 + 716
Menu entries repaired 179
Hardcoded English surfaces localized 525 strings, 14 units
i18n:check 0 missing keys, 0 untranslated
Locale scan 0 double-brace, 0 ICU parse failures, 0 placeholder drift

Sentences that were assembled from fragments in JSX — with the plural picked by n !== 1 ? 's' : '' — are now single ICU messages, so word order and number agreement are finally expressible:

"This will delete {count, plural, one {# subtask} other {# subtasks}} from “{title}”."

Plural blocks carry every CLDR category the locale requires: Arabic gets all six with proper dual forms, the Slavic locales get few/many instead of a copy of English's two branches.

Behaviour changes

First-run language detection. A new install started in English no matter the system language — app.getLocale() was read and sent to telemetry but never reached the UI. It now picks the OS language and maps regional variants (de-ATde, pt-BRpt, zh-Hanszh-CN). Gated on a real first-run signal — no current vault and an empty vault registry — so an existing install can never flip language on update. That guarantee is pinned by a test.

Locale-aware dates. Dates and times were formatted with a hardcoded 'en-US', or with no locale at all (which follows the OS, not the app). They now follow the selected language, via a getActiveLocale() kept in sync from i18next's languageChanged.

Synced language now applies. A language change from another device was written to disk but never applied at runtime, leaving the UI, the native menu and activeLocale stale until restart.

Main-process fallback. getMainI18n() threw before boot installed the real instance, so an IPC error the user was meant to read could surface as main-process i18n not initialized. It now falls back to English and reports the boot-order problem to the log.

Guards added

  • Every locale string is compiled with the parser the formatter actually uses. The old guard only looked for double braces and allowlisted the loudest failure; the new one caught Hebrew דק', where the ASCII apostrophe is ICU's escape character and quoted away a plural block's closing brace. Verified by reintroducing the bug.
  • Placeholder parity per locale: the ICU placeholder set must equal English's, and an English plural block must stay one. That is the failure the ICU guard cannot see — a translation that parses cleanly but silently dropped {total}.
  • IMPORT_STATUS_KEYS is Record<ImportStatusCode, string>; removing an entry fails typecheck with TS2741, so a status code added later cannot silently ship untranslated. Mutation-tested.

Verification

renderer   538 files, 5897 passed
main       408 files, 4440 passed
i18n        11 files,   56 passed
typecheck  web · node · i18n · contracts — clean
lint       0 errors (10 pre-existing warnings, untouched files)
gates      i18n:check · ipc:check · check:contracts · check:architecture · docs:build

Deleting the dead entity-fragment keys was gated on a repo-wide reference check rather than the agents' reports: of 43 keys reported unused, 13 were still referenced and were kept.

Deliberately not done

  • general.language is still never enqueued for sync, so the receiving half added here is dormant. Wiring the sending half is a live sync behaviour change and a product call.
  • 574 values remain byte-identical to English on purpose — brand names, acronyms the language uses unchanged (CRDT, URL, PDF), and format literals — each justified per locale.
  • 208 pre-existing orphan English keys were left alone.
  • On Windows/Linux the Window menu's role: 'close' still owns Ctrl+W over File → Close Tab. Pre-existing, unrelated to localisation.

h4yfans added 17 commits August 3, 2026 11:59
Every non-English locale was missing the exact same 292 keys, so canvas,
tag hub, project hub, diagnostics, the inbox review reminder and the
account-vault surfaces fell back to English in 31 languages.

Each locale was translated and then reviewed by a separate native pass,
and machine-validated: placeholders preserved, ICU single-brace only, and
plural blocks carrying every CLDR category the locale actually requires
(so ru/pl/cs/ar get few/many rather than a copy of English's two forms).

pnpm i18n:check now reports 0 missing keys across all 32 locales.
setup.linking.vaultRow and home.widget.resizeAria used i18next double-brace
under the ICU formatter. IntlMessageFormat throws on {{count}}, IcuFormatter
catches it and returns the raw template, so the vault picker showed the
literal 'Vault — {{count}} items · {{date}}' in every locale, English
included. The guard test allowlisted both keys, so CI stayed green.

Hebrew home.widget.startsIn hit the same silent failure a different way: the
ASCII apostrophe in דק' is ICU's escape character, so it quoted away the
plural block's closing brace. Replaced with the Hebrew geresh ׳, which is
also the correct punctuation for the abbreviation.

Dropped the allowlist and added a guard that compiles every locale string
with the parser the formatter actually uses, so the whole class is covered
rather than just double braces. Verified it fails when the Hebrew bug is
reintroduced.

Repo-wide scan is now clean: 0 double-brace, 0 ICU parse failures,
0 placeholder drift across all 32 locales.
Pure helpers under lib/ format dates and times with Intl but had no way to
reach the language the user picked, so they hardcoded 'en-US' or passed
undefined (which follows the OS locale, not the app's).

Adds getActiveLocale(), kept in sync from i18next's languageChanged event
rather than from each call site, so settings, onboarding and a locale
synced from another device all update it. Mirrors the existing
setDateFormatPref pattern in format-date.ts.
1779 values across the 31 locales were byte-identical to the English source
— the whole Agent Chat / Agent MCP / Agent Providers settings surface had
never been translated in any language, plus scattered leftovers elsewhere.

1304 are now translated. The remaining 475 are deliberately unchanged and
each was justified per locale: product names (Google Calendar, LM Studio,
Ollama), technical literals (OAuth 2.0, PNG/JPEG/GIF/WebP/SVG, A4
(210 x 297 mm), the dateDiff() formula sample), pure placeholder
arrangements with no words ('{type}: {title}', '{count} {label}'), and unit
abbreviations that are genuinely identical in the target language ('min' in
Czech, Finnish, French, Spanish).

Applied with a re-check that refused to overwrite any value that was no
longer identical to English; 0 refusals.
…ow needs

getMainI18n() threw when a translation was requested before boot installed
the real instance. With main-process copy now going through i18n, that turns
an IPC error the user is meant to read into an internal initialization
message. It now falls back to a synchronous English-only instance and
reports the boot-order problem to the log instead.

Also lands the 453 new English keys the localisation pass introduced across
menu, errors, system, common, notes, tasks and settings.
…ale on first run

The main process shipped English regardless of the selected language:
- both browser-extension pairing consent dialogs, including their Allow/Deny
  buttons, and the Windows About dialog's version line
- Electron role menu items with no explicit label, which render Electron's
  own English defaults next to translated siblings (View zoom items, Edit
  paste-and-match/delete, macOS services/hide/unhide, Window submenu)
- every user-facing Google Calendar OAuth error, the device-registration
  rollback message, sync network errors, and the user-visible IPC handler
  errors

Startup locale: a fresh install always began in English even though
app.getLocale() was already read and handed to telemetry. It is now detected
and mapped onto a supported locale (de-AT→de, pt-BR→pt, zh-Hans→zh-CN, …),
gated on a real first-run signal — no current vault AND an empty vault
registry — so an existing install can never flip language on update, and
persisted immediately so it stays a one-time decision.

A locale synced from another device was written to disk but never applied at
runtime, leaving the UI, the native menu and activeLocale stale until
restart; it now goes through the same path as a local language change.
…locale-aware

Hardcoded English the key scanner could not see, because it was in props,
toasts and plain .ts data modules rather than JSX text: the whole keyboard
shortcuts dialog (7 groups, ~90 descriptions), task and kanban empty states,
reminder and snooze presets, priority labels, due-date filter options,
project validation errors, bulk-action/drag/subtask toasts, and the
save-filter summary — which was concatenated from English fragments and is
now one ICU message per clause so other languages can reorder it.

Counts that were pluralized by hand (`${n} task${n === 1 ? '' : 's'}`) are
now single ICU plural messages.

Dates and times were formatted with a hardcoded 'en-US', or with no locale
at all — which silently follows the OS, not the language the user picked.
They now use getActiveLocale(): due dates across Tasks, the calendar hour
gutter (which built 'AM'/'PM' by hand), the repeat dialog's weekday names,
the date picker, the capture heatmap and canvas cards. Date autocomplete now
matches localized weekday and month names while still accepting English.

Built-in names that originate in packages/contracts — folder-view column
names, status categories, the default view name — are translated at the
display layer, leaving the persisted identifiers untouched.

The first-run tour's own copy was localized but driver.js's Next/Previous/
Done chrome was not; it is now passed through, keeping driver.js's own
double-brace template syntax away from the ICU formatter.
… translate them

Importer errors and warnings were built as English sentences inside
@memry/importers and rendered verbatim in the import dialog. The package
cannot depend on the desktop i18n runtime, so each message now carries a
stable code plus the values it interpolates, and keeps its English text as
the message.

The renderer maps code → i18n key at display time and falls back to the raw
message when the code is missing or unknown, so an importer that has not
been migrated still renders exactly as before.

ImportPreviewMessage is `string | ImportMessage`, so preview payloads
written by an older build keep parsing.
…ales

Covers the strings the localisation pass just pulled out of hardcoded
English: the native pairing dialogs and their OS buttons, Electron menu
items that were rendering Electron's own English defaults, IPC error copy,
the keyboard shortcuts dialog, empty states, reminder and snooze presets,
priority and due-date filter labels, relative dates, and import warnings.

Menu items and dialog buttons follow each platform's own wording in that
language rather than a literal translation of the English. Plural blocks
carry every CLDR category the locale requires — Arabic gets all six with
proper dual forms, Czech and the Slavic locales get few/many.

Machine-validated per locale: placeholders preserved, ICU single-brace only,
every plural block compiles and covers its locale's categories.
The application menu was machine-translated and 179 entries were wrong — not
stylistically different, wrong. A native reviewer per locale checked every
entry against the wording that language's macOS and Windows menus actually
use.

The worst were not subtle:
- Arabic Quit read استقال, "he resigned"; German "Hör auf" ("stop it!");
  Japanese やめる ("give up"); Polish "Przestań" ("cut it out");
  Hebrew עזוב ("forget it"); Romanian "Renunță" ("give up")
- the Indonesian File menu read "Mengajukan" — "to submit a claim", the
  verb sense of "to file"
- a Vietnamese value contained a literal newline and would have rendered as
  a broken menu item
- Danish Paste was "Indsæt" (Insert), colliding with the Insert menu in the
  same menu bar
- several locales had Quit and Exit swapped or identical, losing the
  macOS/Windows distinction

Also fixes a recurring grammatical error: menu-bar titles are nouns in
Czech, Polish, Romanian, Hebrew and Indonesian (Edycja, Úpravy, Editare),
but had been translated as second-person imperatives — the app ordering the
user to edit.

Every replacement was checked for placeholder parity and ICU validity before
being written; 0 refusals.
…n English

An earlier sweep only looked at strings of two words or more, so every
single-word leftover slipped through — 2078 entries across the 31 locales,
concentrated in the Agent Chat composer and the Agent MCP / Agent Providers
settings panels. Those panels rendered their own buttons and status badges
in English inside an otherwise translated interface: Save, Off, Access,
Tools, Permissions, Connection, Preset, Custom, Model, Disconnected.

716 are now translated. The remaining 1362 stay English deliberately and
were justified per locale: brand and vendor names (Claude, Codex, OpenAI,
Ollama, Todoist), font names (Geist, Gelasio), acronyms the language uses
unchanged (CRDT, URL, API, PDF, HTML, CSV, MCP), and the YYYY-MM-DD format
pattern.

Applied with a re-check that refused to overwrite any value no longer
identical to English; 0 refusals.
…ments

Adds the English for the confirmation dialogs, filter surfaces, import
status catalog and agent errors that the localisation pass rebuilt, and
removes 30 keys that only ever held the tail of a split HTML entity
('ldquo;', 'rdquo;', 'quot;', 'middot;') plus the sentence fragments that
were concatenated around them — 1056 dead entries across the 32 locales.

Deletion was gated on a repo-wide reference check rather than the agents'
own reports: of the 43 keys reported unused, 13 were still referenced and
were kept. Only the 30 with no remaining call site were removed.
Every importer's ctx.status() line was hardcoded English and rendered
straight into the import dialog, so a Turkish or Japanese user watched an
English progress log. packages/importers cannot depend on the desktop i18n
runtime, so each message now carries a stable code alongside its English
text, and the renderer maps code to i18n key at display time.

22 call sites across 13 importers. IMPORT_STATUS_KEYS is typed
Record<ImportStatusCode, string>, which was mutation-tested: removing one
entry fails typecheck with TS2741, so a status code added later cannot
silently ship untranslated.

ImportProgressEvent.status and ImportPreviewMessage are 'string | ImportMessage',
so a payload produced by an older build still renders, and an unknown code
falls back to the raw English message rather than a blank line.
…tences

The i18n codemod cut HTML entities in half. The ampersand stayed in the JSX
and the tail became a translation key, so a delete dialog read:

    &ldquo;Task name&rdquo; will be permanently deleted.

JSX does not reassemble an entity across an expression boundary — I rendered
it in jsdom to confirm. Users saw the literal &ldquo; / &rdquo; / &quot; /
&middot; in every locale, English included, across 12 surfaces: the delete
task and subtask dialogs, bulk due-date and priority, duplicate-with-subtasks,
unsaved changes, quick capture, move-to-folder, the formula editor and its
delete confirmation, the command palette, capture input, and sync history.

The same sentences were also assembled from separate translation fragments
with the plural picked by a JS ternary appending "s":

    {t('thisWillDelete')}{n}{t('subtask')}{n !== 1 ? 's' : ''}{t('from')}{title}

Word order was frozen to English and no language with real plural rules
could be expressed. Each is now one ICU message with placeholders and a
proper plural block, with real typographic quotes in the English source:

    "This will delete {count, plural, one {# subtask} other {# subtasks}} from “{title}”."

Rendered wording is unchanged apart from the entities becoming the
characters they were always meant to be.
- sync/attachments.ts leaked four English sentences to the renderer,
  including the server-unreachable message that already had a key
- the folder-view drag handle announced the raw column id to screen readers
  ('wordCount') while the visible label said 'Word count'
- task priority labels were fabricated with charAt(0).toUpperCase(), and
  interactive-priority-badge and inline-priority-popover read them at module
  load — before i18n exists — freezing English for the session
- active-filters-bar kept its own duplicate English label tables
- the due-date picker froze its quick options at mount, so Today/Tomorrow
  kept the old language after a mid-session switch
- 'Agent runtime is starting' was matched as an English substring to drive
  the agent bootstrap retry loop, so localizing it would have broken agent
  startup in every non-English locale; the retry now matches a stable code

Adds a placeholder-parity guard: for every locale and key, the ICU
placeholder set must equal English's, and a plural block in English must
stay a plural block. That is the failure the ICU guard cannot see — a
translation that parses cleanly but silently dropped {total}.

Also covers the first-run OS-locale detection, which had no integration
test: the store mock lacked getVaults/setStoredLocale, so detection threw
into its own catch and returned null while all 47 tests still passed. The
existing-install case is now pinned — a user with a vault keeps English.
…31 locales

The final 55 keys, most of them the dialog sentences that were previously
assembled from fragments in JSX. Because they are now whole ICU messages,
these languages can finally order the words naturally and inflect the count
instead of inheriting English word order with an appended "s".

pnpm i18n:check now reports 0 missing keys across all 32 locales, and the
locale scan is clean: 0 double-brace interpolations, 0 ICU parse failures,
0 placeholder drift.
Settings › Language & Region now covers what actually changed for users: a
fresh install picks its language from the OS (with regional variants mapped
onto the closest supported locale), an existing install is never
re-detected, a language change from another device applies without a
restart, and dates and weekday names follow the selected language rather
than the operating system.

Also notes that import progress and warnings are localized.
Copilot AI review requested due to automatic review settings August 3, 2026 17:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

Conflict in sync/http-client.ts: main added a distinct message for a timed-out
sync request (#931) while this branch localized the generic unreachable case.
Kept both branches and localized both — main's new copy becomes
errors:sync.requestTimedOut rather than being folded into the existing
sync.networkTimeout key, whose wording gives different guidance.
Copilot AI review requested due to automatic review settings August 3, 2026 17:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@github-actions github-actions Bot added bug Something isn't working dependencies documentation Improvements or additions to documentation test labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

React Doctor found 1 new issue in 1 file · 1 warning · score 83 / 100 (Needs work) · 1 fixed · vs main

1 warning

src/renderer/src/components/settings/import-dialog.tsx

  • ⚠️ L159 Array index used as a key no-array-index-as-key

Reviewed by React Doctor for commit 9c78fdb. See inline comments for fixes.

These keys arrived with main in English only: the dialog asking whether the
AI agent may read the user's Google Calendar events, its Settings toggle,
and the promote-dialog notice. Adds the merged sync timeout message too.

The consent copy draws a subtle distinction — the user keeps seeing their
Google events either way, the choice only controls whether the agent can
read them — so translators were briefed to preserve that rather than
translate word by word. Allow / Don't allow use each platform's own
permission-prompt wording.

All 32 locales are complete again: 0 missing keys.
Copilot AI review requested due to automatic review settings August 3, 2026 17:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

…persist-order bug

The coverage ratchet caught that several things this branch introduced had no
test. Adds 147 tests across seven modules, all behaviour-first and several
mutation-verified rather than written to colour lines green:

- active-locale: the default must be the fallback locale, and the value must
  reach Intl — asserted through real formatted output (en January / de Januar
  / fr janvier), plus every one of the 32 shipped locale ids round-tripping
  through Intl without throwing
- reminder-presets, tasks-data: the lazy label getters re-resolve per access,
  pinned by flipping the language between two reads. Eager evaluation was the
  original bug; this stops it coming back
- oauth-errors: the full Google Calendar failure mapping, including that six
  distinct causes map to six distinct messages — a collapse line coverage
  cannot see
- the synced-locale apply path: drives the real locale-handler rather than
  stubbing applyLocale, so it asserts the runtime effects. Verified by
  deleting the applySyncedLocale call, which turns five tests red
- use-import-run: both status shapes flow through unchanged, which is the
  backward-compatibility guarantee for payloads from an older build
- tags-handlers: each failure envelope returns its own message key

Fix found while writing them: applyLocale persisted before awaiting
changeLanguage, so a locale bundle that failed to load left config.json, the
store and the DB holding a language the app never switched to, with
activeLocale disagreeing — the same drift this path exists to prevent, on the
error branch. Persistence now happens only after the switch succeeds.

The fix was mutation-tested: reverting the order was initially caught by
nothing, so a test now pins it.
Copilot AI review requested due to automatic review settings August 3, 2026 19:01

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

use-bulk-actions fires the toasts for every bulk task operation, and this
branch replaced its hand-rolled English pluralization with ICU plural
messages. A regression there is invisible in English — "1 task" / "2 tasks"
is what the old code produced too — and only breaks for other languages.

49 tests covering both plural arms of every operation, the failure path of
each, the empty-selection guards, and the undo affordances — invoking the
sonner action and asserting the operation is actually reversed, not just that
a toast appeared. Assertions pin namespace, key and interpolated values as
well as rendered text, so a lost {count} fails.

Mutation-verified. The one worth noting: swapping a key for an
English-identical sibling (phaseI.errors.failedToArchiveTasks ->
phaseI.toasts.failedToArchiveTasks, both render "Failed to archive tasks")
is caught by exactly one of the new tests and was invisible to the
pre-existing suite, which compares rendered strings.

Lines 85.9% -> 98.63%, uncovered statements 41 -> 9.
Copilot AI review requested due to automatic review settings August 3, 2026 19:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of files (300). Try reducing the number of changed files and requesting a review from Copilot again.

@h4yfans
h4yfans marked this pull request as ready for review August 3, 2026 19:41
@h4yfans
h4yfans merged commit 735f78a into main Aug 3, 2026
19 checks passed
@h4yfans
h4yfans deleted the i18n-locale-completion branch August 3, 2026 19:41
<ul className="mt-1 ps-4 list-disc">
{g.warnings.map((w, i) => (
<li key={i}>{w}</li>
<li key={i}>{formatImportMessage(w)}</li>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "i".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working dependencies documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants