Veröffentlichte Quellen
Quellen als ZIP herunterladen
CH-J Tools
Durchsuchen Sie Verzeichnisse und Dateien einer bestimmten Anwendungsausgabe.
CH-J / Server Manager
872 Dateien
core-20261030T220507Z-61e4558d889f
Hinzugefügt: · Erstellt:
1
//! Data-driven translations. The language list (`i18n/languages.json`) and all catalogs2
//! are data files: the Core reads them from the bundled UI assets (core, diagnostics,3
//! license) or from a module directory (`ui/i18n/<language>.json`) and injects them into4
//! the windows. Native texts (dialog titles, OS prompts) use the same core catalogs.6
use serde_json::{json, Map, Value};7
use std::collections::BTreeMap;8
use std::path::Path;10
#[derive(Clone, Debug)]11
pub struct LanguageInfo {12
pub code: String,13
pub raw: Value,14
}16
#[derive(Clone, Debug, Default)]17
pub struct I18n {18
pub manifest: Value,19
pub languages: Vec<LanguageInfo>,20
pub default_language: String,21
pub fallback_language: String,22
pub core: BTreeMap<String, Map<String, Value>>,23
pub diagnostics: BTreeMap<String, Map<String, Value>>,24
pub license: BTreeMap<String, Map<String, Value>>,25
}27
fn valid_code(code: &str) -> bool {28
let mut parts = code.splitn(2, '-');29
let base = parts.next().unwrap_or("");30
(2..=3).contains(&base.len())31
&& base.chars().all(|c| c.is_ascii_lowercase())32
&& parts.next().map(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_alphanumeric())).unwrap_or(true)33
}35
impl I18n {36
/// `read` returns the bytes of a bundled UI asset path such as `i18n/core/cs.json`.37
pub fn load(read: impl Fn(&str) -> Option<Vec<u8>>) -> Self {38
let parse = |path: &str| -> Option<Value> { read(path).and_then(|bytes| serde_json::from_slice(&bytes).ok()) };39
let manifest = parse("i18n/languages.json").unwrap_or_else(|| json!({ "defaultLanguage": "cs", "fallbackLanguage": "en", "languages": [] }));40
let mut i18n = I18n { manifest: manifest.clone(), ..Default::default() };41
for entry in manifest.get("languages").and_then(Value::as_array).cloned().unwrap_or_default() {42
let Some(code) = entry.get("code").and_then(Value::as_str).map(str::to_string) else { continue };43
if !valid_code(&code) {44
continue;45
}46
// A regional variant (`"parent": "de"`) may ship without its own catalog and inherits.47
let parent = entry.get("parent").and_then(Value::as_str).filter(|p| i18n.core.contains_key(*p));48
let core = match parse(&format!("i18n/core/{code}.json")) {49
Some(Value::Object(core)) => core,50
_ if parent.is_some() => Map::new(),51
_ => continue,52
};53
i18n.core.insert(code.clone(), core);54
if let Some(Value::Object(diag)) = parse(&format!("i18n/diagnostics/{code}.json")) {55
i18n.diagnostics.insert(code.clone(), diag);56
}57
if let Some(Value::Object(license)) = parse(&format!("i18n/license/{code}.json")) {58
i18n.license.insert(code.clone(), license);59
}60
i18n.languages.push(LanguageInfo { code, raw: entry });61
}62
let pick = |key: &str, fallback: &str| -> String {63
manifest.get(key).and_then(Value::as_str).filter(|c| i18n.core.contains_key(*c)).unwrap_or(fallback).to_string()64
};65
i18n.default_language = pick("defaultLanguage", i18n.languages.first().map(|l| l.code.as_str()).unwrap_or("cs"));66
i18n.fallback_language = pick("fallbackLanguage", &i18n.default_language.clone());67
i18n68
}70
pub fn codes(&self) -> Vec<String> {71
self.languages.iter().map(|l| l.code.clone()).collect()72
}74
pub fn languages_json(&self) -> Value {75
Value::Array(self.languages.iter().map(|l| l.raw.clone()).collect())76
}78
/// Parent language of a regional variant (`de-at` → `de`).79
pub fn parent(&self, language: &str) -> Option<String> {80
let entry = self.languages.iter().find(|l| l.code == language)?;81
entry.raw.get("parent").and_then(Value::as_str).map(str::to_string).or_else(|| language.split_once('-').map(|(base, _)| base.to_string()))82
}84
/// Lookup order for a language: itself, its parent, the fallback language, Czech.85
pub fn chain(&self, language: &str) -> Vec<String> {86
let mut chain = vec![language.to_string()];87
chain.extend(self.parent(language));88
chain.push(self.fallback_language.clone());89
chain.push("cs".into());90
chain.dedup();91
chain92
}94
/// Native text from the core catalogs with language → parent → fallback language → Czech → key.95
pub fn t(&self, language: &str, key: &str) -> String {96
self.chain(language)97
.iter()98
.find_map(|code| self.core.get(code).and_then(|c| c.get(key)).and_then(Value::as_str))99
.unwrap_or(key)100
.to_string()101
}103
fn chrome_catalogs(&self) -> Value {104
let mut chrome = Map::new();105
for (code, catalog) in &self.core {106
let entries: Map<String, Value> = catalog.iter().filter(|(k, _)| k.starts_with("chrome.")).map(|(k, v)| (k.clone(), v.clone())).collect();107
chrome.insert(code.clone(), Value::Object(entries));108
}109
Value::Object(chrome)110
}112
fn manifest_for_windows(&self) -> Value {113
json!({114
"defaultLanguage": self.default_language,115
"fallbackLanguage": self.fallback_language,116
"languages": self.languages_json()117
})118
}120
pub fn main_window_data(&self) -> Value {121
json!({122
"manifest": self.manifest_for_windows(),123
"catalogs": { "core": self.core, "diagnostics": self.diagnostics, "chrome": self.chrome_catalogs() }124
})125
}127
pub fn license_window_data(&self) -> Value {128
json!({ "manifest": self.manifest_for_windows(), "catalogs": { "license": self.license, "chrome": self.chrome_catalogs() } })129
}131
/// Catalogs of one module (`<module root>/ui/i18n/*.json`, any valid language code).132
pub fn module_window_data(&self, module_root: &Path) -> Value {133
let mut module = Map::new();134
let dir = module_root.join("ui").join("i18n");135
if let Ok(entries) = std::fs::read_dir(&dir) {136
for entry in entries.flatten() {137
let name = entry.file_name().to_string_lossy().to_string();138
let Some(code) = name.strip_suffix(".json") else { continue };139
if !valid_code(code) {140
continue;141
}142
if let Ok(meta) = entry.metadata() {143
if !meta.is_file() || meta.len() > 4 * 1024 * 1024 {144
continue;145
}146
}147
if let Ok(Value::Object(catalog)) = std::fs::read(entry.path()).map_err(|_| ()).and_then(|b| serde_json::from_slice(&b).map_err(|_| ())) {148
module.insert(code.to_string(), Value::Object(catalog));149
}150
}151
}152
json!({ "manifest": self.manifest_for_windows(), "catalogs": { "module": module, "chrome": self.chrome_catalogs() } })153
}154
}156
#[cfg(test)]157
mod tests {158
use super::*;160
#[test]161
fn languages_come_from_manifest_and_existing_catalogs() {162
let files: std::collections::HashMap<&str, &str> = [163
("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"}]}"#),164
("i18n/core/cs.json", r#"{"a":"A-cs","chrome.collapse":"Sbalit"}"#),165
("i18n/core/en.json", r#"{"a":"A-en","b":"B-en"}"#),166
("i18n/core/sk.json", r#"{"a":"A-sk"}"#),167
]168
.into_iter()169
.collect();170
let i18n = I18n::load(|path| files.get(path).map(|s| s.as_bytes().to_vec()));171
assert_eq!(i18n.codes(), vec!["cs", "en", "sk"]);172
assert_eq!(i18n.t("sk", "a"), "A-sk");173
assert_eq!(i18n.t("sk", "b"), "B-en");174
assert_eq!(i18n.t("xx", "missing"), "missing");175
assert_eq!(i18n.main_window_data()["catalogs"]["chrome"]["cs"]["chrome.collapse"], "Sbalit");176
}178
#[test]179
fn regional_variants_inherit_from_parent() {180
let files: std::collections::HashMap<&str, &str> = [181
("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"}]}"#),182
("i18n/core/cs.json", r#"{"a":"A-cs","c":"C-cs"}"#),183
("i18n/core/en.json", r#"{"a":"A-en","b":"B-en"}"#),184
("i18n/core/de.json", r#"{"a":"A-de"}"#),185
]186
.into_iter()187
.collect();188
let i18n = I18n::load(|path| files.get(path).map(|s| s.as_bytes().to_vec()));189
assert_eq!(i18n.codes(), vec!["cs", "en", "de", "de-at"]);190
assert_eq!(i18n.t("de-at", "a"), "A-de");191
assert_eq!(i18n.t("de-at", "b"), "B-en");192
assert_eq!(i18n.t("de-at", "c"), "C-cs");193
assert_eq!(i18n.chain("de-at"), vec!["de-at", "de", "en", "cs"]);194
}196
fn placeholders(text: &str) -> std::collections::BTreeSet<String> {197
regex::Regex::new(r"\{[A-Za-z0-9_]+\}").unwrap().find_iter(text).map(|m| m.as_str().to_string()).collect()198
}200
/// Every bundled catalog of every language has exactly the English keys, the same201
/// placeholders and no empty text. The license and the safety guidance stay in202
/// Czech, German and English only.203
#[test]204
fn bundled_catalogs_are_complete() {205
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("src");206
let read = |path: &std::path::Path| -> Map<String, Value> { serde_json::from_slice(&std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))).unwrap_or_else(|e| panic!("{}: {e}", path.display())) };207
let manifest: Value = serde_json::from_slice(&std::fs::read(root.join("ui/i18n/languages.json")).unwrap()).unwrap();208
let languages: Vec<&Value> = manifest["languages"].as_array().unwrap().iter().collect();209
let mut dirs = vec![root.join("ui/i18n/core"), root.join("ui/i18n/diagnostics")];210
for module in std::fs::read_dir(root.join("modules")).unwrap().flatten().filter(|e| e.path().is_dir()) {211
for version in std::fs::read_dir(module.path()).unwrap().flatten() {212
let dir = version.path().join("ui").join("i18n");213
if dir.join("en.json").exists() {214
dirs.push(dir);215
}216
}217
}218
let mut problems = Vec::new();219
for dir in &dirs {220
let english = read(&dir.join("en.json"));221
for language in &languages {222
let code = language["code"].as_str().unwrap();223
let path = dir.join(format!("{code}.json"));224
if code == "en" || (code == "cs" && !path.exists()) || (language.get("parent").is_some() && !path.exists()) {225
continue;226
}227
if !path.exists() {228
problems.push(format!("{}: missing", path.display()));229
continue;230
}231
let catalog = read(&path);232
for (key, source) in &english {233
match catalog.get(key).and_then(Value::as_str) {234
None => problems.push(format!("{}: missing key {key:?}", path.display())),235
Some("") => problems.push(format!("{}: empty {key:?}", path.display())),236
Some(text) if placeholders(text) != placeholders(source.as_str().unwrap_or("")) => problems.push(format!("{}: placeholders differ in {key:?}", path.display())),237
_ => {}238
}239
}240
problems.extend(catalog.keys().filter(|k| !english.contains_key(*k)).map(|k| format!("{}: unknown key {k:?}", path.display())));241
}242
}243
let mut license: Vec<String> = std::fs::read_dir(root.join("ui/i18n/license")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();244
license.sort();245
assert_eq!(license, vec!["cs.json", "de.json", "en.json"], "the license and safety guidance are not translated");246
assert!(problems.is_empty(), "{} catalog problems:\n{}", problems.len(), problems.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));247
}248
}SHA-256
8650bad944198bd346d3d53740c7b2b48a3528949b844996221be8b294440f91CH-J Proprietary Software License 1.14 · Die Quellen werden unter der CH-J Proprietary Software License 1.14 bereitgestellt. Ihre Verfügbarkeit ändert die Lizenzbedingungen nicht und gewährt keine zusätzlichen Rechte.
SHA-256 des Archivs
77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df