use super::error::Error; use dioxus::prelude::*; use fluent::{FluentArgs, FluentBundle, FluentResource}; use unic_langid::LanguageIdentifier; #[cfg(not(target_arch = "wasm32"))] use walkdir::WalkDir; use std::collections::HashMap; #[cfg(not(target_arch = "wasm32"))] use std::path::{Path, PathBuf}; /// `Locale` is a "place-holder" around what will eventually be a `fluent::FluentBundle` #[cfg_attr(test, derive(Debug, PartialEq))] pub struct Locale { id: LanguageIdentifier, resource: LocaleResource, } impl Locale { pub fn new_static(id: LanguageIdentifier, str: &'static str) -> Self { Self { id, resource: LocaleResource::Static(str), } } #[cfg(not(target_arch = "wasm32"))] pub fn new_dynamic(id: LanguageIdentifier, path: impl Into) -> Self { Self { id, resource: LocaleResource::Path(path.into()), } } } impl From<(LanguageIdentifier, T)> for Locale where T: Into, { fn from((id, resource): (LanguageIdentifier, T)) -> Self { let resource = resource.into(); Self { id, resource } } } /// A `LocaleResource` can be static text, or a filesystem file (not supported in WASM). #[derive(Debug, PartialEq)] pub enum LocaleResource { Static(&'static str), #[cfg(not(target_arch = "wasm32"))] Path(PathBuf), } impl LocaleResource { pub fn try_to_resource_string(&self) -> Result { match self { Self::Static(str) => Ok(str.to_string()), #[cfg(not(target_arch = "wasm32"))] Self::Path(path) => std::fs::read_to_string(path) .map_err(|e| Error::LocaleResourcePathReadFailed(e.to_string())), } } pub fn to_resource_string(&self) -> String { let result = self.try_to_resource_string(); match result { Ok(string) => string, Err(err) => panic!("failed to create resource string {:?}: {}", self, err), } } } impl From<&'static str> for LocaleResource { fn from(value: &'static str) -> Self { Self::Static(value) } } #[cfg(not(target_arch = "wasm32"))] impl From for LocaleResource { fn from(value: PathBuf) -> Self { Self::Path(value) } } /// The configuration for `I18n`. #[cfg_attr(test, derive(Debug, PartialEq))] pub struct I18nConfig { /// The initial language, can be later changed with [`I18n::set_language`] id: LanguageIdentifier, /// The final fallback language if no other locales are found for `id`. /// A `Locale` must exist in `locales' if `fallback` is defined. fallback: Option, /// The locale_resources added to the configuration. locale_resources: Vec, /// The locales added to the configuration. locales: HashMap, } impl I18nConfig { /// Create an i18n config with the selected [LanguageIdentifier]. pub fn new(id: LanguageIdentifier) -> Self { Self { id, fallback: None, locale_resources: Vec::new(), locales: HashMap::new(), } } /// Set a fallback [LanguageIdentifier]. pub fn with_fallback(mut self, fallback: LanguageIdentifier) -> Self { self.fallback = Some(fallback); self } /// Add [Locale]. /// It is possible to share locales resources. If this locale's resource /// matches a previously added one, then this locale will use the existing one. /// This is primarily for the static locale_resources to avoid string duplication. pub fn with_locale(mut self, locale: T) -> Self where T: Into, { let locale = locale.into(); let locale_resources_len = self.locale_resources.len(); let index = self .locale_resources .iter() .position(|r| *r == locale.resource) .unwrap_or(locale_resources_len); if index == locale_resources_len { self.locale_resources.push(locale.resource) }; self.locales.insert(locale.id, index); self } /// Add multiple locales from given folder, based on their filename. /// /// If the path represents a folder, then the folder will be deep traversed for /// all '*.ftl' files. If the filename represents a [LanguageIdentifier] then it /// will be added to the config. /// /// If the path represents a file, then the filename must represent a /// unic_langid::LanguageIdentifier for it to be added to the config. /// /// The method is not available for `wasm32` builds. #[cfg(not(target_arch = "wasm32"))] pub fn try_with_auto_locales(self, path: PathBuf) -> Result { if path.is_dir() { let files = find_ftl_files(&path)?; files .into_iter() .try_fold(self, |acc, file| acc.with_auto_pathbuf(file)) } else if is_ftl_file(&path) { self.with_auto_pathbuf(path) } else { Err(Error::InvalidPath(path.to_string_lossy().to_string())) } } #[cfg(not(target_arch = "wasm32"))] fn with_auto_pathbuf(self, file: PathBuf) -> Result { assert!(is_ftl_file(&file)); let stem = file.file_stem().ok_or_else(|| { Error::InvalidLanguageId(format!("No file stem: '{}'", file.display())) })?; let id_str = stem.to_str().ok_or_else(|| { Error::InvalidLanguageId(format!("Cannot convert: {}", stem.to_string_lossy())) })?; let id = LanguageIdentifier::from_bytes(id_str.as_bytes()) .map_err(|e| Error::InvalidLanguageId(e.to_string()))?; Ok(self.with_locale((id, file))) } /// Add multiple locales from given folder, based on their filename. /// /// Will panic! on error. /// /// The method is not available for `wasm32` builds. #[cfg(not(target_arch = "wasm32"))] pub fn with_auto_locales(self, path: PathBuf) -> Self { let path_name = path.display().to_string(); let result = self.try_with_auto_locales(path); match result { Ok(result) => result, Err(err) => panic!( "with_auto_locales must have valid pathbuf {}: {}", path_name, err ), } } } #[cfg(not(target_arch = "wasm32"))] fn find_ftl_files(folder: &PathBuf) -> Result, Error> { let ftl_files: Vec = WalkDir::new(folder) .into_iter() .filter_map(|entry| entry.ok()) .filter(|entry| is_ftl_file(entry.path())) .map(|entry| entry.path().to_path_buf()) .collect(); Ok(ftl_files) } #[cfg(not(target_arch = "wasm32"))] fn is_ftl_file(entry: &Path) -> bool { entry.is_file() && entry.extension().map(|ext| ext == "ftl").unwrap_or(false) } /// Initialize an i18n provider. pub fn try_use_init_i18n(init: impl FnOnce() -> I18nConfig) -> Result { use_context_provider(move || { // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675 let I18nConfig { id, fallback, locale_resources, locales, } = init(); I18n::try_new(id, fallback, locale_resources, locales) }) } /// Initialize an i18n provider. pub fn use_init_i18n(init: impl FnOnce() -> I18nConfig) -> I18n { use_context_provider(move || { // Coverage false -ve: See https://github.com/xd009642/tarpaulin/issues/1675 let I18nConfig { id, fallback, locale_resources, locales, } = init(); match I18n::try_new(id, fallback, locale_resources, locales) { Ok(i18n) => i18n, Err(e) => panic!("Failed to create I18n context: {}", e), } }) } #[derive(Clone, Copy)] pub struct I18n { selected_language: Signal, fallback_language: Signal>, locale_resources: Signal>, locales: Signal>, active_bundle: Signal>, } impl I18n { pub fn try_new( selected_language: LanguageIdentifier, fallback_language: Option, locale_resources: Vec, locales: HashMap, ) -> Result { let bundle = try_create_bundle( &selected_language, &fallback_language, &locale_resources, &locales, )?; Ok(Self { selected_language: Signal::new(selected_language), fallback_language: Signal::new(fallback_language), locale_resources: Signal::new(locale_resources), locales: Signal::new(locales), active_bundle: Signal::new(bundle), }) } pub fn new( selected_language: LanguageIdentifier, fallback_language: Option, locale_resources: Vec, locales: HashMap, ) -> Self { let result = Self::try_new( selected_language, fallback_language, locale_resources, locales, ); match result { Ok(i18n) => i18n, Err(err) => panic!("I18n cannot be created: {}", err), } } pub fn try_translate_with_args( &self, msg: &str, args: Option<&FluentArgs>, ) -> Result { let (message_id, attribute_name) = Self::decompose_identifier(msg)?; let bundle = self.active_bundle.read(); let message = bundle .get_message(message_id) .ok_or_else(|| Error::MessageIdNotFound(message_id.into()))?; let pattern = if let Some(attribute_name) = attribute_name { let attribute = message .get_attribute(attribute_name) .ok_or_else(|| Error::AttributeIdNotFound(msg.to_string()))?; attribute.value() } else { message .value() .ok_or_else(|| Error::MessagePatternNotFound(message_id.into()))? }; let mut errors = vec![]; let translation = bundle .format_pattern(pattern, args, &mut errors) .to_string(); (errors.is_empty()) .then_some(translation) .ok_or_else(|| Error::FluentErrorsDetected(format!("{:#?}", errors))) } pub fn decompose_identifier(msg: &str) -> Result<(&str, Option<&str>), Error> { let parts: Vec<&str> = msg.split('.').collect(); match parts.as_slice() { [message_id] => Ok((message_id, None)), [message_id, attribute_name] => Ok((message_id, Some(attribute_name))), _ => Err(Error::InvalidMessageId(msg.to_string())), } } pub fn translate_with_args(&self, msg: &str, args: Option<&FluentArgs>) -> String { let result = self.try_translate_with_args(msg, args); match result { Ok(translation) => translation, Err(err) => panic!("Failed to translate {}: {}", msg, err), } } #[inline] pub fn try_translate(&self, msg: &str) -> Result { self.try_translate_with_args(msg, None) } pub fn translate(&self, msg: &str) -> String { let result = self.try_translate(msg); match result { Ok(translation) => translation, Err(err) => panic!("Failed to translate {}: {}", msg, err), } } /// Get the selected language. #[inline] pub fn language(&self) -> LanguageIdentifier { self.selected_language.read().clone() } /// Get the fallback language. pub fn fallback_language(&self) -> Option { self.fallback_language.read().clone() } /// Update the selected language. pub fn try_set_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> { *self.selected_language.write() = id; self.try_update_active_bundle() } /// Update the selected language. pub fn set_language(&mut self, id: LanguageIdentifier) { let id_name = id.to_string(); let result = self.try_set_language(id); match result { Ok(()) => (), Err(err) => panic!("cannot set language {}: {}", id_name, err), } } /// Update the fallback language. pub fn try_set_fallback_language(&mut self, id: LanguageIdentifier) -> Result<(), Error> { self.locales .read() .get(&id) .ok_or_else(|| Error::FallbackMustHaveLocale(id.to_string()))?; *self.fallback_language.write() = Some(id); self.try_update_active_bundle() } /// Update the fallback language. pub fn set_fallback_language(&mut self, id: LanguageIdentifier) { let id_name = id.to_string(); let result = self.try_set_fallback_language(id); match result { Ok(()) => (), Err(err) => panic!("cannot set fallback language {}: {}", id_name, err), } } fn try_update_active_bundle(&mut self) -> Result<(), Error> { let bundle = try_create_bundle( &self.selected_language.peek(), &self.fallback_language.peek(), &self.locale_resources.peek(), &self.locales.peek(), )?; self.active_bundle.set(bundle); Ok(()) } } fn try_create_bundle( selected_language: &LanguageIdentifier, fallback_language: &Option, locale_resources: &[LocaleResource], locales: &HashMap, ) -> Result, Error> { let add_resource = move |bundle: &mut FluentBundle, langid: &LanguageIdentifier, locale_resources: &[LocaleResource]| { if let Some(&i) = locales.get(langid) { let resource = &locale_resources[i]; let resource = FluentResource::try_new(resource.try_to_resource_string()?).map_err(|e| { Error::FluentErrorsDetected(format!("resource langid: {}\n{:#?}", langid, e)) })?; bundle.add_resource_overriding(resource); }; Ok(()) }; let mut bundle = FluentBundle::new(vec![selected_language.clone()]); if let Some(fallback_language) = fallback_language { add_resource(&mut bundle, fallback_language, locale_resources)?; } let (language, script, region, variants) = selected_language.clone().into_parts(); let variants_lang = LanguageIdentifier::from_parts(language, script, region, &variants); let region_lang = LanguageIdentifier::from_parts(language, script, region, &[]); let script_lang = LanguageIdentifier::from_parts(language, script, None, &[]); let language_lang = LanguageIdentifier::from_parts(language, None, None, &[]); add_resource(&mut bundle, &language_lang, locale_resources)?; add_resource(&mut bundle, &script_lang, locale_resources)?; add_resource(&mut bundle, ®ion_lang, locale_resources)?; add_resource(&mut bundle, &variants_lang, locale_resources)?; /* Add this code when the fluent crate includes FluentBundle::add_builtins. * This will allow the use of built-in functions like `NUMBER` and `DATETIME`. * See [Fluent issue](https://github.com/projectfluent/fluent-rs/issues/181) for more information. bundle .add_builtins() .map_err(|e| Error::FluentErrorsDetected(e.to_string()))?; */ Ok(bundle) } pub fn i18n() -> I18n { consume_context() } #[cfg(test)] mod test { use super::*; use pretty_assertions::assert_eq; use unic_langid::langid; #[test] fn can_add_locale_to_config_explicit_locale() { const LANG_A: LanguageIdentifier = langid!("la-LA"); const LANG_B: LanguageIdentifier = langid!("la-LB"); const LANG_C: LanguageIdentifier = langid!("la-LC"); let config = I18nConfig::new(LANG_A) .with_locale(Locale::new_static(LANG_B, "lang = lang_b")) .with_locale(Locale::new_dynamic(LANG_C, PathBuf::new())); assert_eq!( config, I18nConfig { id: LANG_A, fallback: None, locale_resources: vec![ LocaleResource::Static("lang = lang_b"), LocaleResource::Path(PathBuf::new()), ], locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]), } ); } #[test] fn can_add_locale_to_config_implicit_locale() { const LANG_A: LanguageIdentifier = langid!("la-LA"); const LANG_B: LanguageIdentifier = langid!("la-LB"); const LANG_C: LanguageIdentifier = langid!("la-LC"); let config = I18nConfig::new(LANG_A) .with_locale((LANG_B, "lang = lang_b")) .with_locale((LANG_C, PathBuf::new())); assert_eq!( config, I18nConfig { id: LANG_A, fallback: None, locale_resources: vec![ LocaleResource::Static("lang = lang_b"), LocaleResource::Path(PathBuf::new()) ], locales: HashMap::from([(LANG_B, 0), (LANG_C, 1)]), } ); } #[test] fn can_add_locale_string_to_config() { const LANG_A: LanguageIdentifier = langid!("la-LA"); const LANG_B: LanguageIdentifier = langid!("la-LB"); let config = I18nConfig::new(LANG_A).with_locale((LANG_B, "lang = lang_b")); assert_eq!( config, I18nConfig { id: LANG_A, fallback: None, locale_resources: vec![LocaleResource::Static("lang = lang_b")], locales: HashMap::from([(LANG_B, 0)]), } ); } #[test] fn can_add_shared_locale_string_to_config() { const LANG_A: LanguageIdentifier = langid!("la-LA"); const LANG_B: LanguageIdentifier = langid!("la-LB"); const LANG_C: LanguageIdentifier = langid!("la-LC"); let shared_string = "lang = a language"; let config = I18nConfig::new(LANG_A) .with_locale((LANG_B, shared_string)) .with_locale((LANG_C, shared_string)); assert_eq!( config, I18nConfig { id: LANG_A, fallback: None, locale_resources: vec![LocaleResource::Static(shared_string)], locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]), } ); } #[test] fn can_add_locale_pathbuf_to_config() { const LANG_A: LanguageIdentifier = langid!("la-LA"); const LANG_C: LanguageIdentifier = langid!("la-LC"); let config = I18nConfig::new(LANG_A) .with_locale((LANG_C, PathBuf::from("./test/data/fallback/la.ftl"))); assert_eq!( config, I18nConfig { id: LANG_A, fallback: None, locale_resources: vec![LocaleResource::Path(PathBuf::from( "./test/data/fallback/la.ftl" ))], locales: HashMap::from([(LANG_C, 0)]), } ); } #[test] fn can_add_shared_locale_pathbuf_to_config() { const LANG_A: LanguageIdentifier = langid!("la-LA"); const LANG_B: LanguageIdentifier = langid!("la-LB"); const LANG_C: LanguageIdentifier = langid!("la-LC"); let shared_pathbuf = PathBuf::from("./test/data/fallback/la.ftl"); let config = I18nConfig::new(LANG_A) .with_locale((LANG_B, shared_pathbuf.clone())) .with_locale((LANG_C, shared_pathbuf.clone())); assert_eq!( config, I18nConfig { id: LANG_A, fallback: None, locale_resources: vec![LocaleResource::Path(shared_pathbuf)], locales: HashMap::from([(LANG_B, 0), (LANG_C, 0)]), } ); } #[test] fn can_auto_add_locales_folder_to_config() { const LANG_A: LanguageIdentifier = langid!("la-LA"); let root_path_str = &format!("{}/tests/data/fallback/", env!("CARGO_MANIFEST_DIR")); let pathbuf = PathBuf::from(root_path_str); let config = I18nConfig::new(LANG_A) .try_with_auto_locales(pathbuf) .ok() .unwrap(); let expected_locales = [ "fb-FB", "la", "la-Scpt", "la-Scpt-LA", "la-Scpt-LA-variants", ]; assert_eq!(config.locales.len(), expected_locales.len()); assert_eq!(config.locale_resources.len(), expected_locales.len()); expected_locales.into_iter().for_each(|l| { let expected_filename = format!("{root_path_str}/{l}.ftl"); let id = LanguageIdentifier::from_bytes(l.as_bytes()).unwrap(); assert!(config.locales.get(&id).is_some()); assert!(config .locale_resources .contains(&LocaleResource::Path(PathBuf::from(expected_filename)))); }); } #[test] fn can_auto_add_locales_file_to_config() { const LANG_A: LanguageIdentifier = langid!("la-LA"); let path_str = &format!( "{}/tests/data/fallback/fb-FB.ftl", env!("CARGO_MANIFEST_DIR") ); let pathbuf = PathBuf::from(path_str); let config = I18nConfig::new(LANG_A) .try_with_auto_locales(pathbuf.clone()) .ok() .unwrap(); assert_eq!(config.locales.len(), 1); assert!(config.locales.get(&langid!("fb-FB")).is_some()); assert_eq!(config.locale_resources.len(), 1); assert!(config .locale_resources .contains(&LocaleResource::Path(pathbuf))); } #[test] fn will_fail_auto_locales_with_invalid_folder() { const LANG_A: LanguageIdentifier = langid!("la-LA"); let root_path_str = &format!("{}/non_existing_path/", env!("CARGO_MANIFEST_DIR")); let pathbuf = PathBuf::from(root_path_str); let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf); assert_eq!(config.is_err(), true); } #[test] fn will_fail_auto_locales_with_invalid_file() { const LANG_A: LanguageIdentifier = langid!("la-LA"); let path_str = &format!( "{}/tests/data/fallback/invalid_language_id.ftl", env!("CARGO_MANIFEST_DIR") ); let pathbuf = PathBuf::from(path_str); let config = I18nConfig::new(LANG_A).try_with_auto_locales(pathbuf); assert_eq!(config.is_err(), true); } }