From 9882a127bc1663e7a99d92d463651aa9d9b6fbd3 Mon Sep 17 00:00:00 2001 From: Louis Hollingworth Date: Sun, 29 Oct 2023 17:39:36 +0000 Subject: [PATCH] (feat) Commands are now translatable. Closes #8 Signed-off-by: Louis Hollingworth --- Cargo.lock | 125 +++++++++++++++++++++++++++++++ Cargo.toml | 3 + src/commands/threads.rs | 5 +- src/lib.rs | 6 +- src/main.rs | 13 ++-- src/translation.rs | 161 ++++++++++++++++++++++++++++++++++++++++ translations/en-US.ftl | 6 ++ 7 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 src/translation.rs create mode 100644 translations/en-US.ftl diff --git a/Cargo.lock b/Cargo.lock index 5bf73ad..a3327a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -391,6 +391,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.38", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -427,6 +438,9 @@ version = "2.0.0" dependencies = [ "anyhow", "dotenvy", + "fluent", + "fluent-syntax", + "intl-memoizer", "poise", "reqwest", "serde", @@ -486,6 +500,50 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f69378194459db76abd2ce3952b790db103ceb003008d3d50d97c41ff847a7" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e242c601dec9711505f6d5bbff5bedd4b61b2469f2e8bb8e57ee7c9747a87ffd" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0abed97648395c902868fee9026de96483933faa54ea3b40d652f7dfe61ca78" +dependencies = [ + "thiserror", +] + [[package]] name = "flume" version = "0.11.0" @@ -889,6 +947,25 @@ dependencies = [ "hashbrown 0.14.2", ] +[[package]] +name = "intl-memoizer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c310433e4a310918d6ed9243542a6b83ec1183df95dff8f23f87bb88a264a66f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "ipnet" version = "2.9.0" @@ -1498,6 +1575,12 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustix" version = "0.38.21" @@ -1614,6 +1697,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ef965a420fe14fdac7dd018862966a4c14094f900e1650bbc71ddd7d580c8af" + [[package]] name = "serde" version = "1.0.190" @@ -2153,6 +2242,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d0e245e80bdc9b4e5356fc45a72184abbc3861992603f515270e9340f5a219" +dependencies = [ + "displaydoc", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -2319,6 +2417,15 @@ dependencies = [ "webpki", ] +[[package]] +name = "type-map" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d3364c5e96cb2ad1603037ab253ddd34d7fb72a58bdddf4b7350760fc69a46" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typemap_rev" version = "0.1.5" @@ -2331,6 +2438,24 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unic-langid" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398f9ad7239db44fd0f80fe068d12ff22d78354080332a5077dc6f52f14dcf2f" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35bfd2f2b8796545b55d7d3fd3e89a0613f68a0d1c8bc28cb7ff96b411a35ff" +dependencies = [ + "tinystr", +] + [[package]] name = "unicase" version = "2.7.0" diff --git a/Cargo.toml b/Cargo.toml index 6bfebdc..ff00624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,9 @@ name = "er" [dependencies] anyhow = "1.0.75" dotenvy = "0.15.7" +fluent = "0.16.0" +fluent-syntax = "0.11.0" +intl-memoizer = "0.5.1" poise = "0.5.6" reqwest = "0.11.22" serde = { version = "1.0.188", features = ["derive"] } diff --git a/src/commands/threads.rs b/src/commands/threads.rs index 411111d..a21f4d9 100644 --- a/src/commands/threads.rs +++ b/src/commands/threads.rs @@ -3,8 +3,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later use crate::models::discord::{DiscordThreadsResp, DiscordThreadsRespThread, EDiscordThreadsResp}; +use crate::translation::tr; -/// Displays current active threads #[poise::command(slash_command)] pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> { let mut threadsr: DiscordThreadsResp = DiscordThreadsResp { threads: vec![] }; @@ -34,7 +34,8 @@ pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> { } let msg = format!( - "The current active threads are:{}", + "{}{}", + tr!(ctx, "threads-response"), build_thread_response_str(threadsr.threads) ); diff --git a/src/lib.rs b/src/lib.rs index 06fdfba..7994a81 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,19 +4,21 @@ pub mod commands; pub mod models; +pub mod translation; pub type Error = Box; pub type Context<'a> = poise::Context<'a, AppState, Error>; pub struct AppState { pub db_pool: sqlx::Pool, + pub translations: translation::Translations, } impl AppState { - pub async fn new() -> Result { + pub async fn new(translations: translation::Translations) -> Result { let pool = establish_db_pool().await?; sqlx::migrate!().run(&pool).await?; - Ok(AppState { db_pool: pool }) + Ok(AppState { db_pool: pool, translations }) } } diff --git a/src/main.rs b/src/main.rs index 4f66bd3..869b8c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,13 +10,14 @@ use poise::serenity_prelude as serenity; async fn main() { dotenv().ok(); + let translations = er::translation::read_ftl().expect("Failed to read translation files"); + let mut cmds = vec![commands::admin::admin(), commands::threads::threads(), commands::report::report()]; + er::translation::apply_translations(&translations, &mut cmds); + + let framework = poise::Framework::builder() .options(poise::FrameworkOptions { - commands: vec![ - commands::threads::threads(), - commands::admin::admin(), - commands::report::report(), - ], + commands: cmds, ..Default::default() }) .token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN")) @@ -30,7 +31,7 @@ async fn main() { .setup(|ctx, _ready, fw| { Box::pin(async move { poise::builtins::register_globally(ctx, &fw.options().commands).await?; - Ok(er::AppState::new().await?) + Ok(er::AppState::new(translations).await?) }) }); diff --git a/src/translation.rs b/src/translation.rs new file mode 100644 index 0000000..92dac0a --- /dev/null +++ b/src/translation.rs @@ -0,0 +1,161 @@ + +type FluentBundle = fluent::bundle::FluentBundle< + fluent::FluentResource, + intl_memoizer::concurrent::IntlLangMemoizer, +>; + +pub struct Translations { + pub main: FluentBundle, + pub other: std::collections::HashMap +} + +macro_rules! tr { + ($ctx:ident, $id:expr $(, $argname:ident: $argvalue:expr)* $(,)? ) => {{ + #[allow(unused_mut)] + let mut args = fluent::FluentArgs::new(); + $( args.set(stringify!($argname), $argvalue); )* + + $crate::translation::get($ctx, $id, None, Some(&args)) + }}; +} + +pub(super) use tr; + + +pub fn format( + bundle: &FluentBundle, + id: &str, + attr: Option<&str>, + args: Option<&fluent::FluentArgs<'_>>, +) -> Option { + let msg = bundle.get_message(id)?; + let ptrn = match attr { + None => msg.value()?, + Some(a) => msg.get_attribute(a)?.value() + }; + let fmt = bundle.format_pattern(ptrn, args, &mut vec![]); + Some(fmt.into_owned()) +} + +pub fn get<'a>( + ctx: crate::Context<'a>, + id: &str, + attr: Option<&str>, + args: Option<&fluent::FluentArgs<'_>>, +) -> String { + let t = &ctx.data().translations; + println!("The current locale is: {}", ctx.locale().unwrap()); + ctx.locale() + .and_then(|lcl| format(t.other.get(lcl)?, id, attr, args)) + .or_else(|| format(&t.main, id, attr, args)) + .unwrap_or_else(|| { + println!("unknown fluent message identifier `{}`", id); + id.to_string() + }) +} + +pub fn read_ftl() -> Result { + fn read_one_ftl(path: &std::path::Path) -> Result<(String, FluentBundle), crate::Error> { + let loc = path.file_stem().ok_or("Invalid .ftl filename")?; + let loc = loc.to_str().ok_or("Invalid UTF-8 filename")?; + + let fc = std::fs::read_to_string(path)?; + let res = fluent::FluentResource::try_new(fc) + .map_err(|(_, e)| format!("failed to parse {:?}: {:?}", path, e))?; + + let mut bun = FluentBundle::new_concurrent(vec![loc.parse().map_err(|e| format!("invalid locale `{}`: {}", loc, e))?]); + bun.add_resource(res) + .map_err(|e| format!("failed to add resource to bundle: {:?}", e))?; + Ok((loc.to_string(), bun)) + } + + Ok(Translations { + main: read_one_ftl("translations/en-US.ftl".as_ref())?.1, + other: std::fs::read_dir("translations")? + .map(|f| read_one_ftl(&f?.path())) + .collect::>()?, + }) +} + + +/// Given a set of language files, fills in command strings and their localizations accordingly +pub fn apply_translations( + translations: &Translations, + commands: &mut [poise::Command], +) { + for command in &mut *commands { + // Add localizations + for (locale, bundle) in &translations.other { + // Insert localized command name and description + let localized_command_name = match format(bundle, &command.name, None, None) { + Some(x) => x, + None => continue, // no localization entry => skip localization + }; + command + .name_localizations + .insert(locale.clone(), localized_command_name); + command.description_localizations.insert( + locale.clone(), + format(bundle, &command.name, Some("description"), None).unwrap(), + ); + + for parameter in &mut command.parameters { + // Insert localized parameter name and description + parameter.name_localizations.insert( + locale.clone(), + format(bundle, &command.name, Some(¶meter.name), None).unwrap(), + ); + parameter.description_localizations.insert( + locale.clone(), + format( + bundle, + &command.name, + Some(&format!("{}-description", parameter.name)), + None, + ) + .unwrap(), + ); + + // If this is a choice parameter, insert its localized variants + for choice in &mut parameter.choices { + choice.localizations.insert( + locale.clone(), + format(bundle, &choice.name, None, None).unwrap(), + ); + } + } + } + + // At this point, all translation files have been applied. However, if a user uses a locale + // we haven't explicitly inserted, there would be no translations at all -> blank texts. So, + // we use the "main" translation file (en-US) as the non-localized strings. + + // Set fallback command name and description to en-US + let bundle = &translations.main; + match format(bundle, &command.name, None, None) { + Some(x) => command.name = x, + None => continue, // no localization entry => keep hardcoded names + } + command.description = + Some(format(bundle, &command.name, Some("description"), None).unwrap()); + + for parameter in &mut command.parameters { + // Set fallback parameter name and description to en-US + parameter.name = format(bundle, &command.name, Some(¶meter.name), None).unwrap(); + parameter.description = Some( + format( + bundle, + &command.name, + Some(&format!("{}-description", parameter.name)), + None, + ) + .unwrap(), + ); + + // If this is a choice parameter, set the choice names to en-US + for choice in &mut parameter.choices { + choice.name = format(bundle, &choice.name, None, None).unwrap(); + } + } + } +} diff --git a/translations/en-US.ftl b/translations/en-US.ftl new file mode 100644 index 0000000..16db484 --- /dev/null +++ b/translations/en-US.ftl @@ -0,0 +1,6 @@ +# Command metadata +threads = threads + .description = Displays current active threads + +# Responses +threads-response = The current threads are: