generated from lucxjo/template
(feat) Commands are now translatable.
Closes #8 Signed-off-by: Louis Hollingworth <louis@hollingworth.nl>
This commit is contained in:
parent
46c6a25164
commit
9882a127bc
125
Cargo.lock
generated
125
Cargo.lock
generated
|
@ -391,6 +391,17 @@ dependencies = [
|
||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "dotenvy"
|
name = "dotenvy"
|
||||||
version = "0.15.7"
|
version = "0.15.7"
|
||||||
|
@ -427,6 +438,9 @@ version = "2.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"fluent",
|
||||||
|
"fluent-syntax",
|
||||||
|
"intl-memoizer",
|
||||||
"poise",
|
"poise",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -486,6 +500,50 @@ dependencies = [
|
||||||
"miniz_oxide",
|
"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]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.0"
|
version = "0.11.0"
|
||||||
|
@ -889,6 +947,25 @@ dependencies = [
|
||||||
"hashbrown 0.14.2",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.9.0"
|
version = "2.9.0"
|
||||||
|
@ -1498,6 +1575,12 @@ version = "0.1.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.21"
|
version = "0.38.21"
|
||||||
|
@ -1614,6 +1697,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "self_cell"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ef965a420fe14fdac7dd018862966a4c14094f900e1650bbc71ddd7d580c8af"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.190"
|
version = "1.0.190"
|
||||||
|
@ -2153,6 +2242,15 @@ dependencies = [
|
||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinystr"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d5d0e245e80bdc9b4e5356fc45a72184abbc3861992603f515270e9340f5a219"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
@ -2319,6 +2417,15 @@ dependencies = [
|
||||||
"webpki",
|
"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]]
|
[[package]]
|
||||||
name = "typemap_rev"
|
name = "typemap_rev"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
@ -2331,6 +2438,24 @@ version = "1.17.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
|
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]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
|
|
|
@ -17,6 +17,9 @@ name = "er"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.75"
|
anyhow = "1.0.75"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
|
fluent = "0.16.0"
|
||||||
|
fluent-syntax = "0.11.0"
|
||||||
|
intl-memoizer = "0.5.1"
|
||||||
poise = "0.5.6"
|
poise = "0.5.6"
|
||||||
reqwest = "0.11.22"
|
reqwest = "0.11.22"
|
||||||
serde = { version = "1.0.188", features = ["derive"] }
|
serde = { version = "1.0.188", features = ["derive"] }
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
use crate::models::discord::{DiscordThreadsResp, DiscordThreadsRespThread, EDiscordThreadsResp};
|
use crate::models::discord::{DiscordThreadsResp, DiscordThreadsRespThread, EDiscordThreadsResp};
|
||||||
|
use crate::translation::tr;
|
||||||
|
|
||||||
/// Displays current active threads
|
|
||||||
#[poise::command(slash_command)]
|
#[poise::command(slash_command)]
|
||||||
pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
||||||
let mut threadsr: DiscordThreadsResp = DiscordThreadsResp { threads: vec![] };
|
let mut threadsr: DiscordThreadsResp = DiscordThreadsResp { threads: vec![] };
|
||||||
|
@ -34,7 +34,8 @@ pub async fn threads(ctx: crate::Context<'_>) -> Result<(), crate::Error> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let msg = format!(
|
let msg = format!(
|
||||||
"The current active threads are:{}",
|
"{}{}",
|
||||||
|
tr!(ctx, "threads-response"),
|
||||||
build_thread_response_str(threadsr.threads)
|
build_thread_response_str(threadsr.threads)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -4,19 +4,21 @@
|
||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod translation;
|
||||||
|
|
||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
pub type Context<'a> = poise::Context<'a, AppState, Error>;
|
pub type Context<'a> = poise::Context<'a, AppState, Error>;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db_pool: sqlx::Pool<sqlx::Postgres>,
|
pub db_pool: sqlx::Pool<sqlx::Postgres>,
|
||||||
|
pub translations: translation::Translations,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
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?;
|
let pool = establish_db_pool().await?;
|
||||||
sqlx::migrate!().run(&pool).await?;
|
sqlx::migrate!().run(&pool).await?;
|
||||||
Ok(AppState { db_pool: pool })
|
Ok(AppState { db_pool: pool, translations })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
13
src/main.rs
13
src/main.rs
|
@ -10,13 +10,14 @@ use poise::serenity_prelude as serenity;
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenv().ok();
|
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()
|
let framework = poise::Framework::builder()
|
||||||
.options(poise::FrameworkOptions {
|
.options(poise::FrameworkOptions {
|
||||||
commands: vec![
|
commands: cmds,
|
||||||
commands::threads::threads(),
|
|
||||||
commands::admin::admin(),
|
|
||||||
commands::report::report(),
|
|
||||||
],
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
|
.token(std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"))
|
||||||
|
@ -30,7 +31,7 @@ async fn main() {
|
||||||
.setup(|ctx, _ready, fw| {
|
.setup(|ctx, _ready, fw| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
poise::builtins::register_globally(ctx, &fw.options().commands).await?;
|
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
161
src/translation.rs
Normal 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(¶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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
translations/en-US.ftl
Normal file
6
translations/en-US.ftl
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
# Command metadata
|
||||||
|
threads = threads
|
||||||
|
.description = Displays current active threads
|
||||||
|
|
||||||
|
# Responses
|
||||||
|
threads-response = The current threads are:
|
Loading…
Reference in a new issue