langtool: Mark strings that are deliberately the same as the English one

Plenty of strings are legitimately identical in the target language - "Vsync",
"Status", "Ad Hoc multiplayer" - but the only test we had for "is this
translated" is "does it differ from English", so those got sent to the AI again
on every single run of finish-language-with-ai, costing tokens and giving it
another chance to translate something that shouldn't be.

They now get a "# same as English" comment and are skipped. finish-language-with-ai
writes it when the AI hands the English string back unchanged (which the
validation added earlier was throwing away as an error), and import-single writes
it when a language's line matches the en_US one. Deleting the comment puts the
key back in play.

Also stop comparing values with their comments still attached while working out
what's untranslated, which was leaking "# AI translated" into the context block
of the prompt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_0164UKoXpz9r155TRfQsoc3H
This commit is contained in:
Henrik RydgårdandClaude Opus 5 committed 2026-08-23 16:18:07 +02:00
1 parent a1d205539c
commit c9ff8f55fe
5 files changed
+68 -14

No files matched your search

+5
View File
@@ -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 <scratch-file> "$1" "$2"`
Note this overwrites any existing value for that key, so if the key already had human
+3 -1
View File
@@ -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 <scratch-file> <Section> "<Key>"` 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
+5
View File
@@ -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
+33 -9
View File
@@ -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 &section.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}"
);
+22 -4
View File
@@ -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());
}
}
}