| 1 | //! UI language (Tim t1004u): English (default) or Dutch. |
| 2 | //! |
| 3 | //! Source strings are Dutch; [`t`] translates any displayed text at the UI |
| 4 | //! boundary through [`crate::i18n_table::TABLE`]. Entries may be format |
| 5 | //! templates whose `{…}` holes are matched and translated recursively. |
| 6 | use std::collections::HashMap; |
| 7 | use std::path::Path; |
| 8 | use std::sync::atomic::{AtomicU8, Ordering}; |
| 9 | use std::sync::{Mutex, OnceLock}; |
| 10 | |
| 11 | use crate::i18n_table::TABLE; |
| 12 | |
| 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 14 | pub enum Lang { |
| 15 | #[default] |
| 16 | En, |
| 17 | Nl, |
| 18 | } |
| 19 | |
| 20 | impl Lang { |
| 21 | pub const ALL: [Lang; 2] = [Lang::En, Lang::Nl]; |
| 22 | pub fn code(self) -> &'static str { |
| 23 | match self { |
| 24 | Lang::En => "en", |
| 25 | Lang::Nl => "nl", |
| 26 | } |
| 27 | } |
| 28 | pub fn from_code(code: &str) -> Option<Self> { |
| 29 | Self::ALL.into_iter().find(|l| l.code() == code) |
| 30 | } |
| 31 | /// Name of the language in that language. |
| 32 | pub fn native_name(self) -> &'static str { |
| 33 | match self { |
| 34 | Lang::En => "English", |
| 35 | Lang::Nl => "Nederlands", |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | static LANG: AtomicU8 = AtomicU8::new(0); |
| 41 | |
| 42 | pub fn lang() -> Lang { |
| 43 | if LANG.load(Ordering::Relaxed) == 1 { |
| 44 | Lang::Nl |
| 45 | } else { |
| 46 | Lang::En |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | pub fn set_lang(lang: Lang) { |
| 51 | LANG.store(u8::from(lang == Lang::Nl), Ordering::Relaxed); |
| 52 | } |
| 53 | |
| 54 | /// Display text in the current language. |
| 55 | pub fn t(text: impl AsRef<str>) -> String { |
| 56 | tr(lang(), text.as_ref()) |
| 57 | } |
| 58 | |
| 59 | /// Display text in `lang`. |
| 60 | pub fn tr(lang: Lang, text: &str) -> String { |
| 61 | match lang { |
| 62 | Lang::Nl => text.to_string(), |
| 63 | Lang::En => to_english(text), |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | struct Template { |
| 68 | /// Literal parts; holes sit between consecutive parts. |
| 69 | parts: Vec<String>, |
| 70 | holes: Vec<String>, |
| 71 | english: &'static str, |
| 72 | weight: usize, |
| 73 | } |
| 74 | |
| 75 | struct Tables { |
| 76 | exact: HashMap<&'static str, &'static str>, |
| 77 | templates: Vec<Template>, |
| 78 | } |
| 79 | |
| 80 | /// Split a format string into literal parts and `{…}` hole tokens. |
| 81 | fn split_template(text: &str) -> (Vec<String>, Vec<String>) { |
| 82 | let mut parts = vec![String::new()]; |
| 83 | let mut holes = Vec::new(); |
| 84 | let mut chars = text.chars().peekable(); |
| 85 | while let Some(c) = chars.next() { |
| 86 | match c { |
| 87 | '{' if chars.peek() == Some(&'{') => { |
| 88 | chars.next(); |
| 89 | parts.last_mut().unwrap().push('{'); |
| 90 | } |
| 91 | '}' if chars.peek() == Some(&'}') => { |
| 92 | chars.next(); |
| 93 | parts.last_mut().unwrap().push('}'); |
| 94 | } |
| 95 | '{' => { |
| 96 | let mut token = String::from("{"); |
| 97 | for d in chars.by_ref() { |
| 98 | token.push(d); |
| 99 | if d == '}' { |
| 100 | break; |
| 101 | } |
| 102 | } |
| 103 | holes.push(token); |
| 104 | parts.push(String::new()); |
| 105 | } |
| 106 | _ => parts.last_mut().unwrap().push(c), |
| 107 | } |
| 108 | } |
| 109 | (parts, holes) |
| 110 | } |
| 111 | |
| 112 | fn tables() -> &'static Tables { |
| 113 | static T: OnceLock<Tables> = OnceLock::new(); |
| 114 | T.get_or_init(|| { |
| 115 | let mut exact = HashMap::new(); |
| 116 | let mut templates = Vec::new(); |
| 117 | for (nl, en) in TABLE { |
| 118 | let (parts, holes) = split_template(nl); |
| 119 | if holes.is_empty() { |
| 120 | exact.insert(*nl, *en); |
| 121 | // `{{`/`}}`-free literal; also index its unescaped form. |
| 122 | continue; |
| 123 | } |
| 124 | let weight: usize = parts.iter().map(|p| p.chars().count()).sum(); |
| 125 | if weight == 0 { |
| 126 | continue; |
| 127 | } |
| 128 | templates.push(Template { |
| 129 | parts, |
| 130 | holes, |
| 131 | english: en, |
| 132 | weight, |
| 133 | }); |
| 134 | } |
| 135 | templates.sort_by_key(|t| std::cmp::Reverse(t.weight)); |
| 136 | Tables { exact, templates } |
| 137 | }) |
| 138 | } |
| 139 | |
| 140 | /// Match `msg` against literal `parts` (holes in between, non-greedy, backtracking). |
| 141 | fn match_parts<'m>(msg: &'m str, parts: &[String]) -> Option<Vec<&'m str>> { |
| 142 | let rest = msg.strip_prefix(parts[0].as_str())?; |
| 143 | if parts.len() == 1 { |
| 144 | return rest.is_empty().then(Vec::new); |
| 145 | } |
| 146 | let next = &parts[1]; |
| 147 | let mut start = 0; |
| 148 | loop { |
| 149 | let found = if next.is_empty() { |
| 150 | if parts.len() == 2 { |
| 151 | Some(rest.len()) |
| 152 | } else { |
| 153 | // Empty separator between two holes: not supported (ambiguous). |
| 154 | return None; |
| 155 | } |
| 156 | } else { |
| 157 | rest[start..].find(next.as_str()).map(|i| start + i) |
| 158 | }; |
| 159 | let at = found?; |
| 160 | let hole = &rest[..at]; |
| 161 | let mut tail_parts = parts[1..].to_vec(); |
| 162 | tail_parts[0] = next.clone(); |
| 163 | if let Some(mut more) = match_parts(&rest[at..], &tail_parts) { |
| 164 | let mut out = vec![hole]; |
| 165 | out.append(&mut more); |
| 166 | return Some(out); |
| 167 | } |
| 168 | if next.is_empty() { |
| 169 | return None; |
| 170 | } |
| 171 | // Advance to the next occurrence (keep char boundaries). |
| 172 | start = at + next.chars().next().map_or(1, char::len_utf8); |
| 173 | if start > rest.len() { |
| 174 | return None; |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | fn to_english(text: &str) -> String { |
| 180 | static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new(); |
| 181 | let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); |
| 182 | if let Some(hit) = cache.lock().ok().and_then(|c| c.get(text).cloned()) { |
| 183 | return hit; |
| 184 | } |
| 185 | let out = translate(text, 0); |
| 186 | if let Ok(mut c) = cache.lock() { |
| 187 | if c.len() > 4096 { |
| 188 | c.clear(); |
| 189 | } |
| 190 | c.insert(text.to_string(), out.clone()); |
| 191 | } |
| 192 | out |
| 193 | } |
| 194 | |
| 195 | fn translate(text: &str, depth: u32) -> String { |
| 196 | let tables = tables(); |
| 197 | if let Some(en) = tables.exact.get(text) { |
| 198 | return (*en).to_string(); |
| 199 | } |
| 200 | if depth > 6 || text.is_empty() { |
| 201 | return text.to_string(); |
| 202 | } |
| 203 | // Multi-line text: translate per line. |
| 204 | if text.contains('\n') { |
| 205 | return text |
| 206 | .split('\n') |
| 207 | .map(|line| translate(line, depth + 1)) |
| 208 | .collect::<Vec<_>>() |
| 209 | .join("\n"); |
| 210 | } |
| 211 | for template in &tables.templates { |
| 212 | let Some(captures) = match_parts(text, &template.parts) else { |
| 213 | continue; |
| 214 | }; |
| 215 | let translated: Vec<String> = captures.iter().map(|c| translate(c, depth + 1)).collect(); |
| 216 | let (en_parts, en_holes) = split_template(template.english); |
| 217 | let mut used = vec![false; template.holes.len()]; |
| 218 | let mut out = en_parts[0].clone(); |
| 219 | for (k, token) in en_holes.iter().enumerate() { |
| 220 | let pick = (0..template.holes.len()) |
| 221 | .find(|&i| !used[i] && template.holes[i] == *token) |
| 222 | .or_else(|| (0..template.holes.len()).find(|&i| !used[i])); |
| 223 | if let Some(i) = pick { |
| 224 | used[i] = true; |
| 225 | out.push_str(&translated[i]); |
| 226 | } |
| 227 | out.push_str(&en_parts[k + 1]); |
| 228 | } |
| 229 | return out; |
| 230 | } |
| 231 | text.to_string() |
| 232 | } |
| 233 | |
| 234 | /// Settings file in the user config dir. |
| 235 | pub const SETTINGS_FILE: &str = "settings.json"; |
| 236 | |
| 237 | /// Read the stored language (English when missing or unreadable). |
| 238 | pub fn load_language(dir: &Path) -> Lang { |
| 239 | std::fs::read(dir.join(SETTINGS_FILE)) |
| 240 | .ok() |
| 241 | .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok()) |
| 242 | .and_then(|v| v.get("language")?.as_str().and_then(Lang::from_code)) |
| 243 | .unwrap_or_default() |
| 244 | } |
| 245 | |
| 246 | /// Store the language atomically. |
| 247 | pub fn save_language(dir: &Path, lang: Lang) -> Result<(), String> { |
| 248 | std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; |
| 249 | let bytes = serde_json::to_vec_pretty(&serde_json::json!({ "language": lang.code() })) |
| 250 | .map_err(|e| e.to_string())?; |
| 251 | let temp = dir.join(format!("{SETTINGS_FILE}.tmp-{}", std::process::id())); |
| 252 | std::fs::write(&temp, bytes).map_err(|e| e.to_string())?; |
| 253 | std::fs::rename(&temp, dir.join(SETTINGS_FILE)).map_err(|e| e.to_string()) |
| 254 | } |
| 255 | |
| 256 | #[cfg(test)] |
| 257 | mod tests { |
| 258 | use super::*; |
| 259 | |
| 260 | fn holes(s: &str) -> Vec<String> { |
| 261 | let mut h = split_template(s).1; |
| 262 | h.sort(); |
| 263 | h |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn every_entry_has_both_languages_with_the_same_placeholders() { |
| 268 | let mut seen = std::collections::HashSet::new(); |
| 269 | for (nl, en) in TABLE { |
| 270 | assert!( |
| 271 | !nl.is_empty() && !en.trim().is_empty(), |
| 272 | "empty entry {nl:?}" |
| 273 | ); |
| 274 | assert!(seen.insert(*nl), "duplicate key {nl:?}"); |
| 275 | assert_eq!(holes(nl), holes(en), "placeholders differ: {nl:?} / {en:?}"); |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn exact_templates_nested_and_runtime_switch() { |
| 281 | assert_eq!(tr(Lang::En, "Project laden"), "Load project"); |
| 282 | assert_eq!(tr(Lang::Nl, "Project laden"), "Project laden"); |
| 283 | assert_eq!( |
| 284 | tr(Lang::En, "Oude onvoltooide render verwijderd: inprogress-1"), |
| 285 | "Old unfinished render removed: inprogress-1" |
| 286 | ); |
| 287 | // Nested: outer template with a translated inner message. |
| 288 | assert_eq!( |
| 289 | tr(Lang::En, "atlas: raw files/x.png: PNG groter dan limiet"), |
| 290 | "atlas: raw files/x.png: PNG larger than the limit" |
| 291 | ); |
| 292 | assert_eq!( |
| 293 | tr( |
| 294 | Lang::En, |
| 295 | "voltooide render faalt controle: .ppda: ongeldige fps" |
| 296 | ), |
| 297 | "completed render fails verification: .ppda: invalid fps" |
| 298 | ); |
| 299 | // Unknown text passes through. |
| 300 | assert_eq!(tr(Lang::En, "xyz 123"), "xyz 123"); |
| 301 | set_lang(Lang::Nl); |
| 302 | assert_eq!(t("Opslaan"), "Opslaan"); |
| 303 | set_lang(Lang::En); |
| 304 | assert_eq!(t("Opslaan"), "Save"); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn settings_round_trip_and_default_english() { |
| 309 | let dir = std::env::temp_dir().join(format!("paperdoll-i18n-{}", std::process::id())); |
| 310 | let _ = std::fs::remove_dir_all(&dir); |
| 311 | assert_eq!(load_language(&dir), Lang::En); |
| 312 | save_language(&dir, Lang::Nl).unwrap(); |
| 313 | assert_eq!(load_language(&dir), Lang::Nl); |
| 314 | std::fs::write(dir.join(SETTINGS_FILE), b"garbage").unwrap(); |
| 315 | assert_eq!(load_language(&dir), Lang::En); |
| 316 | let _ = std::fs::remove_dir_all(dir); |
| 317 | } |
| 318 | |
| 319 | /// Minimal Rust string-literal scanner for the coverage test. |
| 320 | fn literals(code: &str) -> Vec<String> { |
| 321 | let b = code.as_bytes(); |
| 322 | let mut out = Vec::new(); |
| 323 | let mut i = 0; |
| 324 | while i < b.len() { |
| 325 | if code[i..].starts_with("//") { |
| 326 | i = code[i..].find('\n').map_or(b.len(), |j| i + j); |
| 327 | continue; |
| 328 | } |
| 329 | if b[i] == b'\'' { |
| 330 | // char literal like '"' or '\\' |
| 331 | let rest = &code[i..]; |
| 332 | let len = if rest.starts_with("'\\") { 4 } else { 3 }; |
| 333 | if rest.len() >= len && rest.as_bytes()[len - 1] == b'\'' { |
| 334 | i += len; |
| 335 | continue; |
| 336 | } |
| 337 | } |
| 338 | if b[i] == b'r' |
| 339 | && (i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_')) |
| 340 | && code[i + 1..].starts_with(['"', '#']) |
| 341 | { |
| 342 | let hashes = code[i + 1..].chars().take_while(|c| *c == '#').count(); |
| 343 | if code[i + 1 + hashes..].starts_with('"') { |
| 344 | let end = format!("\"{}", "#".repeat(hashes)); |
| 345 | let start = i + 2 + hashes; |
| 346 | let j = code[start..].find(&end).map_or(b.len(), |j| start + j); |
| 347 | i = j + end.len(); |
| 348 | continue; |
| 349 | } |
| 350 | } |
| 351 | if b[i] == b'"' { |
| 352 | let byte_lit = i > 0 && b[i - 1] == b'b'; |
| 353 | let mut j = i + 1; |
| 354 | let mut s = String::new(); |
| 355 | while j < b.len() && b[j] != b'"' { |
| 356 | if b[j] == b'\\' { |
| 357 | s.push_str(&code[j..j + 2]); |
| 358 | j += 2; |
| 359 | } else { |
| 360 | let c = code[j..].chars().next().unwrap(); |
| 361 | s.push(c); |
| 362 | j += c.len_utf8(); |
| 363 | } |
| 364 | } |
| 365 | if !byte_lit { |
| 366 | out.push(s); |
| 367 | } |
| 368 | i = j + 1; |
| 369 | continue; |
| 370 | } |
| 371 | i += code[i..].chars().next().map_or(1, char::len_utf8); |
| 372 | } |
| 373 | out |
| 374 | } |
| 375 | |
| 376 | fn unescape(s: &str) -> String { |
| 377 | let mut out = String::new(); |
| 378 | let mut chars = s.chars().peekable(); |
| 379 | while let Some(c) = chars.next() { |
| 380 | if c != '\\' { |
| 381 | out.push(c); |
| 382 | continue; |
| 383 | } |
| 384 | match chars.next() { |
| 385 | Some('n') => out.push('\n'), |
| 386 | Some('t') => out.push('\t'), |
| 387 | Some('\n') => { |
| 388 | while chars.peek().is_some_and(|c| c.is_whitespace()) { |
| 389 | chars.next(); |
| 390 | } |
| 391 | } |
| 392 | Some(other) => out.push(other), |
| 393 | None => {} |
| 394 | } |
| 395 | } |
| 396 | out |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn every_user_visible_source_string_has_a_translation() { |
| 401 | let keys: std::collections::HashSet<&str> = TABLE.iter().map(|(nl, _)| *nl).collect(); |
| 402 | let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); |
| 403 | let mut missing = Vec::new(); |
| 404 | for entry in std::fs::read_dir(dir).unwrap() { |
| 405 | let path = entry.unwrap().path(); |
| 406 | let name = path.file_name().unwrap().to_string_lossy().to_string(); |
| 407 | if !name.ends_with(".rs") |
| 408 | || name == "main.rs" |
| 409 | || name == "i18n_table.rs" |
| 410 | || name == "i18n.rs" |
| 411 | { |
| 412 | continue; |
| 413 | } |
| 414 | let code = std::fs::read_to_string(&path).unwrap(); |
| 415 | let code = code |
| 416 | .find("\n#[cfg(test)]\nmod tests") |
| 417 | .map_or(code.as_str(), |i| &code[..i]); |
| 418 | for lit in literals(code) { |
| 419 | let text = unescape(&lit); |
| 420 | let letters = text.chars().filter(|c| c.is_alphabetic()).count() >= 3; |
| 421 | let wordy = text.contains(' ') || text.contains(['é', 'ë', 'ï']); |
| 422 | if letters && wordy && !keys.contains(text.as_str()) { |
| 423 | missing.push(format!("{name}: {text:?}")); |
| 424 | } |
| 425 | } |
| 426 | } |
| 427 | assert!(missing.is_empty(), "untranslated:\n{}", missing.join("\n")); |
| 428 | } |
| 429 | } |