CH-J Toolsmanagementul serverului peste SSH
Meniu
Sursă publicată

CH-J Tools

Navighează directoarele și fișierele pentru o anumită versiune a aplicației.

Descarcă sursa ZIP
CH-J / Server Manager 1704 fișiere
0.1.2-beta Adăugat: · Construit:
i18n.rs Adăugat: Data neînregistrată 13,5 KB · 268 linii
Cod Descarcă fișierul
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 /// Main window data for `language`: the full language list, but catalogs only for its lookup chain. All 42
121 /// 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 }
169#[cfg(test)]
170mod 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 same
221 /// placeholders and no empty text. The license and the safety guidance stay in
222 /// 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 }
SHA-2568618eb610f1637194bd80a58d13c80fcc07af4a506db7d93c81291957b12f2a8

CH-J Proprietary Software License 1.14 · Sursa este furnizată în cadrul CH-J Proprietary Software License 1.14. Disponibilitatea sa nu modifică termenii licenţei sau nu acordă drepturi suplimentare.

Archive SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Fișiere