المصدر المنشور
تنزيل الشيفرة المصدرية بصيغة ZIP
CH-J Tools
تصفّح الأدلة والملفات الخاصة بإصدار محدد من التطبيق.
CH-J / Server Manager
1704 ملفات
0.1.2-beta
تاريخ الإضافة: · تاريخ البناء:
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
/// Main window data for `language`: the full language list, but catalogs only for its lookup chain. All 42121
/// catalogs in the initialization script (about 2 MB) made WebView2 on Windows take ~30 s to create the window;122
/// other languages are fetched with `catalog_bundle` when the user switches.123
pub fn main_window_data(&self, language: &str) -> Value {124
let mut manifest = self.manifest_for_windows();125
manifest["available"] = json!(self.codes());126
json!({ "manifest": manifest, "catalogs": self.catalog_bundle(language) })127
}129
/// Core, diagnostics and window-chrome catalogs of `language` and its lookup chain.130
pub fn catalog_bundle(&self, language: &str) -> Value {131
let codes = self.chain(language);132
let pick = |map: &BTreeMap<String, Map<String, Value>>| -> Value {133
Value::Object(codes.iter().filter_map(|c| map.get(c).map(|v| (c.clone(), Value::Object(v.clone())))).collect())134
};135
let chrome = self.chrome_catalogs();136
let chrome = Value::Object(codes.iter().filter_map(|c| chrome.get(c).map(|v| (c.clone(), v.clone()))).collect());137
json!({ "core": pick(&self.core), "diagnostics": pick(&self.diagnostics), "chrome": chrome })138
}140
pub fn license_window_data(&self) -> Value {141
json!({ "manifest": self.manifest_for_windows(), "catalogs": { "license": self.license, "chrome": self.chrome_catalogs() } })142
}144
/// Catalogs of one module (`<module root>/ui/i18n/*.json`, any valid language code).145
pub fn module_window_data(&self, module_root: &Path) -> Value {146
let mut module = Map::new();147
let dir = module_root.join("ui").join("i18n");148
if let Ok(entries) = std::fs::read_dir(&dir) {149
for entry in entries.flatten() {150
let name = entry.file_name().to_string_lossy().to_string();151
let Some(code) = name.strip_suffix(".json") else { continue };152
if !valid_code(code) {153
continue;154
}155
if let Ok(meta) = entry.metadata() {156
if !meta.is_file() || meta.len() > 4 * 1024 * 1024 {157
continue;158
}159
}160
if let Ok(Value::Object(catalog)) = std::fs::read(entry.path()).map_err(|_| ()).and_then(|b| serde_json::from_slice(&b).map_err(|_| ())) {161
module.insert(code.to_string(), Value::Object(catalog));162
}163
}164
}165
json!({ "manifest": self.manifest_for_windows(), "catalogs": { "module": module, "chrome": self.chrome_catalogs() } })166
}167
}169
#[cfg(test)]170
mod tests {171
use super::*;173
#[test]174
fn languages_come_from_manifest_and_existing_catalogs() {175
let files: std::collections::HashMap<&str, &str> = [176
("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"}]}"#),177
("i18n/core/cs.json", r#"{"a":"A-cs","chrome.collapse":"Sbalit"}"#),178
("i18n/core/en.json", r#"{"a":"A-en","b":"B-en"}"#),179
("i18n/core/sk.json", r#"{"a":"A-sk"}"#),180
]181
.into_iter()182
.collect();183
let i18n = I18n::load(|path| files.get(path).map(|s| s.as_bytes().to_vec()));184
assert_eq!(i18n.codes(), vec!["cs", "en", "sk"]);185
assert_eq!(i18n.t("sk", "a"), "A-sk");186
assert_eq!(i18n.t("sk", "b"), "B-en");187
assert_eq!(i18n.t("xx", "missing"), "missing");188
let data = i18n.main_window_data("sk");189
assert_eq!(data["catalogs"]["chrome"]["cs"]["chrome.collapse"], "Sbalit");190
assert_eq!(data["manifest"]["available"], json!(["cs", "en", "sk"]), "all languages listed");191
let mut loaded: Vec<&String> = data["catalogs"]["core"].as_object().unwrap().keys().collect();192
loaded.sort();193
assert_eq!(loaded, vec!["cs", "en", "sk"], "only the chain of sk");194
assert!(i18n.main_window_data("cs")["catalogs"]["core"].get("sk").is_none(), "other languages are not injected");195
assert_eq!(i18n.catalog_bundle("sk")["core"]["sk"]["a"], "A-sk");196
}198
#[test]199
fn regional_variants_inherit_from_parent() {200
let files: std::collections::HashMap<&str, &str> = [201
("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"}]}"#),202
("i18n/core/cs.json", r#"{"a":"A-cs","c":"C-cs"}"#),203
("i18n/core/en.json", r#"{"a":"A-en","b":"B-en"}"#),204
("i18n/core/de.json", r#"{"a":"A-de"}"#),205
]206
.into_iter()207
.collect();208
let i18n = I18n::load(|path| files.get(path).map(|s| s.as_bytes().to_vec()));209
assert_eq!(i18n.codes(), vec!["cs", "en", "de", "de-at"]);210
assert_eq!(i18n.t("de-at", "a"), "A-de");211
assert_eq!(i18n.t("de-at", "b"), "B-en");212
assert_eq!(i18n.t("de-at", "c"), "C-cs");213
assert_eq!(i18n.chain("de-at"), vec!["de-at", "de", "en", "cs"]);214
}216
fn placeholders(text: &str) -> std::collections::BTreeSet<String> {217
regex::Regex::new(r"\{[A-Za-z0-9_]+\}").unwrap().find_iter(text).map(|m| m.as_str().to_string()).collect()218
}220
/// Every bundled catalog of every language has exactly the English keys, the same221
/// placeholders and no empty text. The license and the safety guidance stay in222
/// Czech, German and English only.223
#[test]224
fn bundled_catalogs_are_complete() {225
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("src");226
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())) };227
let manifest: Value = serde_json::from_slice(&std::fs::read(root.join("ui/i18n/languages.json")).unwrap()).unwrap();228
let languages: Vec<&Value> = manifest["languages"].as_array().unwrap().iter().collect();229
let mut dirs = vec![root.join("ui/i18n/core"), root.join("ui/i18n/diagnostics")];230
for module in std::fs::read_dir(root.join("modules")).unwrap().flatten().filter(|e| e.path().is_dir()) {231
for version in std::fs::read_dir(module.path()).unwrap().flatten() {232
let dir = version.path().join("ui").join("i18n");233
if dir.join("en.json").exists() {234
dirs.push(dir);235
}236
}237
}238
let mut problems = Vec::new();239
for dir in &dirs {240
let english = read(&dir.join("en.json"));241
for language in &languages {242
let code = language["code"].as_str().unwrap();243
let path = dir.join(format!("{code}.json"));244
if code == "en" || (code == "cs" && !path.exists()) || (language.get("parent").is_some() && !path.exists()) {245
continue;246
}247
if !path.exists() {248
problems.push(format!("{}: missing", path.display()));249
continue;250
}251
let catalog = read(&path);252
for (key, source) in &english {253
match catalog.get(key).and_then(Value::as_str) {254
None => problems.push(format!("{}: missing key {key:?}", path.display())),255
Some("") => problems.push(format!("{}: empty {key:?}", path.display())),256
Some(text) if placeholders(text) != placeholders(source.as_str().unwrap_or("")) => problems.push(format!("{}: placeholders differ in {key:?}", path.display())),257
_ => {}258
}259
}260
problems.extend(catalog.keys().filter(|k| !english.contains_key(*k)).map(|k| format!("{}: unknown key {k:?}", path.display())));261
}262
}263
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();264
license.sort();265
assert_eq!(license, vec!["cs.json", "de.json", "en.json"], "the license and safety guidance are not translated");266
assert!(problems.is_empty(), "{} catalog problems:\n{}", problems.len(), problems.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));267
}268
}SHA-256
8618eb610f1637194bd80a58d13c80fcc07af4a506db7d93c81291957b12f2a8CH-J Proprietary Software License 1.14 · تُتاح الشيفرة المصدرية بموجب CH-J Proprietary Software License 1.14. وإتاحتها لا تغيّر شروط الترخيص ولا تمنح حقوقًا إضافية.
SHA-256 للأرشيف
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f