Skip to main content

patois_translate/
lib.rs

1//! Machine translation of gettext catalogs and Markdown readmes through the Claude API, for apps
2//! that use [patois](https://docs.rs/patois).
3//!
4//! [`translate`] syncs each `.po` file with the `.pot` through `msgmerge`, and then asks Claude to
5//! fill the blank entries. Each request includes the `TRANSLATORS:` note of each string, and each
6//! answer goes through [`patois_build::check`], so a translation that loses a placeholder, an
7//! accelerator, or a shortcut is never written. The function can also translate a readme into
8//! `readme-<lang>.md` files, one section at a time.
9//!
10//! Call it from an `xtask` after you regenerate the `.pot`. It needs `msgmerge` from gettext and,
11//! unless it's a dry run, an `ANTHROPIC_API_KEY`. To use a model other than the default, set
12//! `PATOIS_TRANSLATE_MODEL`.
13//!
14//! Two optional files in the `.po` directory change what it does:
15//!
16//! - `human-maintained-locales.txt`: locales that people translate, one per line. The function
17//!   skips them.
18//! - `style/<lang>.md`: conventions for one language, which are added to its instructions.
19
20use 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/// The app that the model translates for.
42#[derive(Debug, Clone, Default)]
43pub struct App {
44	/// The name of the app, such as `Paperback`.
45	pub name: String,
46	/// A description that completes "the user interface of Paperback, …", such as `a desktop ebook
47	/// and document reader used heavily with screen readers`.
48	pub description: String,
49	/// Names to leave untranslated besides the app's own, such as `EPUB` and `PDF`. File
50	/// extensions and URLs are always left alone.
51	pub proper_nouns: Vec<String>,
52}
53
54/// Where a project keeps its translations. The paths other than `root` are relative to `root`.
55#[derive(Debug, Clone)]
56pub struct Project<'a> {
57	/// The root directory of the project.
58	pub root: &'a Path,
59	/// The directory with the `.po` files, such as `po`.
60	pub po_dir: &'a Path,
61	/// The template, such as `po/myapp.pot`.
62	pub pot: &'a Path,
63	/// The English readme to translate into `<stem>-<lang>.md` next to it, such as `doc/readme.md`.
64	pub readme: Option<&'a Path>,
65	/// The app that the strings belong to.
66	pub app: App,
67}
68
69/// Options for [`translate`].
70#[derive(Debug, Clone, Copy, Default)]
71#[allow(clippy::struct_excessive_bools, reason = "these are independent switches")]
72pub struct Options {
73	/// Reports what would be translated, without calling the API or changing a file.
74	pub dry_run: bool,
75	/// Also translates again the entries whose translation lost or added a placeholder, an
76	/// accelerator, or a shortcut.
77	pub repair: bool,
78	/// Also translates again the machine translations that are shared with a string that says
79	/// something different. `msgmerge` can copy these in. Use this option once, not on every run.
80	pub repair_copies: bool,
81}
82
83/// Translates every `.po` file of `project` that isn't human-maintained, then its readme.
84///
85/// A file is written only if its content changed, not if `msgmerge` updated only the timestamps in
86/// its header.
87///
88/// # Errors
89///
90/// Returns an error if `ANTHROPIC_API_KEY` isn't set outside a dry run, if a file can't be read or
91/// written, if an API request fails, or if a readme section comes back incomplete.
92pub 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		// A secret that exists but is empty passes `env::var`, then fails at the API with an
98		// error that reads like a wrong key rather than a missing one.
99		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			// The note is above the singular, which is the msgid.
216			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
235/// Adds the entries whose translation is damaged to the candidates, and returns how many.
236fn 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			// The forms are translated together, so one damaged form means all of them.
247			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
264/// Adds the fuzzy entries whose translation is shared with an entry whose English says something
265/// different, and returns how many.
266///
267/// Only fuzzy entries qualify, because a person wrote the others, and a shared translation isn't
268/// enough reason to replace their work.
269fn 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
288/// Returns the words of a string, without accelerators, case, spaces, and end punctuation. Two
289/// strings with the same key can share a translation.
290fn 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
298/// Fills each blank entry from an obsolete entry with the same words, and returns the new file and
299/// the number of entries filled.
300///
301/// A string often changes only where its accelerator sits. `--no-fuzzy-matching` retires the old
302/// entry and leaves the new one blank, which would discard a translation that a person wrote. The
303/// restored translation keeps its own accelerator, which its translator chose for that language.
304fn 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	// A person's translation wins over a machine's.
308	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	// msgmerge works on a copy, so that the real file changes only if its content does.
342	let tmp = env::temp_dir().join(format!("patois-translate-{lang}-{}.po", process::id()));
343	fs::write(&tmp, &original)?;
344	// Without `--no-fuzzy-matching`, msgmerge gives a new string the translation of the most
345	// similar old one, and fuzzy entries reach the app, so users would see that guess.
346	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			// Without the header there's no way to know how many forms to ask for, and a wrong
392			// count makes a file that gettext accepts without a warning.
393			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
430// `msgmerge` changes these two header lines on every run.
431fn 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}