Skip to content

Commit 9bb2119

Browse files
authored
Merge pull request #12 from WendellXY/feat-translate-command-mentra
Add Mentra-backed translate command
2 parents ec4d5ac + eb4f7f0 commit 9bb2119

7 files changed

Lines changed: 1470 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ This is a `0.9.1` release available on [crates.io](https://crates.io/crates/lang
6161
- Edit (add/update/remove): `langcodec edit set -i 'locales/**/*.strings' -k welcome -v "Hello"` (use `--dry-run` to preview)
6262
- Normalize and detect drift: `langcodec normalize -i 'locales/**/*.{strings,xml,csv,tsv,xcstrings}' --check`
6363
- Sync existing keys only: `langcodec sync --source A.xcstrings --target B.xcstrings --match-lang en`
64+
- Translate drafts with Mentra: `langcodec translate --source source.xcstrings --target target.xcstrings --source-lang en --target-lang fr --provider openai --model gpt-4.1-mini`
6465
- View: `langcodec view -i strings.xml --full`
6566
- View filtered untranslated/review-needed keys: `langcodec view -i Localizable.xcstrings --status new,needs_review --keys-only`
6667
- View filtered results as JSON: `langcodec view -i Localizable.xcstrings --status new --lang fr --json`
@@ -102,6 +103,7 @@ This is a `0.9.1` release available on [crates.io](https://crates.io/crates/lang
102103
- Android path inference: `values/strings.xml` (no qualifier) defaults to English (`en`).
103104
- When converting to `.xcstrings`, if `source_language` or `version` metadata is missing, the CLI defaults them to `en` and `1.0` respectively (overridable via flags).
104105
- Strict status filtering note: `langcodec --strict view --status ...` requires explicit status metadata (supported in v1: `.xcstrings`).
106+
- Translate defaults to `new,stale`, writes `needs_review`, skips plurals, and supports `langcodec.toml` for translate-specific defaults.
105107

106108
#### Plurals
107109

langcodec-cli/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@ clap_complete = "4"
1818
unicode-width = "0.2.1"
1919
crossterm = "0.29.0"
2020
atty = "0.2.14"
21+
async-trait = "0.1.89"
22+
mentra = "0.2.0"
23+
serde = { version = "1.0", features = ["derive"] }
2124
serde_json = "1.0"
2225
serde_yaml = "0.9"
26+
tokio = { version = "1.50.0", features = ["rt-multi-thread", "sync"] }
27+
toml = "0.8"
2328
unic-langid = "0.9.6"
2429
glob = "0.3"
2530
rayon = "1.10"

langcodec-cli/README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
# langcodec-cli (Command Line)
22

3-
Universal CLI for converting, inspecting, merging, and editing localization files.
3+
Universal CLI for converting, inspecting, merging, editing, and translating localization files.
44

55
- Formats: Apple `.strings`, `.xcstrings`, Android `strings.xml`, CSV, TSV
6-
- Commands: convert, diff, merge, sync, view, stats, debug, edit, normalize
6+
- Commands: convert, diff, merge, sync, view, stats, debug, edit, normalize, translate
77

88
## Install
99

@@ -87,6 +87,34 @@ langcodec stats -i Localizable.xcstrings --json
8787

8888
Shows per-language totals, counts by status, and completion percent (excludes DoNotTranslate). Use `--json` for machine-readable output.
8989

90+
### translate
91+
92+
Translate singular source entries into a target language with a Mentra-backed provider.
93+
94+
```sh
95+
langcodec translate \
96+
--source source.xcstrings \
97+
--target target.xcstrings \
98+
--source-lang en \
99+
--target-lang fr \
100+
--provider openai \
101+
--model gpt-4.1-mini
102+
```
103+
104+
Behavior:
105+
106+
- defaults to translating target entries with statuses `new,stale`
107+
- writes generated values as `needs_review`
108+
- skips plural entries in v1
109+
- writes in-place by default and supports `--dry-run`
110+
- supports translate defaults from `langcodec.toml`
111+
112+
Provider/auth notes:
113+
114+
- choose a Mentra provider with `--provider` (`openai|anthropic|gemini`) or `translate.provider` in config
115+
- set the matching API key in the environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY`)
116+
- set the model with `--model`, `translate.model`, or `MENTRA_MODEL`
117+
90118
### debug
91119

92120
```sh

langcodec-cli/src/config.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
use serde::Deserialize;
2+
use std::path::PathBuf;
3+
4+
#[derive(Debug, Clone, Default, Deserialize)]
5+
pub struct CliConfig {
6+
#[serde(default)]
7+
pub translate: TranslateConfig,
8+
}
9+
10+
#[derive(Debug, Clone, Default, Deserialize)]
11+
pub struct TranslateConfig {
12+
pub provider: Option<String>,
13+
pub model: Option<String>,
14+
pub source_lang: Option<String>,
15+
pub target_lang: Option<String>,
16+
pub concurrency: Option<usize>,
17+
pub status: Option<Vec<String>>,
18+
}
19+
20+
#[derive(Debug, Clone)]
21+
pub struct LoadedConfig {
22+
pub path: PathBuf,
23+
pub data: CliConfig,
24+
}
25+
26+
pub fn load_config(explicit_path: Option<&str>) -> Result<Option<LoadedConfig>, String> {
27+
let path = match explicit_path {
28+
Some(path) => {
29+
let resolved = PathBuf::from(path);
30+
if !resolved.exists() {
31+
return Err(format!("Config file does not exist: {}", resolved.display()));
32+
}
33+
resolved
34+
}
35+
None => match discover_config_path()? {
36+
Some(path) => path,
37+
None => return Ok(None),
38+
},
39+
};
40+
41+
let text = std::fs::read_to_string(&path)
42+
.map_err(|e| format!("Failed to read config '{}': {}", path.display(), e))?;
43+
let data: CliConfig = toml::from_str(&text)
44+
.map_err(|e| format!("Failed to parse config '{}': {}", path.display(), e))?;
45+
Ok(Some(LoadedConfig { path, data }))
46+
}
47+
48+
fn discover_config_path() -> Result<Option<PathBuf>, String> {
49+
let mut current = std::env::current_dir()
50+
.map_err(|e| format!("Failed to determine current directory: {}", e))?;
51+
52+
loop {
53+
let candidate = current.join("langcodec.toml");
54+
if candidate.is_file() {
55+
return Ok(Some(candidate));
56+
}
57+
58+
if !current.pop() {
59+
return Ok(None);
60+
}
61+
}
62+
}

langcodec-cli/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
//! CLI library for testing purposes
22
3+
pub mod config;
34
pub mod formats;
45
pub mod merge;
6+
pub mod translate;
57
pub mod transformers;
68
pub mod validation;
79

810
pub use formats::{CustomFormat, parse_custom_format};
911
pub use langcodec::Codec;
12+
pub use translate::{TranslateOptions, run_translate_command};
1013
pub use transformers::custom_format_to_resource;

langcodec-cli/src/main.rs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
mod config;
12
mod convert;
23
mod debug;
34
mod diff;
@@ -8,6 +9,7 @@ mod normalize;
89
mod path_glob;
910
mod stats;
1011
mod sync;
12+
mod translate;
1113
mod transformers;
1214
mod validation;
1315
mod view;
@@ -19,6 +21,7 @@ use crate::edit::{EditSetOptions, run_edit_set_command};
1921
use crate::merge::{ConflictStrategy, run_merge_command};
2022
use crate::normalize::{NormalizeCliOptions, run_normalize_command};
2123
use crate::sync::{SyncOptions, run_sync_command};
24+
use crate::translate::{TranslateOptions, run_translate_command};
2225
use crate::validation::{ValidationContext, validate_context, validate_language_code};
2326
use crate::view::{ViewOptions, print_view, validate_status_filter};
2427
use clap::{CommandFactory, Parser, Subcommand};
@@ -254,6 +257,53 @@ enum Commands {
254257
json: bool,
255258
},
256259

260+
/// Translate source entries into a target language using Mentra-backed providers.
261+
Translate {
262+
/// Source localization file
263+
#[arg(short = 's', long)]
264+
source: String,
265+
266+
/// Optional target localization file. If omitted, translates in-place within multi-language files.
267+
#[arg(short = 't', long)]
268+
target: Option<String>,
269+
270+
/// Optional output file. Defaults to in-place write to target or source.
271+
#[arg(short, long)]
272+
output: Option<String>,
273+
274+
/// Source language code. Required when the source file contains multiple languages.
275+
#[arg(long)]
276+
source_lang: Option<String>,
277+
278+
/// Target language code.
279+
#[arg(long)]
280+
target_lang: Option<String>,
281+
282+
/// Filter target entries by status before translating (default: new,stale)
283+
#[arg(long)]
284+
status: Option<String>,
285+
286+
/// Mentra provider to use: openai, anthropic, gemini
287+
#[arg(long)]
288+
provider: Option<String>,
289+
290+
/// Model identifier to use with Mentra
291+
#[arg(long)]
292+
model: Option<String>,
293+
294+
/// Number of concurrent translation workers
295+
#[arg(long)]
296+
concurrency: Option<usize>,
297+
298+
/// Optional langcodec.toml path
299+
#[arg(long)]
300+
config: Option<String>,
301+
302+
/// Preview the translation run without writing files
303+
#[arg(long, default_value_t = false)]
304+
dry_run: bool,
305+
},
306+
257307
/// Debug: Read a localization file and output as JSON.
258308
Debug {
259309
/// The input file to debug
@@ -647,6 +697,62 @@ fn main() {
647697
strict,
648698
);
649699
}
700+
Commands::Translate {
701+
source,
702+
target,
703+
output,
704+
source_lang,
705+
target_lang,
706+
status,
707+
provider,
708+
model,
709+
concurrency,
710+
config,
711+
dry_run,
712+
} => {
713+
let mut context = ValidationContext::new().with_input_file(source.clone());
714+
if let Some(target_path) = &target
715+
&& std::path::Path::new(target_path).exists()
716+
{
717+
context = context.with_input_file(target_path.clone());
718+
}
719+
if let Some(output_path) = &output {
720+
context = context.with_output_file(output_path.clone());
721+
} else if let Some(target_path) = &target {
722+
context = context.with_output_file(target_path.clone());
723+
}
724+
if let Some(lang_code) = &source_lang {
725+
context = context.with_language_code(lang_code.clone());
726+
}
727+
if let Err(e) = validate_context(&context) {
728+
eprintln!("❌ Validation failed: {}", e);
729+
std::process::exit(1);
730+
}
731+
if let Some(lang_code) = &target_lang
732+
&& let Err(e) = validate_language_code(lang_code)
733+
{
734+
eprintln!("❌ Validation failed: {}", e);
735+
std::process::exit(1);
736+
}
737+
738+
if let Err(e) = run_translate_command(TranslateOptions {
739+
source,
740+
target,
741+
output,
742+
source_lang,
743+
target_lang,
744+
status,
745+
provider,
746+
model,
747+
concurrency,
748+
config,
749+
dry_run,
750+
strict,
751+
}) {
752+
eprintln!("❌ Translate failed: {}", e);
753+
std::process::exit(1);
754+
}
755+
}
650756
Commands::Debug {
651757
input,
652758
lang,

0 commit comments

Comments
 (0)