(feat) Commands are now translatable.

Closes #8

Signed-off-by: Louis Hollingworth <louis@hollingworth.nl>
This commit is contained in:
Louis Hollingworth 2023-10-29 17:39:36 +00:00
parent 46c6a25164
commit 9882a127bc
Signed by: lucxjo
GPG key ID: A11415CB3DC7809B
7 changed files with 309 additions and 10 deletions

125
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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)
);

View file

@ -4,19 +4,21 @@
pub mod commands;
pub mod models;
pub mod translation;
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Context<'a> = poise::Context<'a, AppState, Error>;
pub struct AppState {
pub db_pool: sqlx::Pool<sqlx::Postgres>,
pub translations: translation::Translations,
}
impl AppState {
pub async fn new() -> Result<Self, Error> {
pub async fn new(translations: translation::Translations) -> Result<Self, Error> {
let pool = establish_db_pool().await?;
sqlx::migrate!().run(&pool).await?;
Ok(AppState { db_pool: pool })
Ok(AppState { db_pool: pool, translations })
}
}

View file

@ -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?)
})
});

161
src/translation.rs Normal file
View file

@ -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<String, FluentBundle>
}
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<String> {
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<Translations, crate::Error> {
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::<Result<_, _>>()?,
})
}
/// Given a set of language files, fills in command strings and their localizations accordingly
pub fn apply_translations(
translations: &Translations,
commands: &mut [poise::Command<crate::AppState, crate::Error>],
) {
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(&parameter.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(&parameter.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();
}
}
}
}

6
translations/en-US.ftl Normal file
View file

@ -0,0 +1,6 @@
# Command metadata
threads = threads
.description = Displays current active threads
# Responses
threads-response = The current threads are: