bevy_translation_table/
lib.rs

1use std::{collections::HashMap, path::Path};
2
3use bevy_ecs::system::Resource;
4
5#[cfg(feature = "ods")]
6use spreadsheet_ods::CellContent;
7
8#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
9/// An enum describing the currently supported types of table storage, As well as some reference data for loading different columns
10pub enum TableFile {
11    #[cfg(feature = "csv")]
12    Csv(String),
13    #[cfg(feature = "csv")]
14    CsvRaw(String),
15    #[cfg(feature = "ods")]
16    Ods(String),
17    None,
18}
19#[derive(Clone, Debug, Default)]
20/// A helper struct for storing the two segments commonly used to denote a locale and region.
21pub struct LocaleCode {
22    lang: String,
23    region: String,
24}
25
26impl PartialEq for LocaleCode {
27    fn eq(&self, other: &Self) -> bool {
28        self.lang.to_lowercase() == other.lang.to_lowercase()
29            && self.region.to_lowercase() == other.region.to_lowercase()
30    }
31}
32
33impl LocaleCode {
34    // TODO: make this overrideable.
35    /// The delimiter expected and produced when combining language and region codes in a LocalCode
36    pub const REGION_DELIMITER: &'static str = "-";
37}
38
39impl From<LocaleCode> for String {
40    fn from(value: LocaleCode) -> Self {
41        if value.region.is_empty() {
42            value.lang.clone()
43        } else {
44            format!(
45                "{}{}{}",
46                value.lang,
47                LocaleCode::REGION_DELIMITER,
48                value.region
49            )
50        }
51    }
52}
53
54impl<T> From<T> for LocaleCode
55where
56    T: ToString,
57{
58    fn from(value: T) -> Self {
59        let code = value.to_string();
60        if let Some((lang, region)) = code.split_once(Self::REGION_DELIMITER) {
61            return LocaleCode {
62                lang: lang.trim().into(),
63                region: region.trim().into(),
64            };
65        } else {
66            return LocaleCode {
67                lang: code.trim().into(),
68                region: "".into(),
69            };
70        }
71    }
72}
73
74#[derive(Clone, PartialEq, Debug, Resource)]
75/// The main Resource type that stores translation data.
76pub struct Translations {
77    locale: LocaleCode,
78    path: TableFile,
79    available_locales: Vec<LocaleCode>,
80    mappings: HashMap<String, String>,
81}
82
83impl Default for Translations {
84    fn default() -> Self {
85        Self {
86            locale: LocaleCode::default(),
87            path: TableFile::None,
88            available_locales: Vec::new(),
89            mappings: HashMap::new(),
90        }
91    }
92}
93
94impl Translations {
95    pub fn new() -> Self {
96        Self::default()
97    }
98    /// The short call to acquire a translation. Translations work through a key-value pair that are loaded based on the currently selected locale.
99    /// Here we specificially take a generic argument for the key such that any value that implements `ToString` can be translated. This creates a decent amount of flexibility for users as they will be able to "translate" custom types if they so choose.
100    pub fn tr(&self, key: impl ToString) -> String {
101        if let Some(value) = self.mappings.get(&key.to_string()).cloned() {
102            value
103        } else {
104            if cfg!(feature = "catch-missing-values") {
105                eprintln!(
106                    "missing translation value : {} has no translation value for locale {:?}",
107                    key.to_string(),
108                    self.locale
109                );
110            }
111            key.to_string()
112        }
113    }
114
115    /// Modifies the current Translations data to load from a specified ODS file and load a particular locale.
116    #[cfg(feature = "ods")]
117    fn ods_file(&mut self, file: &Path, locale: &String) -> &mut Self {
118        // note: remember that ODS (and any other spreadsheet) will index starting at 1, not 0!!
119
120        use std::{fs::File, io::BufReader};
121
122        let Ok(f) = File::open(file) else {
123            eprintln!("Failed to locate file: {}", file.display());
124            return self;
125        };
126        let reader = BufReader::new(f);
127
128        let Ok(workbook) = spreadsheet_ods::OdsOptions::default()
129            .content_only()
130            .read_ods(reader)
131        else {
132            eprintln!("Failed to load ODS spreadsheet file at {:?}", file);
133            return self;
134        };
135        if workbook.num_sheets() == 0 {
136            eprintln!("Attempted to load empty spreadsheet file at {:?}", file);
137            return self;
138        }
139        self.path = TableFile::Ods(file.to_str().unwrap_or_default().into());
140        let sheet = workbook.sheet(0);
141        let size = sheet.used_grid_size();
142
143        self.available_locales = Vec::new();
144        for x in 0..size.1 {
145            if let Some(cell) = sheet.cell(0, x) {
146                let str_value = Self::get_cell_text(&cell);
147                if !str_value.is_empty() {
148                    self.available_locales.push(str_value.into());
149                }
150            } else {
151                eprintln!("Failed to load cell at row={}, col={}", 0, x);
152            }
153        }
154
155        let pref_code: LocaleCode = locale.into();
156        let locale_index: u32 = match self.available_locales.iter().position(|p| *p == pref_code) {
157            Some(index) => u32::try_from(index).unwrap_or_default(),
158            None => 0,
159        };
160        self.locale = locale.into();
161        self.mappings = HashMap::new();
162        for y in 1..size.0 {
163            let Some(key) = sheet.cell(y, 0) else {
164                continue;
165            };
166            let Some(value) = sheet.cell(y, locale_index) else {
167                continue;
168            };
169            self.mappings
170                .insert(Self::get_cell_text(&key), Self::get_cell_text(&value));
171        }
172        self
173    }
174
175    #[cfg(feature = "ods")]
176    fn get_cell_text(cell: &CellContent) -> String {
177        match &cell.value {
178            spreadsheet_ods::Value::Empty => "".into(),
179            spreadsheet_ods::Value::Boolean(b) => b.to_string(),
180            spreadsheet_ods::Value::Number(n) => n.to_string(),
181            spreadsheet_ods::Value::Percentage(p) => format!("{}%", p * 100.),
182            spreadsheet_ods::Value::Currency(v, c) => format!("{}{}", c, v),
183            spreadsheet_ods::Value::Text(t) => t.clone(),
184            spreadsheet_ods::Value::TextXml(x) => {
185                for tag in x {
186                    for c in tag.content() {
187                        if let spreadsheet_ods::xmltree::XmlContent::Text(t) = c {
188                            return t.clone();
189                        }
190                    }
191                }
192                "".into()
193            }
194            spreadsheet_ods::Value::DateTime(dt) => dt.to_string(),
195            spreadsheet_ods::Value::TimeDuration(dur) => dur.to_string(),
196        }
197    }
198
199    /// Modifies the current Translations data to load from a specified CSV file and load a particular locale.
200    #[cfg(feature = "csv")]
201    pub fn csv_file(&mut self, path: &Path, locale: &String) -> &mut Self {
202        let Ok(mut reader) = csv::ReaderBuilder::new()
203            .has_headers(true)
204            .double_quote(false)
205            .escape(Some(b'\\'))
206            .flexible(true)
207            .from_path(path)
208        else {
209            eprintln!("Failed to load csv file: {}", path.display());
210            return self;
211        };
212        self.path = TableFile::Csv(path.to_str().unwrap_or_default().into());
213
214        let Ok(head) = reader.headers() else {
215            eprintln!("Failed to collect header row from reader");
216            return self;
217        };
218        let locales = head
219            .into_iter()
220            .map(|s| s.to_string().trim().into())
221            .collect::<Vec<String>>();
222        if locales.is_empty() {
223            eprintln!("Collected empty locale list!");
224        }
225
226        let locale_index = locales.iter().position(|p| p == locale).unwrap_or_default();
227        if locale_index == 0 {
228            eprintln!(
229                "Locale index not found for locale {:?} in set {:#?}",
230                locale, locales
231            )
232        }
233        self.locale = locale.into();
234        let mapping = reader
235            .records()
236            .map(|p| {
237                let rec = p.unwrap_or_default();
238                (
239                    rec.get(0).unwrap_or_default().to_string(),
240                    rec.get(locale_index).unwrap_or_default().to_string(),
241                )
242            })
243            .collect::<Vec<(String, String)>>();
244        self.data(locales.into_iter(), mapping.into_iter(), true)
245    }
246
247    /// Modifies the current Translations data to load from a raw string in CSV format and load a particular locale.
248    #[cfg(feature = "csv")]
249    pub fn csv_raw(&mut self, csv_data: String, locale: &String) -> &mut Self {
250        let mut reader = csv::ReaderBuilder::new()
251            .double_quote(false)
252            .escape(Some(b'\\'))
253            .flexible(true)
254            .has_headers(true)
255            .from_reader(csv_data.as_bytes());
256        self.path = TableFile::CsvRaw(csv_data.clone());
257
258        let Ok(head) = reader.headers() else {
259            eprintln!("Failed to collect header row from reader");
260            return self;
261        };
262        let locales = head
263            .into_iter()
264            .map(|s| s.to_string().trim().into())
265            .collect::<Vec<String>>();
266        if locales.is_empty() {
267            eprintln!("Collected empty locale list!");
268        }
269
270        let locale_index = locales.iter().position(|p| p == locale).unwrap_or_default();
271        if locale_index == 0 {
272            eprintln!(
273                "Locale index not found for locale {:?} in set {:#?}",
274                locale, locales
275            )
276        }
277        self.locale = locale.into();
278
279        let mapping = reader
280            .records()
281            .map(|p| {
282                let rec = p.unwrap_or_default();
283                (
284                    rec.get(0).unwrap_or_default().to_string(),
285                    rec.get(locale_index).unwrap_or_default().to_string(),
286                )
287            })
288            .collect::<Vec<(String, String)>>();
289        self.data(locales.into_iter(), mapping.into_iter(), true)
290    }
291
292    /// Modifies the current Translations data to load from raw data.
293    /// Note that using this method directly does not support changing locales. If you want that feature, you must use CSV or ODS
294    pub fn data<S>(
295        &mut self,
296        locales: impl Iterator<Item = S>,
297        mapping: impl Iterator<Item = (S, S)>,
298        clear_old_data: bool,
299    ) -> &mut Self
300    where
301        S: ToString,
302    {
303        if clear_old_data {
304            self.available_locales.clear();
305            self.mappings.clear();
306        }
307        self.available_locales = locales.map(|code| code.to_string().trim().into()).collect();
308        for (key, value) in mapping {
309            self.mappings.insert(
310                key.to_string().trim().into(),
311                value.to_string().trim().into(),
312            );
313        }
314        self
315    }
316
317    /// A convenience method for calling `use_locale` with the system's default locale.
318    #[cfg(feature = "auto")]
319    pub fn use_system_locale(&mut self) -> &mut Self {
320        self.use_locale(Self::get_system_language().unwrap_or(String::from(
321            self.available_locales.first().cloned().unwrap_or_default(),
322        )))
323    }
324
325    /// Change the current locale to the new locale if available. Also loads the new mapping data allowing for translations to be loaded immediately.
326    pub fn use_locale<S>(&mut self, locale: S) -> &mut Self
327    where
328        S: ToString + Clone,
329    {
330        // validate this format has a way to load different locales
331        let path = self.path.clone();
332        if path == TableFile::None {
333            eprintln!("Current data format does not allow loading different translation columns.");
334            return self;
335        }
336
337        // validate the requested locale is available
338        let code: LocaleCode = locale.clone().into();
339        if !self.available_locales.contains(&code) {
340            eprintln!("Requested locale is not available: requested {:?}", code);
341            return self;
342        }
343        // self.locale = code;
344
345        // collect the key-value pairs based on the current file format
346        match path {
347            #[cfg(feature = "csv")]
348            TableFile::Csv(str_path) => self.csv_file(Path::new(&str_path), &String::from(code)),
349
350            #[cfg(feature = "csv")]
351            TableFile::CsvRaw(raw_data) => self.csv_raw(raw_data, &String::from(code)),
352
353            #[cfg(feature = "ods")]
354            TableFile::Ods(str_path) => self.ods_file(Path::new(&str_path), &String::from(code)),
355
356            TableFile::None => {
357                unreachable!()
358            }
359        }
360    }
361
362    /// Returns an optional string if able to acquire the system's current locale code. Basically just a small wrapper around `bevy_device_lang` for convenience
363    #[cfg(feature = "auto")]
364    pub fn get_system_language() -> Option<String> {
365        bevy_device_lang::get_lang()
366    }
367
368    /// Consumes and clones the instance to make inserting the resource into a bevy App or World a bit easier when using the builder pattern.
369    pub fn build(&self) -> Self {
370        self.clone() // probably not best practice :/
371    }
372}
373
374#[cfg(test)]
375mod tests {
376
377    use super::*;
378    // need to escape out of the target directory
379
380    // assets/lang.ods
381    // target/debug/__.rlib
382    const FILE_CSV: &str = "assets/lang.csv";
383    const FILE_ODS: &str = "assets/lang.ods";
384
385    #[test]
386    fn locale_code_lang() {
387        const LOCALE: [&str; 3] = ["en", "es", "fr"];
388        for loc in LOCALE.into_iter() {
389            let code: LocaleCode = loc.into();
390            assert_eq!(code.lang, loc.to_string());
391            assert_eq!(code.region, "".to_string());
392        }
393    }
394    #[test]
395    fn locale_code_lang_region() {
396        const LOCALE: [(&str, &str); 3] = [("en", "AU"), ("es", "CL"), ("fr", "CI")];
397        for (lang, region) in LOCALE.into_iter() {
398            let code: LocaleCode =
399                format!("{}{}{}", lang, LocaleCode::REGION_DELIMITER, region).into();
400            assert_eq!(code.lang, lang.to_string());
401            assert_eq!(code.region, region.to_string());
402        }
403    }
404
405    #[test]
406    #[cfg(feature = "csv")]
407    fn load_csv_file() {
408        if let Ok(pwd) = std::env::current_dir() {
409            eprintln!("PWD ==> {}", pwd.display());
410        }
411        let mut t = Translations::default();
412        t.csv_file(&Path::new(FILE_CSV), &"en".into());
413        validate_translation_data(&mut t);
414    }
415
416    #[test]
417    #[cfg(feature = "csv")]
418    pub fn load_csv_raw() {
419        const CSV_DATA_RAW: &'static str = r#"key, en, es
420hello, hello, hola,
421green, green, verde"#;
422
423        let mut t = Translations::default();
424        t.csv_raw(CSV_DATA_RAW.into(), &"en".into());
425        validate_translation_data(&mut t);
426    }
427    #[test]
428    #[cfg(feature = "ods")]
429    fn load_ods() {
430        let mut t = Translations::default();
431        t.ods_file(&Path::new(FILE_ODS), &"en".into());
432        validate_translation_data(&mut t);
433    }
434
435    #[test]
436    fn load_data_raw() {
437        let locales: &[&str; 1] = &["es"];
438        let mappings = vec![(&"hello", &"hola"), (&"green", &"verde")];
439
440        let mut t = Translations::default();
441        t.data(locales.iter(), mappings.into_iter(), true);
442        assert_eq!(t.tr("hello"), "hola");
443        assert_eq!(t.tr("green"), "verde");
444        assert_eq!(t.tr("invalid"), "invalid");
445    }
446
447    fn validate_translation_data(trans: &mut Translations) {
448        // eprintln!("Raw Loaded: {:#?}\n", trans);
449
450        trans.use_locale("en");
451        // eprintln!("EN: {:#?}", trans);
452        assert_eq!(trans.tr("hello"), "hello");
453        assert_eq!(trans.tr("green"), "green");
454        assert_eq!(trans.tr("invalid"), "invalid");
455
456        trans.use_locale("es");
457        eprintln!("ES: {:#?}", trans);
458        assert_eq!(trans.tr("hello"), "hola");
459        assert_eq!(trans.tr("green"), "verde");
460        assert_eq!(trans.tr("invalid"), "invalid");
461    }
462}