feat(i18n): Embed locales using rust-embed
- Added rust-embed dependency to botlib (optional, enabled by i18n feature). - Implemented EmbeddedLocales struct to embed 'locales' directory. - Modified I18nBundle::load to fallback to embedded assets if the filesystem path is not found. - This resolves issues where botserver fails to find locales directory in production/container environments.
This commit is contained in:
parent
bfaa68dc35
commit
5ed6ee7988
2 changed files with 80 additions and 10 deletions
|
|
@ -16,7 +16,7 @@ database = []
|
||||||
http-client = ["dep:reqwest"]
|
http-client = ["dep:reqwest"]
|
||||||
validation = []
|
validation = []
|
||||||
resilience = []
|
resilience = []
|
||||||
i18n = []
|
i18n = ["dep:rust-embed"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core
|
# Core
|
||||||
|
|
@ -34,6 +34,9 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
||||||
# Optional: HTTP Client
|
# Optional: HTTP Client
|
||||||
reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true }
|
reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true }
|
||||||
|
|
||||||
|
# Optional: i18n
|
||||||
|
rust-embed = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
tokio = { workspace = true, features = ["rt", "macros"] }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,16 @@ use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
use rust_embed::RustEmbed;
|
||||||
|
|
||||||
use super::Locale;
|
use super::Locale;
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
#[derive(RustEmbed)]
|
||||||
|
#[folder = "locales"]
|
||||||
|
struct EmbeddedLocales;
|
||||||
|
|
||||||
pub type MessageArgs = HashMap<String, String>;
|
pub type MessageArgs = HashMap<String, String>;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -80,13 +88,11 @@ impl LocaleBundle {
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.ok_or_else(|| BotError::config("invalid locale directory name"))?;
|
.ok_or_else(|| BotError::config("invalid locale directory name"))?;
|
||||||
|
|
||||||
let locale = Locale::new(dir_name)
|
.map_err(|e| locale = Locale::new(dir_name) .ok_or_else(|| BotError::config(format!("invalid locale: {dir_name}")))?;
|
||||||
.ok_or_else(|| BotError::config(format!("invalid locale: {dir_name}")))?;
|
|
||||||
|
|
||||||
let mut translations = TranslationFile {
|
let mut translations = TranslationFile {
|
||||||
messages: HashMap::new(),
|
messages: HashMap
|
||||||
};
|
};.map_err(|e|
|
||||||
|
|
||||||
let entries = fs::read_dir(locale_dir).map_err(|e| {
|
let entries = fs::read_dir(locale_dir).map_err(|e| {
|
||||||
BotError::config(format!("failed to read locale directory: {e}"))
|
BotError::config(format!("failed to read locale directory: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -117,6 +123,30 @@ impl LocaleBundle {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
fn load_embedded(locale_str: &str) -> BotResult<Self> {
|
||||||
|
let locale = Locale::new(locale_str)
|
||||||
|
.ok_or_else(|| BotError::config(format!("invalid locale: {locale_str}")))?;
|
||||||
|
|
||||||
|
let mut translations = TranslationFile {
|
||||||
|
messages: HashMap::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for file in EmbeddedLocales::iter() {
|
||||||
|
if file.starts_with(locale_str) && file.ends_with(".ftl") {
|
||||||
|
if let Some(content_bytes) = EmbeddedLocales::get(&file) {
|
||||||
|
if let Ok(content) = std::str::from_utf8(content_bytes.data.as_ref()) {
|
||||||
|
let file_translations = TranslationFile::parse(content);
|
||||||
|
translations.merge(file_translations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|e|
|
||||||
|
Ok(Self {
|
||||||
|
locale,
|
||||||
|
translations,
|
||||||
|
}) .map_err(|e|
|
||||||
|
|
||||||
fn get_message(&self, key: &str) -> Option<&String> {
|
fn get_message(&self, key: &str) -> Option<&String> {
|
||||||
self.translations.get(key)
|
self.translations.get(key)
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +164,12 @@ impl I18nBundle {
|
||||||
let base = Path::new(base_path);
|
let base = Path::new(base_path);
|
||||||
|
|
||||||
if !base.exists() {
|
if !base.exists() {
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
{
|
||||||
|
log::info!("Locales directory not found at {}, trying embedded assets", base_path);
|
||||||
|
return Self::load_embedded();
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "i18n"))]
|
||||||
return Err(BotError::config(format!(
|
return Err(BotError::config(format!(
|
||||||
"locales directory not found: {base_path}"
|
"locales directory not found: {base_path}"
|
||||||
)));
|
)));
|
||||||
|
|
@ -175,6 +211,40 @@ impl I18nBundle {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "i18n")]
|
||||||
|
fn load_embedded() -> BotResult<Self> {
|
||||||
|
let mut bundles = HashMap::new();
|
||||||
|
let mut available = Vec::new();
|
||||||
|
let mut seen_locales = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for file in EmbeddedLocales::iter() {
|
||||||
|
// Path structure: locale/file.ftl
|
||||||
|
let parts: Vec<&str> = file.split('/').collect();
|
||||||
|
if let Some(locale_str) = parts.first() {
|
||||||
|
if !seen_locales.contains(*locale_str) {
|
||||||
|
match LocaleBundle::load_embedded(locale_str) {
|
||||||
|
Ok(bundle) => {
|
||||||
|
available.push(bundle.locale.clone());
|
||||||
|
bundles.insert(bundle.locale.to_string(), bundle);
|
||||||
|
seen_locales.insert(locale_str.to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("failed to load embedded locale bundle {}: {}", locale_str, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = Locale::default();
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
bundles,
|
||||||
|
available,
|
||||||
|
fallback,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_message(&self, locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String {
|
pub fn get_message(&self, locale: &Locale, key: &str, args: Option<&MessageArgs>) -> String {
|
||||||
let negotiated = Locale::negotiate(&[locale], &self.available, &self.fallback);
|
let negotiated = Locale::negotiate(&[locale], &self.available, &self.fallback);
|
||||||
|
|
||||||
|
|
@ -256,10 +326,7 @@ impl I18nBundle {
|
||||||
|
|
||||||
fn handle_plurals(template: &str, args: &MessageArgs) -> String {
|
fn handle_plurals(template: &str, args: &MessageArgs) -> String {
|
||||||
let mut result = template.to_string();
|
let mut result = template.to_string();
|
||||||
|
(key, value) in args let count: i64 = value.parse().unwrap_
|
||||||
for (key, value) in args {
|
|
||||||
let count: i64 = value.parse().unwrap_or(0);
|
|
||||||
|
|
||||||
let plural_pattern = format!("{{ ${key} ->");
|
let plural_pattern = format!("{{ ${key} ->");
|
||||||
|
|
||||||
if let Some(start) = result.find(&plural_pattern) {
|
if let Some(start) = result.find(&plural_pattern) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue