diff --git a/Cargo.lock b/Cargo.lock index 8253fa4..9d9a8fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,6 +212,7 @@ dependencies = [ "libc", "num-integer", "num-traits", + "pure-rust-locales", "time", "winapi 0.3.9", ] @@ -399,6 +400,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "cstr_core" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644828c273c063ab0d39486ba42a5d1f3a499d35529c759e763a9c6cb8a0fb08" +dependencies = [ + "cty", + "memchr", +] + [[package]] name = "ctor" version = "0.1.20" @@ -409,6 +420,12 @@ dependencies = [ "syn 1.0.67", ] +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + [[package]] name = "dbus" version = "0.9.1" @@ -851,6 +868,7 @@ dependencies = [ "log", "rand 0.8.3", "regex", + "sys-locale", "thiserror", ] @@ -2040,6 +2058,12 @@ dependencies = [ "unicode-xid 0.2.1", ] +[[package]] +name = "pure-rust-locales" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45c49fc4f91f35bae654f85ebb3a44d60ac64f11b3166ffa609def390c732d8" + [[package]] name = "quote" version = "0.3.15" @@ -2577,6 +2601,19 @@ dependencies = [ "unicode-xid 0.0.4", ] +[[package]] +name = "sys-locale" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f89ebb59fa30d4f65fafc2d68e94f6975256fd87e812dd99cb6e020c8563df" +dependencies = [ + "cc", + "cstr_core", + "libc", + "web-sys", + "winapi 0.3.9", +] + [[package]] name = "tempdir" version = "0.3.7" diff --git a/espanso-render/Cargo.toml b/espanso-render/Cargo.toml index 3940d0d..2244e8a 100644 --- a/espanso-render/Cargo.toml +++ b/espanso-render/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0.38" thiserror = "1.0.23" regex = "1.4.3" lazy_static = "1.4.0" -chrono = "0.4.19" +chrono = {version = "0.4.19", features=["unstable-locales"]} enum-as-inner = "0.3.3" -rand = "0.8.3" \ No newline at end of file +rand = "0.8.3" +sys-locale = "0.1.0" \ No newline at end of file diff --git a/espanso-render/src/extension/date.rs b/espanso-render/src/extension/date.rs index 5cb0812..bd1daf7 100644 --- a/espanso-render/src/extension/date.rs +++ b/espanso-render/src/extension/date.rs @@ -17,22 +17,30 @@ * along with espanso. If not, see . */ -use chrono::{DateTime, Duration, Local}; +use chrono::{DateTime, Duration, Local, Locale}; use crate::{Extension, ExtensionOutput, ExtensionResult, Number, Params, Value}; -pub struct DateExtension { +pub trait LocaleProvider { + fn get_system_locale(&self) -> String; +} + +pub struct DateExtension<'a> { fixed_date: Option>, + locale_provider: &'a dyn LocaleProvider, } #[allow(clippy::new_without_default)] -impl DateExtension { - pub fn new() -> Self { - Self { fixed_date: None } +impl<'a> DateExtension<'a> { + pub fn new(locale_provider: &'a dyn LocaleProvider) -> Self { + Self { + fixed_date: None, + locale_provider, + } } } -impl Extension for DateExtension { +impl<'a> Extension for DateExtension<'a> { fn name(&self) -> &str { "date" } @@ -53,9 +61,14 @@ impl Extension for DateExtension { } let format = params.get("format"); + let locale = params + .get("locale") + .and_then(|val| val.as_string()) + .map(String::from) + .unwrap_or_else(|| self.locale_provider.get_system_locale()); let date = if let Some(Value::String(format)) = format { - now.format(format).to_string() + DateExtension::format_date_with_locale_string(now, format, &locale) } else { now.to_rfc2822() }; @@ -64,7 +77,7 @@ impl Extension for DateExtension { } } -impl DateExtension { +impl<'a> DateExtension<'a> { fn get_date(&self) -> DateTime { if let Some(fixed_date) = self.fixed_date { fixed_date @@ -72,6 +85,339 @@ impl DateExtension { Local::now() } } + + fn format_date_with_locale(date: DateTime, format: &str, locale: Locale) -> String { + date.format_localized(format, locale).to_string() + } + + fn format_date_with_locale_string( + date: DateTime, + format: &str, + locale_str: &str, + ) -> String { + let locale = convert_locale_string_to_locale(locale_str).unwrap_or(Locale::en_US); + Self::format_date_with_locale(date, format, locale) + } +} + +fn convert_locale_string_to_locale(locale_str: &str) -> Option { + match locale_str { + "aa-DJ" => Some(Locale::aa_DJ), + "aa-ER" => Some(Locale::aa_ER), + "aa-ET" => Some(Locale::aa_ET), + "af-ZA" => Some(Locale::af_ZA), + "agr-PE" => Some(Locale::agr_PE), + "ak-GH" => Some(Locale::ak_GH), + "am-ET" => Some(Locale::am_ET), + "an-ES" => Some(Locale::an_ES), + "anp-IN" => Some(Locale::anp_IN), + "ar-AE" => Some(Locale::ar_AE), + "ar-BH" => Some(Locale::ar_BH), + "ar-DZ" => Some(Locale::ar_DZ), + "ar-EG" => Some(Locale::ar_EG), + "ar-IN" => Some(Locale::ar_IN), + "ar-IQ" => Some(Locale::ar_IQ), + "ar-JO" => Some(Locale::ar_JO), + "ar-KW" => Some(Locale::ar_KW), + "ar-LB" => Some(Locale::ar_LB), + "ar-LY" => Some(Locale::ar_LY), + "ar-MA" => Some(Locale::ar_MA), + "ar-OM" => Some(Locale::ar_OM), + "ar-QA" => Some(Locale::ar_QA), + "ar-SA" => Some(Locale::ar_SA), + "ar-SD" => Some(Locale::ar_SD), + "ar-SS" => Some(Locale::ar_SS), + "ar-SY" => Some(Locale::ar_SY), + "ar-TN" => Some(Locale::ar_TN), + "ar-YE" => Some(Locale::ar_YE), + "as-IN" => Some(Locale::as_IN), + "ast-ES" => Some(Locale::ast_ES), + "ayc-PE" => Some(Locale::ayc_PE), + "az-AZ" => Some(Locale::az_AZ), + "az-IR" => Some(Locale::az_IR), + "be-BY" => Some(Locale::be_BY), + "bem-ZM" => Some(Locale::bem_ZM), + "ber-DZ" => Some(Locale::ber_DZ), + "ber-MA" => Some(Locale::ber_MA), + "bg-BG" => Some(Locale::bg_BG), + "bhb-IN" => Some(Locale::bhb_IN), + "bho-IN" => Some(Locale::bho_IN), + "bho-NP" => Some(Locale::bho_NP), + "bi-VU" => Some(Locale::bi_VU), + "bn-BD" => Some(Locale::bn_BD), + "bn-IN" => Some(Locale::bn_IN), + "bo-CN" => Some(Locale::bo_CN), + "bo-IN" => Some(Locale::bo_IN), + "br-FR" => Some(Locale::br_FR), + "brx-IN" => Some(Locale::brx_IN), + "bs-BA" => Some(Locale::bs_BA), + "byn-ER" => Some(Locale::byn_ER), + "ca-AD" => Some(Locale::ca_AD), + "ca-ES" => Some(Locale::ca_ES), + "ca-FR" => Some(Locale::ca_FR), + "ca-IT" => Some(Locale::ca_IT), + "ce-RU" => Some(Locale::ce_RU), + "chr-US" => Some(Locale::chr_US), + "cmn-TW" => Some(Locale::cmn_TW), + "crh-UA" => Some(Locale::crh_UA), + "cs-CZ" => Some(Locale::cs_CZ), + "csb-PL" => Some(Locale::csb_PL), + "cv-RU" => Some(Locale::cv_RU), + "cy-GB" => Some(Locale::cy_GB), + "da-DK" => Some(Locale::da_DK), + "de-AT" => Some(Locale::de_AT), + "de-BE" => Some(Locale::de_BE), + "de-CH" => Some(Locale::de_CH), + "de-DE" => Some(Locale::de_DE), + "de-IT" => Some(Locale::de_IT), + "de-LI" => Some(Locale::de_LI), + "de-LU" => Some(Locale::de_LU), + "doi-IN" => Some(Locale::doi_IN), + "dsb-DE" => Some(Locale::dsb_DE), + "dv-MV" => Some(Locale::dv_MV), + "dz-BT" => Some(Locale::dz_BT), + "el-CY" => Some(Locale::el_CY), + "el-GR" => Some(Locale::el_GR), + "en-AG" => Some(Locale::en_AG), + "en-AU" => Some(Locale::en_AU), + "en-BW" => Some(Locale::en_BW), + "en-CA" => Some(Locale::en_CA), + "en-DK" => Some(Locale::en_DK), + "en-GB" => Some(Locale::en_GB), + "en-HK" => Some(Locale::en_HK), + "en-IE" => Some(Locale::en_IE), + "en-IL" => Some(Locale::en_IL), + "en-IN" => Some(Locale::en_IN), + "en-NG" => Some(Locale::en_NG), + "en-NZ" => Some(Locale::en_NZ), + "en-PH" => Some(Locale::en_PH), + "en-SC" => Some(Locale::en_SC), + "en-SG" => Some(Locale::en_SG), + "en-US" => Some(Locale::en_US), + "en-ZA" => Some(Locale::en_ZA), + "en-ZM" => Some(Locale::en_ZM), + "en-ZW" => Some(Locale::en_ZW), + "eo" => Some(Locale::eo), + "es-AR" => Some(Locale::es_AR), + "es-BO" => Some(Locale::es_BO), + "es-CL" => Some(Locale::es_CL), + "es-CO" => Some(Locale::es_CO), + "es-CR" => Some(Locale::es_CR), + "es-CU" => Some(Locale::es_CU), + "es-DO" => Some(Locale::es_DO), + "es-EC" => Some(Locale::es_EC), + "es-ES" => Some(Locale::es_ES), + "es-GT" => Some(Locale::es_GT), + "es-HN" => Some(Locale::es_HN), + "es-MX" => Some(Locale::es_MX), + "es-NI" => Some(Locale::es_NI), + "es-PA" => Some(Locale::es_PA), + "es-PE" => Some(Locale::es_PE), + "es-PR" => Some(Locale::es_PR), + "es-PY" => Some(Locale::es_PY), + "es-SV" => Some(Locale::es_SV), + "es-US" => Some(Locale::es_US), + "es-UY" => Some(Locale::es_UY), + "es-VE" => Some(Locale::es_VE), + "et-EE" => Some(Locale::et_EE), + "eu-ES" => Some(Locale::eu_ES), + "fa-IR" => Some(Locale::fa_IR), + "ff-SN" => Some(Locale::ff_SN), + "fi-FI" => Some(Locale::fi_FI), + "fil-PH" => Some(Locale::fil_PH), + "fo-FO" => Some(Locale::fo_FO), + "fr-BE" => Some(Locale::fr_BE), + "fr-CA" => Some(Locale::fr_CA), + "fr-CH" => Some(Locale::fr_CH), + "fr-FR" => Some(Locale::fr_FR), + "fr-LU" => Some(Locale::fr_LU), + "fur-IT" => Some(Locale::fur_IT), + "fy-DE" => Some(Locale::fy_DE), + "fy-NL" => Some(Locale::fy_NL), + "ga-IE" => Some(Locale::ga_IE), + "gd-GB" => Some(Locale::gd_GB), + "gez-ER" => Some(Locale::gez_ER), + "gez-ET" => Some(Locale::gez_ET), + "gl-ES" => Some(Locale::gl_ES), + "gu-IN" => Some(Locale::gu_IN), + "gv-GB" => Some(Locale::gv_GB), + "ha-NG" => Some(Locale::ha_NG), + "hak-TW" => Some(Locale::hak_TW), + "he-IL" => Some(Locale::he_IL), + "hi-IN" => Some(Locale::hi_IN), + "hif-FJ" => Some(Locale::hif_FJ), + "hne-IN" => Some(Locale::hne_IN), + "hr-HR" => Some(Locale::hr_HR), + "hsb-DE" => Some(Locale::hsb_DE), + "ht-HT" => Some(Locale::ht_HT), + "hu-HU" => Some(Locale::hu_HU), + "hy-AM" => Some(Locale::hy_AM), + "ia-FR" => Some(Locale::ia_FR), + "id-ID" => Some(Locale::id_ID), + "ig-NG" => Some(Locale::ig_NG), + "ik-CA" => Some(Locale::ik_CA), + "is-IS" => Some(Locale::is_IS), + "it-CH" => Some(Locale::it_CH), + "it-IT" => Some(Locale::it_IT), + "iu-CA" => Some(Locale::iu_CA), + "ja-JP" => Some(Locale::ja_JP), + "ka-GE" => Some(Locale::ka_GE), + "kab-DZ" => Some(Locale::kab_DZ), + "kk-KZ" => Some(Locale::kk_KZ), + "kl-GL" => Some(Locale::kl_GL), + "km-KH" => Some(Locale::km_KH), + "kn-IN" => Some(Locale::kn_IN), + "ko-KR" => Some(Locale::ko_KR), + "kok-IN" => Some(Locale::kok_IN), + "ks-IN" => Some(Locale::ks_IN), + "ku-TR" => Some(Locale::ku_TR), + "kw-GB" => Some(Locale::kw_GB), + "ky-KG" => Some(Locale::ky_KG), + "lb-LU" => Some(Locale::lb_LU), + "lg-UG" => Some(Locale::lg_UG), + "li-BE" => Some(Locale::li_BE), + "li-NL" => Some(Locale::li_NL), + "lij-IT" => Some(Locale::lij_IT), + "ln-CD" => Some(Locale::ln_CD), + "lo-LA" => Some(Locale::lo_LA), + "lt-LT" => Some(Locale::lt_LT), + "lv-LV" => Some(Locale::lv_LV), + "lzh-TW" => Some(Locale::lzh_TW), + "mag-IN" => Some(Locale::mag_IN), + "mai-IN" => Some(Locale::mai_IN), + "mai-NP" => Some(Locale::mai_NP), + "mfe-MU" => Some(Locale::mfe_MU), + "mg-MG" => Some(Locale::mg_MG), + "mhr-RU" => Some(Locale::mhr_RU), + "mi-NZ" => Some(Locale::mi_NZ), + "miq-NI" => Some(Locale::miq_NI), + "mjw-IN" => Some(Locale::mjw_IN), + "mk-MK" => Some(Locale::mk_MK), + "ml-IN" => Some(Locale::ml_IN), + "mn-MN" => Some(Locale::mn_MN), + "mni-IN" => Some(Locale::mni_IN), + "mnw-MM" => Some(Locale::mnw_MM), + "mr-IN" => Some(Locale::mr_IN), + "ms-MY" => Some(Locale::ms_MY), + "mt-MT" => Some(Locale::mt_MT), + "my-MM" => Some(Locale::my_MM), + "nan-TW" => Some(Locale::nan_TW), + "nb-NO" => Some(Locale::nb_NO), + "nds-DE" => Some(Locale::nds_DE), + "nds-NL" => Some(Locale::nds_NL), + "ne-NP" => Some(Locale::ne_NP), + "nhn-MX" => Some(Locale::nhn_MX), + "niu-NU" => Some(Locale::niu_NU), + "niu-NZ" => Some(Locale::niu_NZ), + "nl-AW" => Some(Locale::nl_AW), + "nl-BE" => Some(Locale::nl_BE), + "nl-NL" => Some(Locale::nl_NL), + "nn-NO" => Some(Locale::nn_NO), + "nr-ZA" => Some(Locale::nr_ZA), + "nso-ZA" => Some(Locale::nso_ZA), + "oc-FR" => Some(Locale::oc_FR), + "om-ET" => Some(Locale::om_ET), + "om-KE" => Some(Locale::om_KE), + "or-IN" => Some(Locale::or_IN), + "os-RU" => Some(Locale::os_RU), + "pa-IN" => Some(Locale::pa_IN), + "pa-PK" => Some(Locale::pa_PK), + "pap-AW" => Some(Locale::pap_AW), + "pap-CW" => Some(Locale::pap_CW), + "pl-PL" => Some(Locale::pl_PL), + "ps-AF" => Some(Locale::ps_AF), + "pt-BR" => Some(Locale::pt_BR), + "pt-PT" => Some(Locale::pt_PT), + "quz-PE" => Some(Locale::quz_PE), + "raj-IN" => Some(Locale::raj_IN), + "ro-RO" => Some(Locale::ro_RO), + "ru-RU" => Some(Locale::ru_RU), + "ru-UA" => Some(Locale::ru_UA), + "rw-RW" => Some(Locale::rw_RW), + "sa-IN" => Some(Locale::sa_IN), + "sah-RU" => Some(Locale::sah_RU), + "sat-IN" => Some(Locale::sat_IN), + "sc-IT" => Some(Locale::sc_IT), + "sd-IN" => Some(Locale::sd_IN), + "se-NO" => Some(Locale::se_NO), + "sgs-LT" => Some(Locale::sgs_LT), + "shn-MM" => Some(Locale::shn_MM), + "shs-CA" => Some(Locale::shs_CA), + "si-LK" => Some(Locale::si_LK), + "sid-ET" => Some(Locale::sid_ET), + "sk-SK" => Some(Locale::sk_SK), + "sl-SI" => Some(Locale::sl_SI), + "sm-WS" => Some(Locale::sm_WS), + "so-DJ" => Some(Locale::so_DJ), + "so-ET" => Some(Locale::so_ET), + "so-KE" => Some(Locale::so_KE), + "so-SO" => Some(Locale::so_SO), + "sq-AL" => Some(Locale::sq_AL), + "sq-MK" => Some(Locale::sq_MK), + "sr-ME" => Some(Locale::sr_ME), + "sr-RS" => Some(Locale::sr_RS), + "ss-ZA" => Some(Locale::ss_ZA), + "st-ZA" => Some(Locale::st_ZA), + "sv-FI" => Some(Locale::sv_FI), + "sv-SE" => Some(Locale::sv_SE), + "sw-KE" => Some(Locale::sw_KE), + "sw-TZ" => Some(Locale::sw_TZ), + "szl-PL" => Some(Locale::szl_PL), + "ta-IN" => Some(Locale::ta_IN), + "ta-LK" => Some(Locale::ta_LK), + "tcy-IN" => Some(Locale::tcy_IN), + "te-IN" => Some(Locale::te_IN), + "tg-TJ" => Some(Locale::tg_TJ), + "th-TH" => Some(Locale::th_TH), + "the-NP" => Some(Locale::the_NP), + "ti-ER" => Some(Locale::ti_ER), + "ti-ET" => Some(Locale::ti_ET), + "tig-ER" => Some(Locale::tig_ER), + "tk-TM" => Some(Locale::tk_TM), + "tl-PH" => Some(Locale::tl_PH), + "tn-ZA" => Some(Locale::tn_ZA), + "to-TO" => Some(Locale::to_TO), + "tpi-PG" => Some(Locale::tpi_PG), + "tr-CY" => Some(Locale::tr_CY), + "tr-TR" => Some(Locale::tr_TR), + "ts-ZA" => Some(Locale::ts_ZA), + "tt-RU" => Some(Locale::tt_RU), + "ug-CN" => Some(Locale::ug_CN), + "uk-UA" => Some(Locale::uk_UA), + "unm-US" => Some(Locale::unm_US), + "ur-IN" => Some(Locale::ur_IN), + "ur-PK" => Some(Locale::ur_PK), + "uz-UZ" => Some(Locale::uz_UZ), + "ve-ZA" => Some(Locale::ve_ZA), + "vi-VN" => Some(Locale::vi_VN), + "wa-BE" => Some(Locale::wa_BE), + "wae-CH" => Some(Locale::wae_CH), + "wal-ET" => Some(Locale::wal_ET), + "wo-SN" => Some(Locale::wo_SN), + "xh-ZA" => Some(Locale::xh_ZA), + "yi-US" => Some(Locale::yi_US), + "yo-NG" => Some(Locale::yo_NG), + "yue-HK" => Some(Locale::yue_HK), + "yuw-PG" => Some(Locale::yuw_PG), + "zh-CN" => Some(Locale::zh_CN), + "zh-HK" => Some(Locale::zh_HK), + "zh-SG" => Some(Locale::zh_SG), + "zh-TW" => Some(Locale::zh_TW), + "zu-ZA" => Some(Locale::zu_ZA), + _ => None, + } +} + +pub struct DefaultLocaleProvider {} +impl LocaleProvider for DefaultLocaleProvider { + fn get_system_locale(&self) -> String { + sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")) + } +} +impl DefaultLocaleProvider { + pub fn new() -> Self { + Self {} + } } #[cfg(test)] @@ -79,9 +425,30 @@ mod tests { use super::*; use chrono::offset::TimeZone; + struct MockLocaleProvider { + locale: String, + } + impl LocaleProvider for MockLocaleProvider { + fn get_system_locale(&self) -> String { + self.locale.clone() + } + } + impl MockLocaleProvider { + pub fn new() -> Self { + Self { + locale: "en-US".to_string(), + } + } + + pub fn new_with_locale(locale: String) -> Self { + Self { locale } + } + } + #[test] fn date_formatted_correctly() { - let mut extension = DateExtension::new(); + let locale_provider = MockLocaleProvider::new(); + let mut extension = DateExtension::new(&locale_provider); extension.fixed_date = Some(Local.ymd(2014, 7, 8).and_hms(9, 10, 11)); let param = vec![("format".to_string(), Value::String("%H:%M:%S".to_string()))] @@ -98,7 +465,8 @@ mod tests { #[test] fn offset_works_correctly() { - let mut extension = DateExtension::new(); + let locale_provider = MockLocaleProvider::new(); + let mut extension = DateExtension::new(&locale_provider); extension.fixed_date = Some(Local.ymd(2014, 7, 8).and_hms(9, 10, 11)); let param = vec![ @@ -115,4 +483,61 @@ mod tests { ExtensionOutput::Single("10:10:11".to_string()) ); } + + #[test] + fn default_locale_works_correctly() { + let locale_provider = MockLocaleProvider::new_with_locale("it-IT".to_string()); + let mut extension = DateExtension::new(&locale_provider); + extension.fixed_date = Some(Local.ymd(2014, 7, 8).and_hms(9, 10, 11)); + + let param = vec![("format".to_string(), Value::String("%A".to_string()))] + .into_iter() + .collect::(); + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("martedì".to_string()) + ); + } + + #[test] + fn invalid_locale_should_default_to_en_us() { + let locale_provider = MockLocaleProvider::new_with_locale("invalid".to_string()); + let mut extension = DateExtension::new(&locale_provider); + extension.fixed_date = Some(Local.ymd(2014, 7, 8).and_hms(9, 10, 11)); + + let param = vec![("format".to_string(), Value::String("%A".to_string()))] + .into_iter() + .collect::(); + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("Tuesday".to_string()) + ); + } + + #[test] + fn override_locale() { + let locale_provider = MockLocaleProvider::new(); + let mut extension = DateExtension::new(&locale_provider); + extension.fixed_date = Some(Local.ymd(2014, 7, 8).and_hms(9, 10, 11)); + + let param = vec![ + ("format".to_string(), Value::String("%A".to_string())), + ("locale".to_string(), Value::String("it-IT".to_string())), + ] + .into_iter() + .collect::(); + assert_eq!( + extension + .calculate(&Default::default(), &Default::default(), ¶m) + .into_success() + .unwrap(), + ExtensionOutput::Single("martedì".to_string()) + ); + } }