Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/actions/install-qt-deps/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ runs:
ccache \
qt6-base-dev \
qt6-base-private-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
libgl1-mesa-dev \
libx11-dev \
libxrandr-dev \
Expand Down
55 changes: 55 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,61 @@ jobs:
fi
echo "✅ Code formatting check passed"

i18n-check:
name: Translation Completeness
runs-on: ubuntu-24.04

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Install system dependencies
uses: ./.github/actions/install-qt-deps

- name: Install Conan
run: |
pip3 install conan
conan profile detect --force

- name: Install dependencies
run: conan install . --build=missing -s build_type=Release

- name: Configure CMake
run: cmake --preset release

- name: Run lupdate and check for drift
run: |
cmake --build --preset release --target sudoku_lupdate
if ! git diff --exit-code resources/translations/; then
echo "❌ Translation drift detected."
echo "Run 'cmake --build build/Release --target sudoku_lupdate' locally,"
echo "translate any new <translation type=\"unfinished\"> entries"
echo "using Qt Linguist, and commit."
exit 1
fi
# Check every non-English locale for unfinished entries.
# English is the source language — its entries are always 'unfinished'
# and Qt's QTranslator falls back to the <source> text.
fail=0
for ts in resources/translations/sudoku_*.ts; do
[ "$ts" = "resources/translations/sudoku_en.ts" ] && continue
if grep -q 'type="unfinished"' "$ts"; then
echo "❌ $ts has unfinished translations:"
grep 'type="unfinished"' "$ts" | head
fail=1
fi
done
[ "$fail" -eq 1 ] && exit 1
echo "✅ Translations are in sync and complete"

- name: Validate fmt-style placeholders in translations
run: |
# Catch translator-introduced placeholder mismatches (e.g. dropping
# {0} from a "Score: {0}" source) that lupdate does not validate.
# See scripts/check_translation_placeholders.py for the rules.
python3 scripts/check_translation_placeholders.py
python3 scripts/tests/test_check_translation_placeholders.py

test-and-coverage:
name: Test & Coverage
runs-on: ubuntu-24.04
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/packaging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y \
qt6-base-dev libgl1-mesa-dev \
qt6-base-dev qt6-tools-dev qt6-tools-dev-tools libgl1-mesa-dev \
ninja-build ccache \
libfuse2

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,6 @@ Sudoku-*-win64.exe
.appimage-tools/
AppDir/
Sudoku-*.AppImage
# Python bytecode caches
__pycache__/
*.pyc
40 changes: 30 additions & 10 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ find_package(ZLIB REQUIRED)
find_package(libsodium REQUIRED)

# Qt6 integration (system-installed)
find_package(Qt6 REQUIRED COMPONENTS Widgets Test)
qt_standard_project_setup()
find_package(Qt6 REQUIRED COMPONENTS Widgets Test LinguistTools)
qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES en de)

# Testing framework (optional)
find_package(Catch2 QUIET)
Expand Down Expand Up @@ -178,12 +178,32 @@ set_target_properties(${PROJECT_NAME} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)

# Copy locale files to build output directory (next to executable)
# Qt Linguist translations: compile .ts -> .qm and copy alongside the executable.
# Refresh .ts files from source after editing tr() call sites:
# cmake --build build/Debug --target sudoku_lupdate
set(SUDOKU_TS_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/resources/translations/sudoku_en.ts"
"${CMAKE_CURRENT_SOURCE_DIR}/resources/translations/sudoku_de.ts"
)
qt_add_lupdate(${PROJECT_NAME}
TS_FILES ${SUDOKU_TS_FILES}
SOURCES ${PROJECT_SOURCES}
OPTIONS
-tr-function-alias translate+=loc
-no-obsolete
-locations none
)
qt_add_lrelease(${PROJECT_NAME}
TS_FILES ${SUDOKU_TS_FILES}
QM_FILES_OUTPUT_VARIABLE SUDOKU_QM_FILES
)
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/resources/locales"
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/locales"
COMMENT "Copying locale files to build directory"
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/translations"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${SUDOKU_QM_FILES}
"$<TARGET_FILE_DIR:${PROJECT_NAME}>/translations/"
COMMENT "Copying Qt translation .qm files to build directory"
)

# Testing
Expand All @@ -197,7 +217,7 @@ endif()
if(WIN32)
# Windows: flat layout for NSIS installer
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION ".")
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/resources/locales" DESTINATION ".")
install(FILES ${SUDOKU_QM_FILES} DESTINATION "translations")

# Qt runtime via windeployqt
# QT6_DIR is set as an environment variable by the build scripts; read it with $ENV{}
Expand All @@ -221,8 +241,8 @@ else()
# Linux: FHS-compliant layout
include(GNUInstallDirs)
install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}")
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/resources/locales"
DESTINATION "${CMAKE_INSTALL_DATADIR}/sudoku")
install(FILES ${SUDOKU_QM_FILES}
DESTINATION "${CMAKE_INSTALL_DATADIR}/sudoku/translations")
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/resources/linux/org.sudoku_cpp.Sudoku.desktop"
DESTINATION "${CMAKE_INSTALL_DATADIR}/applications")
install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/resources/linux/org.sudoku_cpp.Sudoku.metainfo.xml"
Expand Down
154 changes: 154 additions & 0 deletions docs/TRANSLATIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Translations

This project uses Qt's `.ts` translation files at runtime (loaded via
`QTranslator`) and Qt's standard `lupdate` tool to extract translatable
strings from C++ source.

## Source-code conventions

Two free helper functions in `src/core/i18n_helpers.h` wrap Qt's translation
API so non-`QObject` code (Model, ViewModel, free functions) can stay
non-`QObject`:

```cpp
#include "core/i18n_helpers.h"

// Simple lookup — use anywhere:
std::string s = sudoku::core::loc("Sudoku", "Open");

// Format with positional args (fmt-style {0}, {1}):
std::string fmt = sudoku::core::locFormat(
sudoku::core::loc("Sudoku", "Score: {0}"),
score
);
```

`core::loc(context, source)` has the same signature as
`QCoreApplication::translate(context, source)` — Qt's `lupdate` is told
about this equivalence via `-tr-function-alias translate+=loc` in
[CMakeLists.txt](../CMakeLists.txt), so it extracts every `core::loc("Sudoku", "...")`
call site as a translatable string with context `Sudoku`.

`core::locFormat` takes an already-translated string (typically the result
of `core::loc(...)`) plus format arguments. The split exists so the
literal source goes through `core::loc` where `lupdate` can see it. **The
first argument to `core::loc` must be the literal `"Sudoku"`** — every
call site uses the same context.

## Files

- **Source-of-truth `.ts` files**: `resources/translations/sudoku_<locale>.ts`
- One `<context>Sudoku</context>` for the whole app.
- English text is the message ID (no synthetic-key indirection).
- Currently shipping: `en`, `de`.
- **Compiled `.qm` files**: produced at build time by `qt_add_lrelease`.
- Linux install path: `${CMAKE_INSTALL_DATADIR}/sudoku/translations/`.
- Flatpak: `<exe_dir>/translations/`.
- Windows: `translations/` subfolder next to the executable.

## Translator workflow

Open the `.ts` file in **Qt Linguist** (the GUI editor that ships with
Qt 6), translate any entry with empty `<translation>` or
`type="unfinished"`, save. No `lupdate` needed for translation work —
that runs on the developer side when source code adds new strings.

```sh
# Linux: install Qt Linguist (GUI editor)
sudo dnf install qt6-linguist # Fedora
sudo apt install linguist-qt6 # Debian/Ubuntu

# Open the German translation
linguist-qt6 resources/translations/sudoku_de.ts
```

After saving, rebuild — `qt_add_lrelease` compiles `.ts` → `.qm`
automatically and the next launch picks up the new translations.

To submit a translation: open a PR with the `.ts` diff. The CI
`i18n-check` job verifies completeness (no `type="unfinished"` entries,
no drift between source and `.ts`).

## Adding a new locale

1. Copy `sudoku_de.ts` to `sudoku_<lang>.ts` (e.g. `sudoku_ru.ts`).
2. Update the `<TS language="...">` attribute to the new language code.
3. Translate every `<translation>` element.
4. Add the language to `qt_standard_project_setup(I18N_TRANSLATED_LANGUAGES ...)`
in [CMakeLists.txt](../CMakeLists.txt).
5. Add the language code to the `kLocales` map in
[src/view/main_window.cpp](../src/view/main_window.cpp) (Settings → Language dropdown).

## Packager workflow

Distribution packagers building from a release tarball don't need any Qt
translation tooling beyond what's already pulled in by `find_package(Qt6 ... LinguistTools)`
— `cmake --build` runs `lrelease` automatically and produces the `.qm`
files in the build directory.

## Contributor workflow

When you add a new user-visible string in C++ source via
`core::loc("Sudoku", "...")` or
`core::locFormat(core::loc("Sudoku", "..."), args...)`:

1. Run `cmake --build build/Release --target sudoku_lupdate`.
This invokes `lupdate` and updates `sudoku_en.ts` / `sudoku_de.ts`
with the new entry, marked `type="unfinished"`.
2. Open `sudoku_de.ts` in Qt Linguist and translate the new entry (or
coordinate with a translator).
3. Commit both the source change and the `.ts` files in the same PR.

CI fails if:

- Running `lupdate` would change the `.ts` files (drift between source and
translations), **or**
- `sudoku_de.ts` contains any `<translation type="unfinished">` entries.

The `i18n-check` GitHub Actions job at
[.github/workflows/ci.yml](../.github/workflows/ci.yml) enforces both.

### A note on the source language

`sudoku_en.ts` intentionally has every entry as
`<translation type="unfinished"></translation>` — English is the source
language and Qt's `QTranslator` falls back to the `<source>` text when
the matching translation is empty. The CI's `unfinished` check runs only
against non-English locales (`sudoku_de.ts` and any future
`sudoku_<lang>.ts` files).

### A note on removed call sites (`-no-obsolete`)

`qt_add_lupdate` is configured with `-no-obsolete` in
[CMakeLists.txt](../CMakeLists.txt). When a `core::loc(...)` call site is
deleted from the source, the matching `<message>` block is **stripped
from every `.ts` file** the next time `lupdate` runs — including
translations a contributor has invested time in. The CI drift check
catches the removal (the `.ts` diff fails `git diff --exit-code`), so
the removal lands deliberately rather than silently, but the
translation text is gone from the working tree once the change is
committed. If you decide later that the string should come back, recover
the translation via `git log -p resources/translations/sudoku_<lang>.ts`
and paste it manually — there is no `<translation type="obsolete">`
fallback the way default `lupdate` provides.

The trade-off is intentional: keeping obsolete markers around indefinitely
clutters the file as the codebase evolves, and Qt Linguist surfaces them
under a separate "Obsolete" section that translators tend to ignore. If
you find yourself recovering translations from git history regularly,
consider dropping `-no-obsolete` from the `qt_add_lupdate` `OPTIONS`
block.

## Local verification

```sh
# Rebuild + run lupdate + diff
cmake --build build/Release --target sudoku_lupdate
git diff resources/translations/

# Check for unfinished German translations
grep 'type="unfinished"' resources/translations/sudoku_de.ts
```

Empty diff and zero unfinished entries means the source and translations
are in sync.
Loading
Loading