1use std::{
21 collections::{HashMap, HashSet},
22 env,
23 error::Error,
24 fs,
25 path::{Path, PathBuf},
26 process::{self, Command},
27};
28
29use patois_build::{
30 check::is_damaged,
31 po::{PoDocument, Translation},
32};
33
34mod claude;
35mod markdown;
36mod prompts;
37mod readme;
38mod terms;
39mod update;
40
41#[derive(Debug, Clone, Default)]
43pub struct App {
44 pub name: String,
46 pub description: String,
49 pub proper_nouns: Vec<String>,
52}
53
54#[derive(Debug, Clone)]
56pub struct Project<'a> {
57 pub root: &'a Path,
59 pub po_dir: &'a Path,
61 pub pot: &'a Path,
63 pub readme: Option<&'a Path>,
65 pub app: App,
67}
68
69#[derive(Debug, Clone, Copy, Default)]
71#[allow(clippy::struct_excessive_bools, reason = "these are independent switches")]
72pub struct Options {
73 pub dry_run: bool,
75 pub repair: bool,
78 pub repair_copies: bool,
81}
82
83pub fn translate(project: &Project, options: Options) -> Result<(), Box<dyn Error>> {
93 let client = if options.dry_run {
94 None
95 } else {
96 let api_key = env::var("ANTHROPIC_API_KEY").map_err(|_| "ANTHROPIC_API_KEY environment variable is not set")?;
97 if api_key.trim().is_empty() {
100 return Err("ANTHROPIC_API_KEY is set but empty".into());
101 }
102 let client = claude::ClaudeClient::new(api_key, project.app.clone());
103 println!("translating with {}", client.model());
104 Some(client)
105 };
106 let pot_path = project.root.join(project.pot);
107 let context = translator_comments(&fs::read_to_string(&pot_path)?);
108 let po_dir = project.root.join(project.po_dir);
109 let mut po_files: Vec<PathBuf> = fs::read_dir(&po_dir)?
110 .filter_map(Result::ok)
111 .map(|e| e.path())
112 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("po"))
113 .collect();
114 po_files.sort();
115 let human_maintained = load_human_maintained_locales(project);
116 let mut auto_langs = Vec::new();
117 for po_path in &po_files {
118 let lang = po_path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
119 if human_maintained.contains(lang) {
120 println!("{lang}: human-maintained, skipping");
121 continue;
122 }
123 auto_langs.push(lang.to_string());
124 translate_one(project, po_path, &pot_path, options, client.as_ref(), &context)?;
125 }
126 readme::sync_readmes(project, &auto_langs, client.as_ref(), options.dry_run)
127}
128
129fn translator_comments(pot: &str) -> HashMap<String, String> {
130 PoDocument::parse(pot)
131 .entries
132 .into_iter()
133 .filter_map(|entry| Some((entry.msgid, entry.comment?)))
134 .filter(|(msgid, _)| !msgid.is_empty())
135 .collect()
136}
137
138fn load_human_maintained_locales(project: &Project) -> HashSet<String> {
139 let path = project.root.join(project.po_dir).join("human-maintained-locales.txt");
140 fs::read_to_string(path).map(|content| parse_human_maintained_locales(&content)).unwrap_or_default()
141}
142
143fn parse_human_maintained_locales(content: &str) -> HashSet<String> {
144 content
145 .lines()
146 .map(|line| line.split('#').next().unwrap_or("").trim())
147 .filter(|line| !line.is_empty())
148 .map(str::to_string)
149 .collect()
150}
151
152fn load_style_note(project: &Project, lang: &str) -> Option<String> {
153 let path = project.root.join(project.po_dir).join("style").join(format!("{lang}.md"));
154 parse_style_note(&fs::read_to_string(path).ok()?)
155}
156
157fn parse_style_note(content: &str) -> Option<String> {
158 let note = content.trim();
159 (!note.is_empty()).then(|| note.to_string())
160}
161
162fn style_note_suffix(project: &Project, lang: &str, present: bool) -> String {
163 if present {
164 format!(
165 ", with {}",
166 project.po_dir.join("style").join(format!("{lang}.md")).to_string_lossy().replace('\\', "/")
167 )
168 } else {
169 String::new()
170 }
171}
172
173type Applied = Vec<(usize, Translation)>;
174
175fn translate_singulars(
176 client: &claude::ClaudeClient,
177 target: &claude::Target,
178 candidates: &[(usize, String)],
179 context: &HashMap<String, String>,
180) -> Result<(Applied, usize), Box<dyn Error>> {
181 if candidates.is_empty() {
182 return Ok((Vec::new(), 0));
183 }
184 let phrases: Vec<claude::Phrase> = candidates
185 .iter()
186 .map(|(_, text)| claude::Phrase { source: text.clone(), context: context.get(text).cloned() })
187 .collect();
188 let annotated = phrases.iter().filter(|p| p.context.is_some()).count();
189 let results = client.translate_phrases(&phrases, target)?;
190 let applied = candidates
191 .iter()
192 .map(|(i, _)| *i)
193 .zip(results)
194 .filter_map(|(i, result)| result.map(|text| (i, Translation::Singular(text))))
195 .collect();
196 Ok((applied, annotated))
197}
198
199fn translate_plurals(
200 client: &claude::ClaudeClient,
201 target: &claude::Target,
202 candidates: &[(usize, String, String)],
203 context: &HashMap<String, String>,
204 nplurals: usize,
205 rule: &str,
206) -> Result<Applied, Box<dyn Error>> {
207 if candidates.is_empty() {
208 return Ok(Vec::new());
209 }
210 let phrases: Vec<claude::PluralPhrase> = candidates
211 .iter()
212 .map(|(_, singular, plural)| claude::PluralPhrase {
213 singular: singular.clone(),
214 plural: plural.clone(),
215 context: context.get(singular).cloned(),
217 })
218 .collect();
219 let results = client.translate_plurals(&phrases, target, nplurals, rule)?;
220 Ok(candidates
221 .iter()
222 .map(|(i, _, _)| *i)
223 .zip(results)
224 .filter_map(|(i, result)| result.map(|forms| (i, Translation::Plural(forms))))
225 .collect())
226}
227
228fn plural_forms(content: &str) -> Option<(usize, String)> {
229 let nplurals = PoDocument::parse(content).nplurals()?;
230 let line = content.lines().map(str::trim).find(|l| l.contains("Plural-Forms:"))?;
231 let rule = line.trim_start_matches('"').trim_end_matches("\\n\"").trim().to_string();
232 Some((nplurals, rule))
233}
234
235fn add_damaged_entries(
237 doc: &PoDocument,
238 candidates: &mut Vec<(usize, String)>,
239 plurals: &mut Vec<(usize, String, String)>,
240) -> usize {
241 let already: HashSet<usize> = candidates.iter().map(|(i, _)| *i).collect();
242 let already_plural: HashSet<usize> = plurals.iter().map(|(i, _, _)| *i).collect();
243 let mut count = 0;
244 for (i, entry) in doc.entries.iter().enumerate() {
245 match entry.msgid_plural.as_deref() {
246 Some(plural) => {
248 if !already_plural.contains(&i) && entry.msgstr_plural.iter().any(|form| is_damaged(plural, form)) {
249 plurals.push((i, entry.msgid.clone(), plural.to_string()));
250 count += 1;
251 }
252 }
253 None => {
254 if !already.contains(&i) && is_damaged(&entry.msgid, &entry.msgstr) {
255 candidates.push((i, entry.msgid.clone()));
256 count += 1;
257 }
258 }
259 }
260 }
261 count
262}
263
264fn add_copied_entries(doc: &PoDocument, candidates: &mut Vec<(usize, String)>) -> usize {
270 let already: HashSet<usize> = candidates.iter().map(|(i, _)| *i).collect();
271 let mut sources: HashMap<&str, HashSet<String>> = HashMap::new();
272 for entry in &doc.entries {
273 if entry.msgid_plural.is_none() && !entry.msgid.is_empty() && !entry.msgstr.is_empty() {
274 sources.entry(entry.msgstr.as_str()).or_default().insert(same_meaning_key(&entry.msgid));
275 }
276 }
277 let mut count = 0;
278 for (i, entry) in doc.entries.iter().enumerate() {
279 let shared = sources.get(entry.msgstr.as_str()).is_some_and(|keys| keys.len() > 1);
280 if entry.is_fuzzy && entry.msgid_plural.is_none() && shared && !already.contains(&i) {
281 candidates.push((i, entry.msgid.clone()));
282 count += 1;
283 }
284 }
285 count
286}
287
288fn same_meaning_key(msgid: &str) -> String {
291 msgid
292 .chars()
293 .filter(|c| !matches!(c, '&' | '.' | ':' | '…' | '!' | '?') && !c.is_whitespace())
294 .flat_map(char::to_lowercase)
295 .collect()
296}
297
298fn restore_obsolete(content: String) -> (String, usize) {
305 let mut doc = PoDocument::parse(&content);
306 let mut found: HashMap<String, Vec<(String, bool)>> = HashMap::new();
307 let mut obsolete: Vec<_> = doc.obsolete.iter().filter(|entry| !entry.msgstr.is_empty()).collect();
309 obsolete.sort_by_key(|entry| entry.is_fuzzy);
310 for entry in obsolete {
311 found.entry(same_meaning_key(&entry.msgid)).or_default().push((entry.msgstr.clone(), entry.is_fuzzy));
312 }
313 let restored: Vec<(usize, Translation, bool)> = doc
314 .needs_translation()
315 .filter(|(i, _)| doc.entries[*i].msgstr.is_empty())
316 .filter_map(|(i, msgid)| {
317 let (text, fuzzy) =
318 found.get(&same_meaning_key(msgid))?.iter().find(|(text, _)| !is_damaged(msgid, text))?;
319 Some((i, Translation::Singular(text.clone()), *fuzzy))
320 })
321 .collect();
322 if restored.is_empty() {
323 return (content, 0);
324 }
325 let flagged: Vec<(usize, &Translation, bool)> = restored.iter().map(|(i, t, fuzzy)| (*i, t, *fuzzy)).collect();
326 doc.apply_flagged(&flagged);
327 (doc.render(), restored.len())
328}
329
330fn translate_one(
331 project: &Project,
332 po_path: &Path,
333 pot_path: &Path,
334 options: Options,
335 client: Option<&claude::ClaudeClient>,
336 context: &HashMap<String, String>,
337) -> Result<(), Box<dyn Error>> {
338 let lang = po_path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
339 let style = load_style_note(project, &lang);
340 let original = fs::read_to_string(po_path)?;
341 let tmp = env::temp_dir().join(format!("patois-translate-{lang}-{}.po", process::id()));
343 fs::write(&tmp, &original)?;
344 let merged_ok = Command::new("msgmerge")
347 .args(["--update", "--backup=none", "--no-wrap", "--no-fuzzy-matching"])
348 .arg(&tmp)
349 .arg(pot_path)
350 .status()
351 .is_ok_and(|s| s.success());
352 if !merged_ok {
353 eprintln!("warning: msgmerge failed for {lang}, leaving it untouched this run");
354 let _ = fs::remove_file(&tmp);
355 return Ok(());
356 }
357 let merged = fs::read_to_string(&tmp)?;
358 let _ = fs::remove_file(&tmp);
359 let (merged, restored) = restore_obsolete(merged);
360 if restored > 0 {
361 let verb = if client.is_some() { "restored" } else { "would restore" };
362 println!("{lang}: {verb} {restored} entries from obsolete ones with the same words");
363 }
364 let mut doc = PoDocument::parse(&merged);
365 let mut candidates: Vec<(usize, String)> = doc.needs_translation().map(|(i, m)| (i, m.to_string())).collect();
366 let mut plurals: Vec<(usize, String, String)> =
367 doc.needs_plural_translation().map(|(i, s, p)| (i, s.to_string(), p.to_string())).collect();
368 let mut repaired = if options.repair { add_damaged_entries(&doc, &mut candidates, &mut plurals) } else { 0 };
369 if options.repair_copies {
370 repaired += add_copied_entries(&doc, &mut candidates);
371 }
372 let total = candidates.len() + plurals.len();
373 let Some(client) = client else {
374 let style_note = style_note_suffix(project, &lang, style.is_some());
375 if total == 0 {
376 println!("{lang}: fully translated, nothing to do{style_note}");
377 } else {
378 let plural_note = if plurals.is_empty() { String::new() } else { format!(", {} plural", plurals.len()) };
379 let repair_note = if repaired > 0 { format!(" ({repaired} damaged)") } else { String::new() };
380 println!("{lang}: {total} entries would be translated{plural_note}{repair_note}{style_note}");
381 }
382 return Ok(());
383 };
384 let final_content = if total == 0 {
385 merged
386 } else {
387 let language = patois::language_name(&lang);
388 let target = claude::Target { language, style: style.as_deref() };
389 let (mut applied, annotated) = translate_singulars(client, &target, &candidates, context)?;
390 let plural_done = match plural_forms(&merged) {
391 None if !plurals.is_empty() => {
394 eprintln!("warning: {lang} has no usable Plural-Forms header, leaving its plural entries");
395 0
396 }
397 None => 0,
398 Some((nplurals, rule)) => {
399 let translated = translate_plurals(client, &target, &plurals, context, nplurals, &rule)?;
400 let done = translated.len();
401 applied.extend(translated);
402 done
403 }
404 };
405 let singular_done = applied.len() - plural_done;
406 let skipped = total - applied.len();
407 doc.apply(&applied);
408 print!("{lang} ({language}): translated {singular_done} entries");
409 if plural_done > 0 {
410 print!(", {plural_done} plural");
411 }
412 if repaired > 0 {
413 print!(", {repaired} of them repaired");
414 }
415 if annotated > 0 {
416 print!(", {annotated} with translator notes");
417 }
418 if skipped > 0 {
419 print!(", skipped {skipped} (failed a placeholder, accelerator, or shortcut check, will retry next run)");
420 }
421 println!();
422 doc.render()
423 };
424 if content_without_volatile_headers(&original) != content_without_volatile_headers(&final_content) {
425 fs::write(po_path, final_content)?;
426 }
427 Ok(())
428}
429
430fn content_without_volatile_headers(content: &str) -> String {
432 content
433 .lines()
434 .filter(|line| {
435 let t = line.trim();
436 !(t.starts_with("\"POT-Creation-Date:") || t.starts_with("\"PO-Revision-Date:"))
437 })
438 .collect::<Vec<_>>()
439 .join("\n")
440}
441
442#[cfg(test)]
443mod tests {
444 use std::fmt::Write as _;
445
446 use super::*;
447
448 #[test]
449 fn timestamp_only_changes_are_ignored() {
450 let a = "msgid \"\"\nmsgstr \"\"\n\"POT-Creation-Date: 2026-01-01 00:00+0000\\n\"\n\"PO-Revision-Date: 2026-01-01 00:00+0000\\n\"\n\"Language: de\\n\"\n";
451 let b = "msgid \"\"\nmsgstr \"\"\n\"POT-Creation-Date: 2026-06-01 12:00+0000\\n\"\n\"PO-Revision-Date: 2026-06-01 12:00+0000\\n\"\n\"Language: de\\n\"\n";
452 assert_eq!(content_without_volatile_headers(a), content_without_volatile_headers(b));
453 }
454
455 #[test]
456 fn real_content_changes_are_detected() {
457 let a = "msgid \"Cancel\"\nmsgstr \"\"\n";
458 let b = "msgid \"Cancel\"\nmsgstr \"Abbrechen\"\n";
459 assert_ne!(content_without_volatile_headers(a), content_without_volatile_headers(b));
460 }
461
462 #[test]
463 fn human_maintained_locales_ignores_comments_and_blank_lines() {
464 let content = "# comment\n\nfi # Jani Kinnunen\n\n sr \n";
465 assert_eq!(parse_human_maintained_locales(content), HashSet::from(["fi".to_string(), "sr".to_string()]));
466 assert!(parse_human_maintained_locales("# nothing here yet\n").is_empty());
467 }
468
469 #[test]
470 fn a_style_note_is_trimmed() {
471 let content = "\n\n# Dutch\n\nAddress the reader as \"je\".\n\n\n";
472 assert_eq!(parse_style_note(content), Some("# Dutch\n\nAddress the reader as \"je\".".to_string()));
473 assert_eq!(parse_style_note(" \n\n"), None);
474 }
475
476 #[test]
477 fn the_dry_run_line_names_the_style_note_it_would_use() {
478 let project = Project {
479 root: Path::new("."),
480 po_dir: Path::new("po"),
481 pot: Path::new("po/app.pot"),
482 readme: None,
483 app: App::default(),
484 };
485 assert_eq!(style_note_suffix(&project, "nl", true), ", with po/style/nl.md");
486 assert_eq!(style_note_suffix(&project, "nl", false), "");
487 }
488
489 #[test]
490 fn a_translator_note_attaches_to_the_msgid_below_it_only() {
491 let pot = "#. TRANSLATORS: about Ready\nmsgid \"Ready\"\nmsgstr \"\"\n\nmsgid \"Cancel\"\nmsgstr \"\"\n";
492 let notes = translator_comments(pot);
493 assert_eq!(notes.get("Ready").map(String::as_str), Some("about Ready"));
494 assert!(!notes.contains_key("Cancel"));
495 }
496
497 #[test]
498 fn keys_are_unescaped_and_joined_across_continuation_lines() {
499 let pot =
500 "#. TRANSLATORS: a two-line prompt\nmsgid \"\"\n\"No parser for {}.\\n\"\n\"Open it how?\"\nmsgstr \"\"\n";
501 let notes = translator_comments(pot);
502 assert_eq!(notes.get("No parser for {}.\nOpen it how?").map(String::as_str), Some("a two-line prompt"));
503 }
504
505 #[test]
506 fn the_header_entry_never_takes_a_note() {
507 let pot = "#. TRANSLATORS: stray\nmsgid \"\"\nmsgstr \"\"\n\nmsgid \"Ready\"\nmsgstr \"\"\n";
508 assert!(translator_comments(pot).is_empty());
509 }
510
511 #[test]
512 fn plural_forms_reads_the_count_and_the_rule() {
513 let po = "msgid \"\"\nmsgstr \"\"\n\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : 1);\\n\"\n";
514 let (nplurals, rule) = plural_forms(po).unwrap();
515 assert_eq!(nplurals, 3);
516 assert!(rule.starts_with("Plural-Forms: nplurals=3;"), "got: {rule}");
517 assert!(!rule.ends_with("\\n\""), "got: {rule}");
518 assert!(plural_forms("msgid \"\"\nmsgstr \"\"\n").is_none());
519 }
520
521 fn catalog(entries: &[(&str, &str, bool)]) -> PoDocument {
522 let mut text = String::from("msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n");
523 for (msgid, msgstr, fuzzy) in entries {
524 text.push('\n');
525 if *fuzzy {
526 text.push_str("#, fuzzy\n");
527 }
528 let _ = writeln!(text, "msgid \"{msgid}\"\nmsgstr \"{msgstr}\"");
529 }
530 PoDocument::parse(&text)
531 }
532
533 fn copied(doc: &PoDocument) -> Vec<String> {
534 let mut candidates = Vec::new();
535 add_copied_entries(doc, &mut candidates);
536 candidates.into_iter().map(|(_, msgid)| msgid).collect()
537 }
538
539 #[test]
540 fn a_translation_shared_by_strings_that_say_different_things_is_a_copy() {
541 let doc = catalog(&[("1 minute", "1 minuto", true), ("10 minutes", "1 minuto", true)]);
542 assert_eq!(vec!["1 minute", "10 minutes"], copied(&doc));
543 }
544
545 #[test]
546 fn the_same_words_sharing_a_translation_are_not_a_copy() {
547 let doc = catalog(&[
548 ("&Close", "Cerrar", true),
549 ("Close", "Cerrar", true),
550 ("Open...", "Abrir", true),
551 ("Open", "Abrir", true),
552 ]);
553 assert_eq!(copied(&doc), [] as [String; 0]);
554 }
555
556 #[test]
557 fn a_string_whose_accelerator_moved_keeps_its_translation() {
558 let po = "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n\n#. TRANSLATORS: menu item\nmsgid \"&Toggle Bookmark\"\nmsgstr \"\"\n\nmsgid \"Go to li&ne...\"\nmsgstr \"\"\n\nmsgid \"&View\"\nmsgstr \"\"\n\n#~ msgid \"Toggle &Bookmark\"\n#~ msgstr \"&Lesezeichen umschalten\"\n\n#, fuzzy\n#~ msgid \"Go to &line...\"\n#~ msgstr \"Gehe zu &Zeile...\"\n";
559 let (restored, count) = restore_obsolete(po.to_string());
560 assert_eq!(count, 2);
561 assert!(
562 restored.contains(
563 "#. TRANSLATORS: menu item\nmsgid \"&Toggle Bookmark\"\nmsgstr \"&Lesezeichen umschalten\"\n"
564 ),
565 "{restored}"
566 );
567 assert!(restored.contains("#, fuzzy\nmsgid \"Go to li&ne...\"\nmsgstr \"Gehe zu &Zeile...\"\n"), "{restored}");
568 assert!(restored.contains("msgid \"&View\"\nmsgstr \"\"\n"), "{restored}");
569 }
570
571 #[test]
572 fn a_person_s_obsolete_translation_wins_over_a_machine_s() {
573 let po = "msgid \"&Links\"\nmsgstr \"\"\n\n#, fuzzy\n#~ msgid \"Lin&ks\"\n#~ msgstr \"&Verknüpfungen\"\n\n#~ msgid \"Links\"\n#~ msgstr \"Links\"\n\n#~ msgid \"L&inks\"\n#~ msgstr \"&Links\"\n";
574 let (restored, _) = restore_obsolete(po.to_string());
575 assert!(restored.starts_with("msgid \"&Links\"\nmsgstr \"&Links\"\n"), "{restored}");
576 }
577
578 #[test]
579 fn an_obsolete_translation_that_fails_the_check_is_not_restored() {
580 let po = "msgid \"&Open %s\"\nmsgstr \"\"\n\n#~ msgid \"Open %s\"\n#~ msgstr \"%s öffnen\"\n";
581 assert_eq!(restore_obsolete(po.to_string()).1, 0);
582 }
583
584 #[test]
585 fn a_string_with_different_words_is_not_restored() {
586 let po = "msgid \"Export\"\nmsgstr \"\"\n\n#~ msgid \"I&mport/Export\"\n#~ msgstr \"I&mport/Export\"\n";
587 assert_eq!(restore_obsolete(po.to_string()).1, 0);
588 }
589
590 #[test]
591 fn a_human_translation_is_never_selected() {
592 let doc = catalog(&[("Match Case", "Mayusculas", false), ("Batch OCR", "Mayusculas", true)]);
593 assert_eq!(vec!["Batch OCR"], copied(&doc));
594 }
595}