rush_sync_server/i18n/
mod.rs1use crate::core::prelude::*;
2use crate::ui::color::AppColor;
3use rust_embed::RustEmbed;
4use std::collections::HashMap;
5use std::sync::{Arc, RwLock};
6
7pub const DEFAULT_LANGUAGE: &str = "en";
8
9#[derive(Debug)]
10pub enum TranslationError {
11 InvalidLanguage(String),
12 LoadError(String),
13}
14
15impl std::fmt::Display for TranslationError {
16 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17 match self {
18 Self::InvalidLanguage(lang) => write!(f, "Invalid language: {}", lang),
19 Self::LoadError(msg) => write!(f, "Load error: {}", msg),
20 }
21 }
22}
23
24#[derive(RustEmbed)]
25#[folder = "src/i18n/langs/"]
26pub struct Langs;
27
28#[derive(Debug, Clone)]
29struct Entry {
30 text: String,
31 display: String,
32 category: String,
33}
34
35impl Entry {
36 fn format(&self, params: &[&str]) -> String {
37 params
38 .iter()
39 .enumerate()
40 .fold(self.text.clone(), |mut text, (i, param)| {
41 text = text.replace(&format!("{{{}}}", i), param);
42 if text.contains("{}") {
43 text = text.replacen("{}", param, 1);
44 }
45 text
46 })
47 }
48}
49
50struct I18nService {
51 language: String,
52 entries: HashMap<String, Entry>,
53 fallback: HashMap<String, Entry>,
54 cache: HashMap<String, String>,
55}
56
57impl I18nService {
58 fn new() -> Self {
59 Self {
60 language: DEFAULT_LANGUAGE.into(),
61 entries: HashMap::new(),
62 fallback: HashMap::new(),
63 cache: HashMap::new(),
64 }
65 }
66
67 fn load_language(&mut self, lang: &str) -> Result<()> {
68 if !Self::available_languages()
70 .iter()
71 .any(|l| l.eq_ignore_ascii_case(lang))
72 {
73 return Err(AppError::Translation(TranslationError::InvalidLanguage(
74 lang.into(),
75 )));
76 }
77
78 self.entries = Self::load_entries(lang)?;
80
81 self.fallback.clear();
83 for available_lang in Self::available_languages() {
84 if available_lang.to_lowercase() != lang.to_lowercase() {
85 if let Ok(other_entries) = Self::load_entries(&available_lang.to_lowercase()) {
86 for (key, entry) in other_entries {
87 self.fallback.entry(key).or_insert(entry);
88 }
89 }
90 }
91 }
92
93 self.cache.clear();
94 self.language = lang.into();
95 Ok(())
96 }
97
98 fn load_entries(lang: &str) -> Result<HashMap<String, Entry>> {
99 let lang_lower = lang.to_lowercase();
100 let mut merged_raw: HashMap<String, String> = HashMap::new();
101
102 let category_files: Vec<String> = Langs::iter()
104 .filter_map(|file| {
105 let filename = file.as_ref();
106 let prefix = format!("{}/", lang_lower);
107
108 if filename.starts_with(&prefix) && filename.ends_with(".json") {
109 Some(filename.to_string())
110 } else {
111 None
112 }
113 })
114 .collect();
115
116 let mut found_modular = false;
118 for filename in category_files {
119 if let Some(content) = Langs::get(&filename) {
120 if let Ok(content_str) = std::str::from_utf8(content.data.as_ref()) {
121 if let Ok(raw) = serde_json::from_str::<HashMap<String, String>>(content_str) {
122 merged_raw.extend(raw);
123 found_modular = true;
124 }
125 }
126 }
127 }
128
129 if !found_modular {
131 let filename = format!("{}.json", lang_lower);
132 let content = Langs::get(&filename).ok_or_else(|| {
133 AppError::Translation(TranslationError::LoadError(format!(
134 "File not found: {}",
135 filename
136 )))
137 })?;
138
139 let content_str = std::str::from_utf8(content.data.as_ref())
140 .map_err(|e| AppError::Translation(TranslationError::LoadError(e.to_string())))?;
141
142 merged_raw = serde_json::from_str(content_str)
143 .map_err(|e| AppError::Translation(TranslationError::LoadError(e.to_string())))?;
144 }
145
146 Ok(merged_raw
148 .iter()
149 .filter_map(|(key, value)| {
150 key.strip_suffix(".text").map(|base_key| {
151 let display = merged_raw
152 .get(&format!("{}.display_text", base_key))
153 .unwrap_or(&base_key.to_uppercase())
154 .clone();
155 let category = merged_raw
156 .get(&format!("{}.category", base_key))
157 .unwrap_or(&"info".to_string())
158 .clone();
159
160 (
161 base_key.into(),
162 Entry {
163 text: value.clone(),
164 display,
165 category,
166 },
167 )
168 })
169 })
170 .collect())
171 }
172
173 fn get_translation(&mut self, key: &str, params: &[&str]) -> String {
174 let cache_key = if params.is_empty() {
176 key.into()
177 } else {
178 format!("{}:{}", key, params.join(":"))
179 };
180
181 if let Some(cached) = self.cache.get(&cache_key) {
182 return cached.clone();
183 }
184
185 let text = match self.entries.get(key).or_else(|| self.fallback.get(key)) {
187 Some(entry) => entry.format(params),
188 None => format!("Missing: {}", key),
189 };
190
191 if self.cache.len() >= 1000 {
193 self.cache.clear();
194 }
195 self.cache.insert(cache_key, text.clone());
196 text
197 }
198
199 fn get_command_translation(&mut self, key: &str, params: &[&str]) -> String {
200 match self.entries.get(key).or_else(|| self.fallback.get(key)) {
201 Some(entry) => format!("[{}] {}", entry.display, entry.format(params)),
202 None => format!("[WARNING] Missing: {}", key),
203 }
204 }
205
206 fn get_display_color(&self, display_text: &str) -> AppColor {
207 for entry in self.entries.values() {
209 if entry.display.to_uppercase() == display_text.to_uppercase() {
210 return AppColor::from_category(&entry.category);
211 }
212 }
213
214 AppColor::from_any("info")
216 }
217
218 fn available_languages() -> Vec<String> {
220 let mut languages = std::collections::HashSet::new();
221
222 for file in Langs::iter() {
223 let filename = file.as_ref();
224
225 if filename.ends_with(".json") {
226 if let Some(lang) = filename.strip_suffix(".json") {
227 if !lang.contains('/') {
229 languages.insert(lang.to_uppercase());
230 }
231 }
232
233 if let Some(slash_pos) = filename.find('/') {
234 let lang = &filename[..slash_pos];
236 languages.insert(lang.to_uppercase());
237 }
238 }
239 }
240
241 languages.into_iter().collect()
242 }
243}
244
245static SERVICE: std::sync::LazyLock<Arc<RwLock<I18nService>>> =
247 std::sync::LazyLock::new(|| Arc::new(RwLock::new(I18nService::new())));
248
249pub async fn init() -> Result<()> {
251 set_language(DEFAULT_LANGUAGE)
252}
253
254pub fn set_language(lang: &str) -> Result<()> {
255 SERVICE.write().unwrap().load_language(lang)
256}
257
258pub fn get_translation(key: &str, params: &[&str]) -> String {
259 SERVICE.write().unwrap().get_translation(key, params)
260}
261
262pub fn get_command_translation(key: &str, params: &[&str]) -> String {
263 SERVICE
264 .write()
265 .unwrap()
266 .get_command_translation(key, params)
267}
268
269pub fn get_color_for_display_text(display_text: &str) -> AppColor {
270 SERVICE.read().unwrap().get_display_color(display_text)
271}
272
273pub fn get_color_category_for_display(display: &str) -> String {
274 match display.to_lowercase().as_str() {
275 "theme" => "theme".to_string(),
276 "lang" | "sprache" => "lang".to_string(),
277 _ => "info".to_string(),
278 }
279}
280
281pub fn get_current_language() -> String {
282 SERVICE.read().unwrap().language.to_uppercase()
283}
284
285pub fn get_available_languages() -> Vec<String> {
286 I18nService::available_languages()
287}
288
289pub fn has_translation(key: &str) -> bool {
290 let service = SERVICE.read().unwrap();
291 service.entries.contains_key(key) || service.fallback.contains_key(key)
292}
293
294pub fn clear_translation_cache() {
295 SERVICE.write().unwrap().cache.clear();
296}
297
298#[macro_export]
299macro_rules! t {
300 ($key:expr) => { $crate::i18n::get_translation($key, &[]) };
301 ($key:expr, $($arg:expr),+) => { $crate::i18n::get_translation($key, &[$($arg),+]) };
302}
303
304#[macro_export]
305macro_rules! tc {
306 ($key:expr) => { $crate::i18n::get_command_translation($key, &[]) };
307 ($key:expr, $($arg:expr),+) => { $crate::i18n::get_command_translation($key, &[$($arg),+]) };
308}