//! Data-driven translations. The language list (`i18n/languages.json`) and all catalogs //! are data files: the Core reads them from the bundled UI assets (core, diagnostics, //! license) or from a module directory (`ui/i18n/.json`) and injects them into //! the windows. Native texts (dialog titles, OS prompts) use the same core catalogs. use serde_json::{json, Map, Value}; use std::collections::BTreeMap; use std::path::Path; #[derive(Clone, Debug)] pub struct LanguageInfo { pub code: String, pub raw: Value, } #[derive(Clone, Debug, Default)] pub struct I18n { pub manifest: Value, pub languages: Vec, pub default_language: String, pub fallback_language: String, pub core: BTreeMap>, pub diagnostics: BTreeMap>, pub license: BTreeMap>, } fn valid_code(code: &str) -> bool { let mut parts = code.splitn(2, '-'); let base = parts.next().unwrap_or(""); (2..=3).contains(&base.len()) && base.chars().all(|c| c.is_ascii_lowercase()) && parts.next().map(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or(true) } impl I18n { /// `read` returns the bytes of a bundled UI asset path such as `i18n/core/cs.json`. pub fn load(read: impl Fn(&str) -> Option>) -> Self { let parse = |path: &str| -> Option { read(path).and_then(|bytes| serde_json::from_slice(&bytes).ok()) }; let manifest = parse("i18n/languages.json").unwrap_or_else(|| json!({ "defaultLanguage": "cs", "fallbackLanguage": "en", "languages": [] })); let mut i18n = I18n { manifest: manifest.clone(), ..Default::default() }; for entry in manifest.get("languages").and_then(Value::as_array).cloned().unwrap_or_default() { let Some(code) = entry.get("code").and_then(Value::as_str).map(str::to_string) else { continue }; if !valid_code(&code) { continue; } // A regional variant (`"parent": "de"`) may ship without its own catalog and inherits. let parent = entry.get("parent").and_then(Value::as_str).filter(|p| i18n.core.contains_key(*p)); let core = match parse(&format!("i18n/core/{code}.json")) { Some(Value::Object(core)) => core, _ if parent.is_some() => Map::new(), _ => continue, }; i18n.core.insert(code.clone(), core); if let Some(Value::Object(diag)) = parse(&format!("i18n/diagnostics/{code}.json")) { i18n.diagnostics.insert(code.clone(), diag); } if let Some(Value::Object(license)) = parse(&format!("i18n/license/{code}.json")) { i18n.license.insert(code.clone(), license); } i18n.languages.push(LanguageInfo { code, raw: entry }); } let pick = |key: &str, fallback: &str| -> String { manifest.get(key).and_then(Value::as_str).filter(|c| i18n.core.contains_key(*c)).unwrap_or(fallback).to_string() }; i18n.default_language = pick("defaultLanguage", i18n.languages.first().map(|l| l.code.as_str()).unwrap_or("cs")); i18n.fallback_language = pick("fallbackLanguage", &i18n.default_language.clone()); i18n } pub fn codes(&self) -> Vec { self.languages.iter().map(|l| l.code.clone()).collect() } pub fn languages_json(&self) -> Value { Value::Array(self.languages.iter().map(|l| l.raw.clone()).collect()) } /// Parent language of a regional variant (`de-at` → `de`). pub fn parent(&self, language: &str) -> Option { let entry = self.languages.iter().find(|l| l.code == language)?; entry.raw.get("parent").and_then(Value::as_str).map(str::to_string).or_else(|| language.split_once('-').map(|(base, _)| base.to_string())) } /// Lookup order for a language: itself, its parent, the fallback language, Czech. pub fn chain(&self, language: &str) -> Vec { let mut chain = vec![language.to_string()]; chain.extend(self.parent(language)); chain.push(self.fallback_language.clone()); chain.push("cs".into()); chain.dedup(); chain } /// Native text from the core catalogs with language → parent → fallback language → Czech → key. pub fn t(&self, language: &str, key: &str) -> String { self.chain(language) .iter() .find_map(|code| self.core.get(code).and_then(|c| c.get(key)).and_then(Value::as_str)) .unwrap_or(key) .to_string() } fn chrome_catalogs(&self) -> Value { let mut chrome = Map::new(); for (code, catalog) in &self.core { let entries: Map = catalog.iter().filter(|(k, _)| k.starts_with("chrome.")).map(|(k, v)| (k.clone(), v.clone())).collect(); chrome.insert(code.clone(), Value::Object(entries)); } Value::Object(chrome) } fn manifest_for_windows(&self) -> Value { json!({ "defaultLanguage": self.default_language, "fallbackLanguage": self.fallback_language, "languages": self.languages_json() }) } pub fn main_window_data(&self) -> Value { json!({ "manifest": self.manifest_for_windows(), "catalogs": { "core": self.core, "diagnostics": self.diagnostics, "chrome": self.chrome_catalogs() } }) } pub fn license_window_data(&self) -> Value { json!({ "manifest": self.manifest_for_windows(), "catalogs": { "license": self.license, "chrome": self.chrome_catalogs() } }) } /// Catalogs of one module (`/ui/i18n/*.json`, any valid language code). pub fn module_window_data(&self, module_root: &Path) -> Value { let mut module = Map::new(); let dir = module_root.join("ui").join("i18n"); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); let Some(code) = name.strip_suffix(".json") else { continue }; if !valid_code(code) { continue; } if let Ok(meta) = entry.metadata() { if !meta.is_file() || meta.len() > 4 * 1024 * 1024 { continue; } } if let Ok(Value::Object(catalog)) = std::fs::read(entry.path()).map_err(|_| ()).and_then(|b| serde_json::from_slice(&b).map_err(|_| ())) { module.insert(code.to_string(), Value::Object(catalog)); } } } json!({ "manifest": self.manifest_for_windows(), "catalogs": { "module": module, "chrome": self.chrome_catalogs() } }) } } #[cfg(test)] mod tests { use super::*; #[test] fn languages_come_from_manifest_and_existing_catalogs() { let files: std::collections::HashMap<&str, &str> = [ ("i18n/languages.json", r#"{"defaultLanguage":"cs","fallbackLanguage":"en","languages":[{"code":"cs","name":"Čeština"},{"code":"en","name":"English"},{"code":"sk","name":"Slovenčina"},{"code":"../x","name":"bad"}]}"#), ("i18n/core/cs.json", r#"{"a":"A-cs","chrome.collapse":"Sbalit"}"#), ("i18n/core/en.json", r#"{"a":"A-en","b":"B-en"}"#), ("i18n/core/sk.json", r#"{"a":"A-sk"}"#), ] .into_iter() .collect(); let i18n = I18n::load(|path| files.get(path).map(|s| s.as_bytes().to_vec())); assert_eq!(i18n.codes(), vec!["cs", "en", "sk"]); assert_eq!(i18n.t("sk", "a"), "A-sk"); assert_eq!(i18n.t("sk", "b"), "B-en"); assert_eq!(i18n.t("xx", "missing"), "missing"); assert_eq!(i18n.main_window_data()["catalogs"]["chrome"]["cs"]["chrome.collapse"], "Sbalit"); } #[test] fn regional_variants_inherit_from_parent() { let files: std::collections::HashMap<&str, &str> = [ ("i18n/languages.json", r#"{"defaultLanguage":"cs","fallbackLanguage":"en","languages":[{"code":"cs"},{"code":"en"},{"code":"de"},{"code":"de-at","parent":"de"},{"code":"xx-yy","parent":"zz"}]}"#), ("i18n/core/cs.json", r#"{"a":"A-cs","c":"C-cs"}"#), ("i18n/core/en.json", r#"{"a":"A-en","b":"B-en"}"#), ("i18n/core/de.json", r#"{"a":"A-de"}"#), ] .into_iter() .collect(); let i18n = I18n::load(|path| files.get(path).map(|s| s.as_bytes().to_vec())); assert_eq!(i18n.codes(), vec!["cs", "en", "de", "de-at"]); assert_eq!(i18n.t("de-at", "a"), "A-de"); assert_eq!(i18n.t("de-at", "b"), "B-en"); assert_eq!(i18n.t("de-at", "c"), "C-cs"); assert_eq!(i18n.chain("de-at"), vec!["de-at", "de", "en", "cs"]); } fn placeholders(text: &str) -> std::collections::BTreeSet { regex::Regex::new(r"\{[A-Za-z0-9_]+\}").unwrap().find_iter(text).map(|m| m.as_str().to_string()).collect() } /// Every bundled catalog of every language has exactly the English keys, the same /// placeholders and no empty text. The license and the safety guidance stay in /// Czech, German and English only. #[test] fn bundled_catalogs_are_complete() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("src"); let read = |path: &std::path::Path| -> Map { serde_json::from_slice(&std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))).unwrap_or_else(|e| panic!("{}: {e}", path.display())) }; let manifest: Value = serde_json::from_slice(&std::fs::read(root.join("ui/i18n/languages.json")).unwrap()).unwrap(); let languages: Vec<&Value> = manifest["languages"].as_array().unwrap().iter().collect(); let mut dirs = vec![root.join("ui/i18n/core"), root.join("ui/i18n/diagnostics")]; for module in std::fs::read_dir(root.join("modules")).unwrap().flatten().filter(|e| e.path().is_dir()) { for version in std::fs::read_dir(module.path()).unwrap().flatten() { let dir = version.path().join("ui").join("i18n"); if dir.join("en.json").exists() { dirs.push(dir); } } } let mut problems = Vec::new(); for dir in &dirs { let english = read(&dir.join("en.json")); for language in &languages { let code = language["code"].as_str().unwrap(); let path = dir.join(format!("{code}.json")); if code == "en" || (code == "cs" && !path.exists()) || (language.get("parent").is_some() && !path.exists()) { continue; } if !path.exists() { problems.push(format!("{}: missing", path.display())); continue; } let catalog = read(&path); for (key, source) in &english { match catalog.get(key).and_then(Value::as_str) { None => problems.push(format!("{}: missing key {key:?}", path.display())), Some("") => problems.push(format!("{}: empty {key:?}", path.display())), Some(text) if placeholders(text) != placeholders(source.as_str().unwrap_or("")) => problems.push(format!("{}: placeholders differ in {key:?}", path.display())), _ => {} } } problems.extend(catalog.keys().filter(|k| !english.contains_key(*k)).map(|k| format!("{}: unknown key {k:?}", path.display()))); } } let mut license: Vec = std::fs::read_dir(root.join("ui/i18n/license")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect(); license.sort(); assert_eq!(license, vec!["cs.json", "de.json", "en.json"], "the license and safety guidance are not translated"); assert!(problems.is_empty(), "{} catalog problems:\n{}", problems.len(), problems.iter().take(40).cloned().collect::>().join("\n")); } }