From 8eef1b1f1da4ccb64df7552ad15b6d2646a5253f Mon Sep 17 00:00:00 2001 From: "R. Tyler Croy" Date: Mon, 5 Jul 2021 08:56:08 -0700 Subject: [PATCH] Implement the basics of the Y10n structure and the merging of values --- l10n/de.yml | 3 + l10n/en.yml | 4 ++ src/lib.rs | 166 +++++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 l10n/de.yml create mode 100644 l10n/en.yml diff --git a/l10n/de.yml b/l10n/de.yml new file mode 100644 index 0000000..801b049 --- /dev/null +++ b/l10n/de.yml @@ -0,0 +1,3 @@ +# Hier ist ein Beispiel mit ein paar Strings drinne +--- +greeting: 'moin moin' diff --git a/l10n/en.yml b/l10n/en.yml new file mode 100644 index 0000000..3156e00 --- /dev/null +++ b/l10n/en.yml @@ -0,0 +1,4 @@ +# This is an example file with some strings in it for localization +--- +greeting: 'hello world' +secret: 'pancakes' diff --git a/src/lib.rs b/src/lib.rs index ae95005..87c74b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,10 +4,100 @@ #[macro_use] extern crate lazy_static; +use glob::glob; use log::*; +use std::collections::HashMap; +use std::fs::File; lazy_static! { - static ref LANG_REGEX: regex::Regex = regex::Regex::new(r"(?P\w+)-?(?P\w+)?(;q=(?P([0-9]*[.])?[0-9]+)?)?").unwrap(); + static ref LANG_REGEX: regex::Regex = + regex::Regex::new(r"(?P\w+)-?(?P\w+)?(;q=(?P([0-9]*[.])?[0-9]+)?)?") + .unwrap(); +} + +/** + * Y10n is a stateful struct that can be loaded with localization files + */ +pub struct Y10n { + translations: HashMap, +} + +impl Y10n { + fn new() -> Self { + Self { + translations: HashMap::default(), + } + } + + /** + * Create and load a Y10n instance from the yml files in the given glob + * + * For example `"l10n/**/*.yml"` will load all the yml files in the `l10n` directory using each + * file's name (e.g. `en.yml`) to derive it's language key (`en`). + */ + fn from_glob(pattern: &str) -> Self { + let mut this = Self::new(); + trace!( + "Attempting to load translations from glob pattern: {:?}", + pattern + ); + + for entry in glob(pattern).expect("Failed to read glob pattern") { + match entry { + Ok(path) => { + trace!("Loading translations from: {}", path.display()); + + if let Some(stem) = path.file_stem() { + let key = stem.to_string_lossy(); + // TODO: Make this error handling more robust + let value = serde_yaml::from_reader( + File::open(&path).expect("Failed to load file"), + ) + .expect("Failed to deserialize YAML"); + + this.translations.insert(key.to_string(), value); + } + } + Err(e) => warn!("{:?}", e), + } + } + this + } + + /** + * Return a Vec of all the names of languages that have been loaded + * These are conventionally just the file stems of the yml files loaded + */ + fn languages(&self) -> Vec<&String> { + self.translations.keys().collect() + } + + /** + * Returns the merged serde_yaml::Value for the given sets of languages. + * + * THis function is useful for managing language fallbacks to account for partial translations. + * FOr example if the German `de` translation file only has one string in it, but the English + * `en` file has 10, then this function could be called with a Vec of `Language` instances of + * `[de, en]` and the result would contain the one German string and 9 English strings. + */ + fn localize(&self, languages: &[Language]) -> serde_yaml::Value { + use serde_yaml::{Mapping, Value}; + + let mut values = vec![]; + + for lang in languages { + if let Some(value) = self.translations.get(&lang.code) { + values.push(value.clone()); + } + } + + let mut map = Value::Mapping(Mapping::new()); + + for value in values.into_iter().rev() { + merge_yaml(&mut map, value); + } + map + } } /** @@ -38,16 +128,25 @@ pub struct Language { } impl Language { + /** + * Create a `Language` instance from a segment of an `Accepts-Language` header + * + * For example `en` or `de;q=0.5`. + */ fn from(segment: &str) -> Result { if let Some(captures) = LANG_REGEX.captures(segment) { - println!("caps: {:?}", captures); Ok(Language { - code: captures.name("code").map_or("unknown".to_string(), |c| c.as_str().to_string()), - region: captures.name("region").map_or(None, |c| Some(c.as_str().to_string())), - quality: captures.name("quality").map_or(1.0, |c| c.as_str().parse().unwrap_or(0.0)), + code: captures + .name("code") + .map_or("unknown".to_string(), |c| c.as_str().to_string()), + region: captures + .name("region") + .map_or(None, |c| Some(c.as_str().to_string())), + quality: captures + .name("quality") + .map_or(1.0, |c| c.as_str().parse().unwrap_or(0.0)), }) - } - else { + } else { Err(Error::Generic) } } @@ -58,11 +157,63 @@ enum Error { Generic, } +/** + * Merge a couple of serde_yaml together + * + * THis code courtesy of https://stackoverflow.com/a/67743348 + */ +fn merge_yaml(a: &mut serde_yaml::Value, b: serde_yaml::Value) { + match (a, b) { + (a @ &mut serde_yaml::Value::Mapping(_), serde_yaml::Value::Mapping(b)) => { + let a = a.as_mapping_mut().unwrap(); + for (k, v) in b { + if v.is_sequence() && a.contains_key(&k) && a[&k].is_sequence() { + let mut _b = a.get(&k).unwrap().as_sequence().unwrap().to_owned(); + _b.append(&mut v.as_sequence().unwrap().to_owned()); + a[&k] = serde_yaml::Value::from(_b); + continue; + } + if !a.contains_key(&k) { + a.insert(k.to_owned(), v.to_owned()); + } else { + merge_yaml(&mut a[&k], v); + } + } + } + (a, b) => *a = b, + } +} #[cfg(test)] mod tests { use super::*; + #[test] + fn y10n_from_valid_glob() { + let y10n = Y10n::from_glob("l10n/*.yml"); + assert_eq!(y10n.languages().len(), 2); + } + + #[test] + fn y10n_localize() { + use serde_yaml::Value; + + let y10n = Y10n::from_glob("l10n/*.yml"); + let en = Language::from("en").expect("Failed to parse!"); + let de = Language::from("de").expect("Failed to parse!"); + let value = y10n.localize(&[de, en]); + if let Some(map) = value.as_mapping() { + let key = "greeting".into(); + let greeting = map.get(&key).expect("Failed to find a greeting"); + assert_eq!(&Value::String("moin moin".to_string()), greeting); + + let secret = map.get(&"secret".into()).expect("Failed to find a secret"); + assert_eq!(&Value::String("pancakes".to_string()), secret); + } else { + assert!(false, "The value wasn't a map like I expected"); + } + } + #[test] fn language_from_segment() { let lang = Language::from("en-US"); @@ -90,4 +241,3 @@ mod tests { assert_eq!(0.3, de.quality); } } -