Skip to main content

rush_sync_server/i18n/
mod.rs

1use crate::core::prelude::*;
2use crate::ui::color::AppColor;
3use rust_embed::RustEmbed;
4use std::collections::HashMap;
5use std::sync::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: RwLock<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: RwLock::new(HashMap::new()),
64        }
65    }
66
67    fn load_language(&mut self, lang: &str) -> Result<()> {
68        if !Self::available_languages()
69            .iter()
70            .any(|l| l.eq_ignore_ascii_case(lang))
71        {
72            return Err(AppError::Translation(TranslationError::InvalidLanguage(
73                lang.into(),
74            )));
75        }
76
77        self.entries = Self::load_entries(lang)?;
78
79        // Load fallback from other languages
80        self.fallback.clear();
81        for available_lang in Self::available_languages() {
82            if available_lang.to_lowercase() != lang.to_lowercase() {
83                if let Ok(other_entries) = Self::load_entries(&available_lang.to_lowercase()) {
84                    for (key, entry) in other_entries {
85                        self.fallback.entry(key).or_insert(entry);
86                    }
87                }
88            }
89        }
90
91        if let Ok(mut cache) = self.cache.write() {
92            cache.clear();
93        }
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()
103            .filter_map(|file| {
104                let filename = file.as_ref();
105                let prefix = format!("{}/", lang_lower);
106
107                if filename.starts_with(&prefix) && filename.ends_with(".json") {
108                    Some(filename.to_string())
109                } else {
110                    None
111                }
112            })
113            .collect();
114
115        let mut found_modular = false;
116        for filename in category_files {
117            if let Some(content) = Langs::get(&filename) {
118                if let Ok(content_str) = std::str::from_utf8(content.data.as_ref()) {
119                    if let Ok(raw) = serde_json::from_str::<HashMap<String, String>>(content_str) {
120                        merged_raw.extend(raw);
121                        found_modular = true;
122                    }
123                }
124            }
125        }
126
127        // Fallback: single-file format
128        if !found_modular {
129            let filename = format!("{}.json", lang_lower);
130            let content = Langs::get(&filename).ok_or_else(|| {
131                AppError::Translation(TranslationError::LoadError(format!(
132                    "File not found: {}",
133                    filename
134                )))
135            })?;
136
137            let content_str = std::str::from_utf8(content.data.as_ref())
138                .map_err(|e| AppError::Translation(TranslationError::LoadError(e.to_string())))?;
139
140            merged_raw = serde_json::from_str(content_str)
141                .map_err(|e| AppError::Translation(TranslationError::LoadError(e.to_string())))?;
142        }
143
144        Ok(merged_raw
145            .iter()
146            .filter_map(|(key, value)| {
147                key.strip_suffix(".text").map(|base_key| {
148                    let display = merged_raw
149                        .get(&format!("{}.display_text", base_key))
150                        .unwrap_or(&base_key.to_uppercase())
151                        .clone();
152                    let category = merged_raw
153                        .get(&format!("{}.category", base_key))
154                        .unwrap_or(&"info".to_string())
155                        .clone();
156
157                    (
158                        base_key.into(),
159                        Entry {
160                            text: value.clone(),
161                            display,
162                            category,
163                        },
164                    )
165                })
166            })
167            .collect())
168    }
169
170    // Now takes &self - cache has its own lock
171    fn get_translation(&self, key: &str, params: &[&str]) -> String {
172        let cache_key = if params.is_empty() {
173            key.into()
174        } else {
175            format!("{}:{}", key, params.join(":"))
176        };
177
178        // Fast path: read lock on cache
179        if let Ok(cache) = self.cache.read() {
180            if let Some(cached) = cache.get(&cache_key) {
181                return cached.clone();
182            }
183        }
184
185        // Slow path: compute and write to cache
186        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 let Ok(mut cache) = self.cache.write() {
192            if cache.len() >= 1000 {
193                cache.clear();
194            }
195            cache.insert(cache_key, text.clone());
196        }
197        text
198    }
199
200    fn get_command_translation(&self, key: &str, params: &[&str]) -> String {
201        match self.entries.get(key).or_else(|| self.fallback.get(key)) {
202            Some(entry) => format!("[{}] {}", entry.display, entry.format(params)),
203            None => format!("[WARNING] Missing: {}", key),
204        }
205    }
206
207    fn get_display_color(&self, display_text: &str) -> AppColor {
208        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        AppColor::from_any("info")
214    }
215
216    fn available_languages() -> Vec<String> {
217        let mut languages = std::collections::HashSet::new();
218
219        for file in Langs::iter() {
220            let filename = file.as_ref();
221
222            if filename.ends_with(".json") {
223                if let Some(lang) = filename.strip_suffix(".json") {
224                    if !lang.contains('/') {
225                        languages.insert(lang.to_uppercase());
226                    }
227                }
228
229                if let Some(slash_pos) = filename.find('/') {
230                    let lang = &filename[..slash_pos];
231                    languages.insert(lang.to_uppercase());
232                }
233            }
234        }
235
236        languages.into_iter().collect()
237    }
238}
239
240static SERVICE: std::sync::LazyLock<RwLock<I18nService>> =
241    std::sync::LazyLock::new(|| RwLock::new(I18nService::new()));
242
243pub async fn init() -> Result<()> {
244    set_language(DEFAULT_LANGUAGE)
245}
246
247pub fn set_language(lang: &str) -> Result<()> {
248    match SERVICE.write() {
249        Ok(mut service) => service.load_language(lang),
250        Err(e) => Err(AppError::Validation(format!("i18n lock poisoned: {}", e))),
251    }
252}
253
254pub fn get_translation(key: &str, params: &[&str]) -> String {
255    match SERVICE.read() {
256        Ok(service) => service.get_translation(key, params),
257        Err(_) => format!("Missing: {}", key),
258    }
259}
260
261pub fn get_command_translation(key: &str, params: &[&str]) -> String {
262    match SERVICE.read() {
263        Ok(service) => service.get_command_translation(key, params),
264        Err(_) => format!("[WARNING] Missing: {}", key),
265    }
266}
267
268pub fn get_color_for_display_text(display_text: &str) -> AppColor {
269    match SERVICE.read() {
270        Ok(service) => service.get_display_color(display_text),
271        Err(_) => AppColor::from_any("info"),
272    }
273}
274
275pub fn get_color_category_for_display(display: &str) -> String {
276    match display.to_lowercase().as_str() {
277        "theme" => "theme".to_string(),
278        "lang" | "sprache" => "lang".to_string(),
279        _ => "info".to_string(),
280    }
281}
282
283pub fn get_current_language() -> String {
284    match SERVICE.read() {
285        Ok(service) => service.language.to_uppercase(),
286        Err(_) => DEFAULT_LANGUAGE.to_uppercase(),
287    }
288}
289
290pub fn get_available_languages() -> Vec<String> {
291    I18nService::available_languages()
292}
293
294pub fn has_translation(key: &str) -> bool {
295    match SERVICE.read() {
296        Ok(service) => service.entries.contains_key(key) || service.fallback.contains_key(key),
297        Err(_) => false,
298    }
299}
300
301pub fn clear_translation_cache() {
302    if let Ok(service) = SERVICE.read() {
303        if let Ok(mut cache) = service.cache.write() {
304            cache.clear();
305        }
306    }
307}
308
309#[macro_export]
310macro_rules! t {
311    ($key:expr) => { $crate::i18n::get_translation($key, &[]) };
312    ($key:expr, $($arg:expr),+) => { $crate::i18n::get_translation($key, &[$($arg),+]) };
313}
314
315#[macro_export]
316macro_rules! tc {
317    ($key:expr) => { $crate::i18n::get_command_translation($key, &[]) };
318    ($key:expr, $($arg:expr),+) => { $crate::i18n::get_command_translation($key, &[$($arg),+]) };
319}