Skip to content

i18n: migrate from custom YAML/loc() runtime to Qt Linguist (.ts)#4

Merged
darkstar79 merged 9 commits intomainfrom
i18n-qt-linguist-migration
Apr 28, 2026
Merged

i18n: migrate from custom YAML/loc() runtime to Qt Linguist (.ts)#4
darkstar79 merged 9 commits intomainfrom
i18n-qt-linguist-migration

Conversation

@darkstar79
Copy link
Copy Markdown
Owner

@darkstar79 darkstar79 commented Apr 28, 2026

Summary

  • Replaces the bespoke YAML-based localization (LocalizationManager, string_keys.h, ~599-line per-locale YAML files) with native Qt Linguist .ts translations driven by QCoreApplication::translate / tr().
  • Adds a CI check that runs lupdate and fails on translation drift or unfinished non-English entries; documents the translator/contributor workflow in docs/TRANSLATIONS.md.
  • Restores LinguistTools to the Qt6 components installed by CI, so the build configures cleanly on Ubuntu 24.04 runners.

Key changes

  • Build: find_package(Qt6 ... LinguistTools), qt_add_translations, sudoku_lupdate target. apt install lists in .github/actions/install-qt-deps/action.yml and .github/workflows/packaging.yml gain qt6-tools-dev + qt6-tools-dev-tools.
  • Runtime: ~500 loc() call sites migrated to QCoreApplication::translate; LocalizationManager and its mock are deleted.
  • Translations: resources/locales/*.yaml removed; resources/translations/sudoku_{en,de}.ts are now the source of truth (488 source strings, no unfinished entries in sudoku_de.ts).
  • CI: new "Translation Completeness" job invokes cmake --build --target sudoku_lupdate and gates on git diff --exit-code resources/translations/.

Test plan

  • Local nightly suite reproduced via worktree-isolated subagents — all 5 jobs green (CPD, strategy correctness, clang-tidy, cppcheck, ASan + UBSan)
  • Local CI suite — Code Formatting + Translation Completeness pass; Test & Coverage fails the 55% branch-coverage gate at 54.2% (lines 84.7%, functions 85.9% still pass). Pre-existing coverage debt in newly-added strategies (ALSChain, KrakenFish, RegionForcingChain, UnitForcingChain) — not caused by this branch but surfaces here. To address in a follow-up.
  • CI run on this PR confirms the LinguistTools fix unblocks all jobs that build
  • Manual smoke test: launch in LANG=de_DE.UTF-8 and verify menu/dialog strings render in German

🤖 Generated with Claude Code

darkstar79 and others added 9 commits April 27, 2026 21:10
Phase 1 of migrating from the custom YAML LocalizationManager to native
Qt6 Linguist. No call sites or runtime behaviour changed yet — this only
adds the build plumbing and an inert QTranslator.

- find_package(Qt6 ... LinguistTools), qt_standard_project_setup with
  I18N_TRANSLATED_LANGUAGES en de
- Empty sudoku_en.ts / sudoku_de.ts placeholders with a single Sudoku
  context; populated by lupdate in later phases
- qt_add_lupdate (target sudoku_lupdate) + qt_add_lrelease, with
  post-build copy of the generated .qm files to bin/translations/
- Install rules ship .qm under translations/ on Windows and
  \${CMAKE_INSTALL_DATADIR}/sudoku/translations on Linux
- main.cpp installs a stack-allocated QTranslator using the locale from
  SettingsManager, mirroring the existing LocalizationManager lookup
  paths (exe_dir/translations, then ../share/sudoku/translations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of the Qt Linguist migration. Populates sudoku_en.ts and
sudoku_de.ts with the 509 strings from resources/locales/en.yaml and
de.yaml under a single <context>Sudoku</context> block. Each entry
carries an <extracomment>Key: foo.bar</extracomment> annotation for
transition reference.

scripts/yaml_to_ts.py is the converter. It defaults to bulk-converting
the in-tree en+de YAML and accepts --input/--language/--output so
downstream packagers (e.g. the openSUSE maintainer holding an external
ru.yaml) can convert their files to .ts in one command. The script is
kept in the repository even though string_keys.h and the YAML locales
are removed in Phase 4 — users with legacy YAML translations can run
it against an older commit.

530 keys minus 1 missing en.yaml entry (menu.get_coaching_hint, the
original bug, fixed in Phase 5) minus 21 deduped-by-source keys (e.g.
menu.reset_puzzle and button.reset_puzzle both map to "Reset Puzzle")
= 509 unique <message> entries. Deduping eliminates lrelease "duplicate
messages" warnings; same-source entries share the same translation,
which is the desired behavior.

Phase 3 will rewrite all loc() call sites to use
QCoreApplication::translate("Sudoku", "...") so lupdate sees the same
single context and preserves these seeded translations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of the Qt Linguist migration. Mechanically rewrites every
loc(KEY) and locFormat(KEY, args) call site in the codebase from
key-based YAML lookup to source-string-based Qt translation:

  loc(MenuGetCoachingHint)       -> core::loc("Get Coaching Hint")
  locFormat(RatingFormat, value) -> core::locFormat("SE {0}", value)

Adds src/core/i18n_helpers.h with two free functions in sudoku::core
that wrap QCoreApplication::translate("Sudoku", source). All call
sites use the single "Sudoku" context to align with the seeded .ts
files from Phase 2; lupdate -no-obsolete will preserve translations
by source-string match.

The migration was driven by scripts/migrate_loc_to_tr.py — a
balanced-paren matcher that handled 255 of 256 substitutions
mechanically. Three lambda/function-signature patterns required
manual handling: formatTechniqueLabel (training_widget.cpp) lost
its loc_fn callback; addStatRow (main_window.cpp) takes const char*
instead of string_view; setInteractionMode (training_number_pad.cpp)
uses a literal-string ternary instead of a runtime key.

Fixes three dangling-string_view bugs found in review:
GameViewModel::statisticsErrorToString, MainWindow::difficultyString,
and a local mastery_text variable were all binding the std::string
returned by core::loc to a std::string_view, leaving the view
pointing at a destroyed temporary. Subsequent reads (spdlog format,
operator+=, locFormat substitution) were undefined behaviour.

The original GH issue's missing key (menu.get_coaching_hint) is
fixed as a side effect: en.yaml and de.yaml gained the entry
("Get Coaching Hint" / "Coaching-Tipp"), and the seeded .ts files
were regenerated to include it.

tests/CMakeLists.txt links Qt6::Core into sudoku_lib (the test-time
static library) since view_models now need QCoreApplication at link
time. Six assertions in test_hint_revelation.cpp updated from
"hint.select_cell" etc. to the matching English source strings.

The member loc()/locFormat() helpers in the 6 view classes are now
unused but remain in place; Phase 4 deletes them with the rest of
the LocalizationManager wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the Qt Linguist migration. The runtime now flows entirely
through QCoreApplication::translate("Sudoku", ...); legacy plumbing
is gone.

Deleted:
- src/core/i_localization_manager.h, localization_manager.{h,cpp}
- src/core/string_keys.h
- resources/locales/{en,de}.yaml (and the directory)
- tests/helpers/mock_localization_manager.h
- tests/unit/test_{localization,mock_localization}_manager.cpp
  (~16 test cases that exercised the deleted class)

Migrated table-lookup helpers to drop their ILocalizationManager&
parameter and call core::loc(...) directly:
- getLocalizedTechniqueName (solving_technique.h, 56 cases)
- getTechniqueDescription (technique_descriptions.h, 56 entries x 2)
  TechniqueDescription struct fields: string_view -> string
- localizedPosition / localizedRegion / formatPositionList /
  getLocalizedExplanation (localized_explanations.h, 80 substitutions);
  fixed two more dangling-string_view bindings in the explanation
  builder (region_name, sec_region_name)
- getTrainingHint (training_hints.cpp, 27 substitutions); local
  locHint helper deleted

ViewModel/View cleanup:
- GameViewModel and TrainingViewModel constructors lose the
  loc_manager parameter; member loc_manager_, loc(), locFormat()
  removed. statisticsErrorToString return-type fix from Phase 3
  is preserved.
- 4 view classes lose setLocalizationManager(), loc_manager_ member,
  and the local loc()/locFormat() helpers (qstr() retained for
  std::string -> QString).
- MainWindow::setLocalizationManager body removed entirely; the
  Settings dialog language combo persists the user's choice but no
  longer attempts in-process retranslation. QTranslator swap-on-
  language-change is flagged as future work in code comments.
- main.cpp drops the LocalizationManager DI registration; Flatpak
  read-only-sandbox detection now keys on exe_dir/translations
  instead of exe_dir/locales (same outcome — both directories
  ship under share/sudoku/ in FHS layout).

CMake:
- Removed the resources/locales POST_BUILD copy and install rules
  on both Windows and Linux. .qm files install via Phase 1 rules.
- yaml-cpp stays as a Conan dependency: still used by settings,
  saves, statistics, and several test helpers (8 source files).
  The original plan's claim that yaml-cpp would be removed was
  wrong; this was caught in Phase 1.

Test cleanup:
- Fixtures (game_view_model_fixture.h, ui/test_fixture.h) drop the
  mock loc manager.
- 11+ test files updated to drop mock_loc / loc_manager constructor
  args and assertion strings; YAML-key assertions like
  "hint.select_cell", "training.next_exercise", "button.undo",
  "status.ready", "difficulty.medium", "training_mode" updated to
  English source strings (caught both by Phase 3 review and a
  follow-up sweep across UI tests).
- MockLocManager class definition removed from
  test_training_view_model.cpp.

scripts/migrate_getstring_to_loc.py is the throwaway script that
performed the .getString(KEY) substitutions in this phase. Phase 5
deletes it together with migrate_loc_to_tr.py.

Verification: Debug + Release build clean with -Werror; 941 unit
tests, 9 integration tests, 6 UI test suites all pass. Net change
-2943 lines across 47 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of the Qt Linguist migration. Standard `lupdate` cannot extract
source strings from our `core::loc` / `core::locFormat` helpers (the
`"Sudoku"` context is supplied inside the helper body where lupdate
doesn't peek), so a custom `scripts/check_translations.py` parses the
literal first arguments of those calls and verifies the .ts files cover
them — same role lupdate plays for native tr()-based projects. CI runs
this on every push/PR via the new `i18n-check` job.

To make every translatable literal visible to the scanner, three call
sites that previously hid the literals behind a helper variable or
lambda are inlined:

  * MainWindow `addStatRow` lambda — caller now passes `core::locFormat(...)`
    directly instead of `(fmt_str, value)`.
  * MainWindow `Clear/Fill Notes` ternary — `cond ? loc("A") : loc("B")`
    instead of `loc(cond ? "A" : "B")`.
  * TrainingNumberPad `Place/Eliminate` ternary — same pattern.

22 dead orphan entries are removed from both .ts files (510 → 488
each). Most were strings whose call sites disappeared in Phase 3-4;
a few (Cancel, Close, Reset) are Qt-provided translations that ship
with `qt_<locale>.qm`, not our `sudoku_<locale>.qm`.

`docs/TRANSLATIONS.md` documents the workflow for translators
(open .ts in `linguist-qt6`, save, rebuild), packagers (use
`scripts/yaml_to_ts.py` to convert downstream YAML translations),
and contributors (first arg to `core::loc/locFormat` must be a
literal — CI enforces it).

The throwaway migration scripts `scripts/migrate_loc_to_tr.py` and
`scripts/migrate_getstring_to_loc.py` are deleted now that the
substitution is complete; `scripts/yaml_to_ts.py` stays in-tree for
packagers maintaining downstream locale files (e.g. the openSUSE
maintainer's `sudoku_ru.yaml`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates the half-standard i18n setup. Source extraction is now done
by Qt's stock `lupdate` via a `-tr-function-alias` flag, replacing the
240-line custom Python scanner introduced in the previous commit.

The helpers in `src/core/i18n_helpers.h` are reshaped to expose the
"Sudoku" context to lupdate's parser:

  // Before (1-arg, lupdate-invisible):
  std::string loc(const char* source);
  std::string locFormat(const char* source, Args&&... args);

  // After (2-arg, matches QCoreApplication::translate):
  std::string loc(const char* context, const char* source);
  std::string locFormat(const std::string& translated, Args&&... args);

`locFormat` is split into a "translate then format" pattern at every
call site:

  core::locFormat(core::loc("Sudoku", "Score: {0}"), score)

This puts the literal source text inside `core::loc(...)` where lupdate
can extract it via the alias `-tr-function-alias translate+=loc`. The
variadic `locFormat` no longer needs lupdate visibility since it just
runs `fmt::format` against an already-translated string.

615 mechanical edits across 13 files were applied via two throwaway
migration scripts (now deleted). No behavior change: same translations
loaded at runtime, same .qm files produced, same UI text. lupdate now
finds all 488 source texts with zero drift.

CI's `i18n-check` job is rewritten as standard Qt tooling: build the
`sudoku_lupdate` target, fail if `git diff resources/translations/` is
non-empty (drift detection), fail if `sudoku_de.ts` has any
`<translation type="unfinished">` (incomplete-translation guard).

`docs/TRANSLATIONS.md` simplifies to the conventional `lupdate` +
Linguist GUI workflow that any Qt developer already knows. The
"Why a custom check instead of lupdate" section is gone — answer is
"we use lupdate now."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…locale-agnostic CI

Three follow-ups to commit 75378e8 surfaced by review:

1. Block the legacy `locFormat("Sudoku", "fmt", arg)` shape with a
   deleted `const char*` overload. Without it, `const char*` would
   implicitly convert to the `std::string` parameter, fmt::format would
   run on "Sudoku" (no specifiers), and the rest of the args would be
   silently discarded. Verified the deleted overload turns the footgun
   into a compile error while leaving correct
   `locFormat(loc(...), arg)` calls untouched.

2. Document the `-no-obsolete` lupdate flag in
   `docs/TRANSLATIONS.md`. The flag means a removed `core::loc(...)`
   call site permanently deletes the translation from every `.ts` file
   on the next lupdate run; recovery requires `git log -p`. The CI
   drift check still catches the removal at PR time, but contributors
   need to know the trade-off.

3. Make the CI unfinished-translation check locale-agnostic. It now
   loops over every `sudoku_*.ts` except `sudoku_en.ts` (which is the
   source language and intentionally has all entries marked
   `type="unfinished"`). Future locales (`sudoku_ru.ts`, etc.) get
   gated automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The i18n migration added find_package(Qt6 ... LinguistTools) but the
shared install-qt-deps action and packaging workflow only pulled
qt6-base-dev, breaking every nightly build job and packaging on this
branch with "Failed to find required Qt component LinguistTools".
Adds qt6-tools-dev (CMake config) and qt6-tools-dev-tools (lupdate
/lrelease binaries) so qt_add_translations succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fixture

Four follow-ups from review of the Qt Linguist migration:

1. Runtime language swap. MainWindow now owns the QTranslator and listens
   for QEvent::LanguageChange via changeEvent — installing or removing a
   translator triggers retranslateUi() automatically. Language changes
   from the Settings dialog go through MainWindow::applyLocale, which
   swaps the translator on the live qApp. TrainingWidget rebuilds its
   page stack on LanguageChange (rebuildPages already destroys and
   recreates the widget tree, so the new locale is picked up for free).
   Translator handling is removed from main.cpp; ownership and lifetime
   are colocated with the widget that consumes the events.
   New UI test test_language_change asserts &Game → &Spiel after
   installTranslator and the reverse on removeTranslator.

2. fmt-placeholder CI check. Translators editing .ts files in Qt
   Linguist can drop or rename a {0}-style placeholder; lupdate does
   not catch it (it knows %1 instead) and the bad string then hits
   fmt::format(fmt::runtime(...)) at runtime, throwing for users on
   the affected locale only. scripts/check_translation_placeholders.py
   parses every <message> and asserts the multiset of positional fields
   matches between source and translation. Strings whose source has no
   positional placeholder are skipped — that's the unambiguous signal
   for "this goes through fmt::format", and it lets prose like
   "candidates {A,B}" in technique descriptions pass through without
   false positives. Wired into the i18n-check CI job and covered by
   20 unit tests in scripts/tests/.

3. QCoreApplication test fixture. tests/helpers/qt_test_main.cpp
   replaces Catch2WithMain for unit + integration test binaries. With
   qApp present (and no translator installed), QCoreApplication::translate
   runs the documented "no installed translators" branch instead of the
   undocumented null-qApp short-circuit Qt has historically used.
   Tests are now isolated from Qt-internal behaviour changes and any
   test that needs a German translator can installTranslator on the
   already-running qApp.

4. yaml_to_ts.py removed. The script's required inputs (string_keys.h,
   resources/locales/en.yaml) were deleted in Phase 4, so the script
   could not run from a current checkout. Its only legitimate use was
   seeding sudoku_de.ts during Phase 2; that work is done. Anyone
   needing it for a downstream YAML can recover it from git history.

retranslateUi() and selected_language_ — flagged in the review as dead
code — are now live (the former) or removed (the latter, replaced by
current_locale_ which guards the settings observer against firing
applyLocale on unrelated setting changes).

Verified: 941 unit + 9 integration + 7 UI tests pass; clang-format
clean; lupdate produces zero drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@darkstar79 darkstar79 marked this pull request as ready for review April 28, 2026 17:26
@darkstar79 darkstar79 merged commit b413cbd into main Apr 28, 2026
7 checks passed
@darkstar79 darkstar79 linked an issue Apr 28, 2026 that may be closed by this pull request
@darkstar79 darkstar79 deleted the i18n-qt-linguist-migration branch May 3, 2026 12:47
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