CH-J Toolsspráva serverů přes SSH
Menu
Vydané zdroje

CH-J Tools

Procházejte adresáře a soubory konkrétního vydání aplikace.

Stáhnout zdroje ZIP
CH-J / Server Manager 872 souborů
core-20261030T220507Z-61e4558d889f Přidáno: · Sestaveno:
i18n.rs Přidáno: Datum není evidováno 12,0 KB · 248 řádků
Kód Stáhnout soubor
1//! Data-driven translations. The language list (`i18n/languages.json`) and all catalogs
2//! 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 into
4//! the windows. Native texts (dialog titles, OS prompts) use the same core catalogs.
6use serde_json::{json, Map, Value};
7use std::collections::BTreeMap;
8use std::path::Path;
10#[derive(Clone, Debug)]
11pub struct LanguageInfo {
12 pub code: String,
13 pub raw: Value,
16#[derive(Clone, Debug, Default)]
17pub 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>>,
27fn 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)
35impl 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 i18n
68 }
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 chain
92 }
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 }
156#[cfg(test)]
157mod 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 same
201 /// placeholders and no empty text. The license and the safety guidance stay in
202 /// 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 }
SHA-2568650bad944198bd346d3d53740c7b2b48a3528949b844996221be8b294440f91

CH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.

SHA-256 archivu77380d4805485ffa4027fbe7858b5836f197a4aa9d52358a43e2d12d018744df
Soubory