diff --git a/.claude/commands/add-string.md b/.claude/commands/add-string.md index c056a36741..a383a95f8f 100644 --- a/.claude/commands/add-string.md +++ b/.claude/commands/add-string.md @@ -44,6 +44,11 @@ Follow the workflow in AGENTS.md under "Translated UI strings (assets/lang)". Ru from a language file falls back to the English string at runtime, which is normal and fine - much better than a confident guess that nobody in the project can read well enough to catch. + If a language deliberately keeps the English string (a term like "Vsync" that language doesn't + translate), that's different from not knowing - include it with the English text. It gets written + with a `# same as English` comment, which is what stops langtool from trying to translate it + again on every later run. + 3. `cargo run -- import-single "$1" "$2"` Note this overwrites any existing value for that key, so if the key already had human diff --git a/AGENTS.md b/AGENTS.md index d1b813ecbe..98d13d5de4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,7 +203,9 @@ from `Tools/langtool`: No trailing `# comments` on those lines, they'd end up inside the translation. Placeholders have to survive verbatim. If you don't know a language well enough, leave it out - a key that's missing from a language file falls back to the English string at runtime, which is much better than a confident - guess. + guess. If a language deliberately keeps the English string (a term like "Vsync" that isn't + translated), do include it with the English text - it gets written with a `# same as English` + comment, which stops langtool from trying to translate it again on every later run. 3. `cargo run -- import-single
""` writes them all in, including a new key in en_US.ini, tagged `# AI translated` except for the en_US line, which is the string the rest were translated from. Note it overwrites existing values for that key, so take care with keys that diff --git a/Tools/langtool/README.md b/Tools/langtool/README.md index 9ee1858511..1bbed82385 100644 --- a/Tools/langtool/README.md +++ b/Tools/langtool/README.md @@ -47,6 +47,11 @@ cargo run -- --model gpt-4o finish-language-with-ai sv_SE The provider is inferred from the model name, so `--provider` is only needed to disambiguate. +Strings that are deliberately the same as the English one (terms like "Vsync") are marked with a +`# same as English` comment. `finish-language-with-ai` skips those instead of paying to have them +"translated" again on every run, and writes the comment itself when the AI hands the English string +back unchanged. Delete the comment if you disagree with it and it'll be picked up again next time. + To autoformat the code, use: ```bash diff --git a/Tools/langtool/src/main.rs b/Tools/langtool/src/main.rs index e9970a9191..ff7a735dd1 100644 --- a/Tools/langtool/src/main.rs +++ b/Tools/langtool/src/main.rs @@ -3,7 +3,7 @@ use std::io; use std::collections::{BTreeMap, BTreeSet}; mod section; -use section::{Section, line_value}; +use section::{SAME_COMMENT, Section, line_value, marked_same, split_comment}; mod inifile; use inifile::IniFile; @@ -498,8 +498,13 @@ fn finish_language_with_ai( let mut untranslated_keys = vec![]; let mut translated_keys = vec![]; for line in §ion.lines { - if let Some((key, value)) = split_line(line) { + if let Some((key, raw_value)) = split_line(line) { + let (value, comment) = split_comment(raw_value); if let Some(ref_value) = ref_section.get_value(key) { + if marked_same(comment) { + // Settled: this one stays as the English string, don't ask about it again. + continue; + } if value == ref_value { // Key not translated. // However, we need to reject some things that the AI likes to mishandle. @@ -583,6 +588,18 @@ fn finish_language_with_ai( let ref_value = ref_section.get_value(original_key).unwrap_or_default(); let issues = validate::check_ai_translation(&ref_value, value); + if issues == vec![validate::Issue::Untranslated] { + // The AI handed the English string back unchanged, which for + // things like "Vsync" or "Ad Hoc multiplayer" is the right + // answer. Write that down so we stop asking every run. + println!("Marking '{original_key}' as not needing translation"); + target_section.set_value( + original_key, + value, + Some(SAME_COMMENT), + ); + continue; + } if !issues.is_empty() { for issue in issues { println!("Rejecting '{original_key}' = '{value}': {issue}"); @@ -1033,17 +1050,24 @@ fn execute_command(cmd: Command, ai: Option<&Ai>, dry_run: bool, verbose: bool) ref key, } => { let lang_id = filename.strip_suffix(".ini").unwrap(); - // en_US is in here too, if the file has a line for it - that one is the English - // string the others were translated from, not a translation, so no comment on it. - let comment = if is_reference { - None - } else { - Some("AI translated") - }; if let Some(single_section) = &single_ini_section { + let english = single_section + .get_line("en_US") + .and_then(|line| line_value(&line).map(|value| value.to_string())); if let Some(target_section) = target_ini.get_section_mut(section) { if let Some(single_line) = single_section.get_line(lang_id) { if let Some(value) = line_value(&single_line) { + // en_US is the string the others were translated from, not a + // translation, so it gets no comment. A language where the + // translation is just the English string gets marked as such, so + // nothing tries to translate it again later. + let comment = if is_reference { + None + } else if Some(value) == english.as_deref() { + Some(SAME_COMMENT) + } else { + Some("AI translated") + }; println!( "Inserting value {value} for key {key} in section {section} in {target_ini_filename}" ); diff --git a/Tools/langtool/src/section.rs b/Tools/langtool/src/section.rs index 06bee833ad..83b987fe48 100644 --- a/Tools/langtool/src/section.rs +++ b/Tools/langtool/src/section.rs @@ -28,6 +28,26 @@ pub fn line_value(line: &str) -> Option<&str> { split_line(line).map(|tuple| tuple.1) } +/// The comment we put on a line that's deliberately identical to the English string, so we don't +/// keep paying to have it "translated" over and over. +pub const SAME_COMMENT: &str = "same as English"; + +/// Splits the value part of a line into the value itself and its trailing comment, if any +/// (without the '#'). +pub fn split_comment(value: &str) -> (&str, &str) { + match value.split_once('#') { + Some((value, comment)) => (value.trim(), comment.trim()), + None => (value.trim(), ""), + } +} + +/// True if the comment marks the line as deliberately left as the English string. Lenient about +/// what follows, so "# same", "# same as English" and "# same as English, checked by hrydgard" +/// all count. +pub fn marked_same(comment: &str) -> bool { + comment.to_ascii_lowercase().starts_with("same") +} + impl Section { pub fn apply_regex(&mut self, key: &str, pattern: &str, replacement: &str) { let re = Regex::new(pattern).unwrap(); @@ -375,10 +395,8 @@ impl Section { for line in &self.lines { if let Some((ref_key, value)) = split_line(line) { if key.eq(ref_key) { - // Found it! - // The value might have a comment starting with #, strip that before returning. - let value = value.split('#').next().unwrap().trim(); - return Some(value.to_string()); + // Found it! The value might have a comment, that's not part of the value. + return Some(split_comment(value).0.to_string()); } } }