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)]
9pub 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)]
20pub 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 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)]
75pub 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 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 #[cfg(feature = "ods")]
117 fn ods_file(&mut self, file: &Path, locale: &String) -> &mut Self {
118 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 #[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 #[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 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 #[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 pub fn use_locale<S>(&mut self, locale: S) -> &mut Self
327 where
328 S: ToString + Clone,
329 {
330 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 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 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 #[cfg(feature = "auto")]
364 pub fn get_system_language() -> Option<String> {
365 bevy_device_lang::get_lang()
366 }
367
368 pub fn build(&self) -> Self {
370 self.clone() }
372}
373
374#[cfg(test)]
375mod tests {
376
377 use super::*;
378 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 trans.use_locale("en");
451 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}