- Rust 100%
Bug: translating `en_GB.po` to `de` walked through the file, saw every msgstr was non-empty, and reported "already translated" for all 236 entries. translated=0 for the user. Same hit anything where the file's `Language:` header was unparseable (e.g. Django's free-form "Language: BRITISH ENGLISH" — which `LanguageIdentifier::from_str` silently accepts as a single 7-letter primary subtag, so the catalog target happened to be neither None nor the requested target). Two compounding root causes: 1. `translate_catalog` overwrote `catalog.target_lang = Some(target_lang.clone())` *before* deciding what to translate, so any "catalog target == requested target" check always saw equal. 2. `build_plans` always trusted `entry.is_translated()` regardless of whether the baked-in msgstr was in the language we now want. Fix: - `build_plans` now takes the requested target_lang and only honours pre-existing translations when `catalog.target_lang == Some(target_lang)`. Otherwise (mismatch *or* unknown) those entries go to the provider. - `translate_catalog` defers assigning the catalog target until after `build_plans` runs, so the original value is visible for that check. Three unit tests cover all three branches (match → preserve, mismatch → retranslate, unknown → retranslate). Verified live on a Django `en_GB.po` with `Language: BRITISH ENGLISH`: 236 entries → 236 DeepL-translated to German, 0 placeholder mismatches, output rewritten with the correct `Language: de` and `Plural-Forms:` headers. Only the `youtra` binary crate changes; libraries stay at 0.1.0 on crates.io. Bumps youtra to 0.1.1 via an explicit `version = "0.1.1"` override that supersedes the workspace-inherited 0.1.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| assets/logo | ||
| crates | ||
| tests/fixtures | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| DECISIONS.md | ||
| LICENSE | ||
| README.md | ||
Youtra
A Rust CLI that translates localization files (.po, .json) from one language
to another using machine-translation APIs. You give it an API key, it figures
out which provider the key belongs to.
Status: MVP feature-complete. Real providers (DeepL, Anthropic) and the mock provider work end-to-end. See DECISIONS.md for design notes; live API tests are gated behind env vars so
cargo testis offline by default.
Quickstart
# Translate a gettext catalog from English to Russian using DeepL.
youtra en_GB.po -t ru_RU --api-key "$DEEPL_AUTH_KEY"
# JSON, i18next-style, into multiple targets at once. DeepL key shape is
# auto-detected; with -t ru,de,fr you get three files written next to the
# input (or into a directory passed via -o).
youtra locales/en.json -t ru,de,fr -o locales/ --api-key "$DEEPL_AUTH_KEY"
# Or use Claude as the translator.
youtra src.po -t ru --api-key "$ANTHROPIC_API_KEY"
# Offline / deterministic dev loop with the mock provider.
youtra src.po -t ru --api-key mock --no-cache
By default the SQLite cache lives at $XDG_CACHE_HOME/youtra/cache.db
(%LOCALAPPDATA%\youtra\cache.db on Windows) so re-runs against the same
(provider, source, target, source-string) hit the cache instead of the API.
Override with --cache-dir or disable with --no-cache. Use --cache-only in
CI to assert that nothing new needs translating.
Install
From crates.io (recommended once published):
cargo install youtra
Or build from source:
cargo install --path crates/youtra
Workspace
| Crate | Purpose |
|---|---|
youtra-core |
Catalog, Entry, Format & Provider traits, errors, placeholder masking |
youtra-formats |
.po (via polib) and .json adapters |
youtra-providers |
DeepL, Anthropic, mock |
youtra-cache |
SQLite-backed translation cache |
youtra-plurals |
CLDR plural rules per target language |
youtra |
The youtra binary (CLI) |
Supported providers
| Provider | Auto-detected key shape | Endpoint |
|---|---|---|
| DeepL Free | UUID with :fx suffix |
api-free.deepl.com |
| DeepL Pro | UUID without suffix | api.deepl.com |
| Anthropic | starts with sk-ant- |
api.anthropic.com |
| Mock | literal mock |
none (testing only) |
Force a specific provider with --provider deepl|anthropic|mock. The CLI
errors out if multiple providers claim the same key shape (none currently do).
Placeholders that survive translation
Youtra masks placeholders before sending text to the provider and substitutes
them back on return. The masker auto-picks a style from gettext format flags
(c-format, python-format, python-brace-format); for JSON it protects
{name} and {{name}} patterns by default. If a translated string is missing
a placeholder or duplicates one, that entry is written out with the fuzzy
flag so a human can review.
Plural handling
.po plurals (msgid_plural) and i18next-style _one / _other JSON keys
are grouped into a single entry. On output the entry is fanned out to the CLDR
form set the target language requires:
- English / German / Spanish:
[one, other] - Russian / Ukrainian:
[one, few, many, other] - Arabic:
[zero, one, two, few, many, other] - Japanese / Chinese / Korean:
[other]
Synthesized forms get the fuzzy flag so a translator reviews them.
Contributing
Run cargo fmt, cargo clippy -- -D warnings, and cargo test before sending
a patch. CI gates on the first two and on cargo test.
Live provider tests are #[ignore]d by default so plain cargo test is
offline. Run them on demand with both the env var and --ignored:
YOUTRA_LIVE_DEEPL_KEY=... cargo test -p youtra-providers -- --ignored
YOUTRA_LIVE_ANTHROPIC_KEY=... cargo test -p youtra-providers -- --ignored
Heads up: DeepL returns HTTP 451 and Anthropic returns 403 from region-restricted networks even with valid keys, so these tests can fail for reasons unrelated to your changes.
License
Apache-2.0.