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"]
|
||||
validation = []
|
||||
resilience = []
|
||||
i18n = []
|
||||
i18n = ["dep:rust-embed"]
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
|
|
@ -34,6 +34,9 @@ tokio = { workspace = true, features = ["sync", "time"] }
|
|||
# Optional: HTTP Client
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls"], optional = true }
|
||||
|
||||
# Optional: i18n
|
||||
rust-embed = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt", "macros"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,16 @@ use std::collections::HashMap;
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
use super::Locale;
|
||||
|
||||
#[cfg(feature = "i18n")]
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "locales"]
|
||||
struct EmbeddedLocales;
|
||||
|
||||
pub type MessageArgs = HashMap<String, String>;
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -80,13 +88,11 @@ impl LocaleBundle {
|
|||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| BotError::config("invalid locale directory name"))?;
|
||||
|
||||
let locale = Locale::new(dir_name)
|
||||
.ok_or_else(|| BotError::config(format!("invalid locale: {dir_name}")))?;
|
||||
.map_err(|e| locale = Locale::new(dir_name) .ok_or_else(|| BotError::config(format!("invalid locale: {dir_name}")))?;
|
||||
|
||||
let mut translations = TranslationFile {
|
||||
messages: HashMap::new(),
|
||||
};
|
||||
|
||||
messages: HashMap
|
||||
};.map_err(|e|
|
||||
let entries = fs::read_dir(locale_dir).map_err(|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> {
|
||||
self.translations.get(key)
|
||||
}
|
||||
|
|
@ -134,6 +164,12 @@ impl I18nBundle {
|
|||
let base = Path::new(base_path);
|
||||
|
||||
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!(
|
||||
"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 {
|
||||
let negotiated = Locale::negotiate(&[locale], &self.available, &self.fallback);
|
||||
|
||||
|
|
@ -256,10 +326,7 @@ impl I18nBundle {
|
|||
|
||||
fn handle_plurals(template: &str, args: &MessageArgs) -> String {
|
||||
let mut result = template.to_string();
|
||||
|
||||
for (key, value) in args {
|
||||
let count: i64 = value.parse().unwrap_or(0);
|
||||
|
||||
(key, value) in args let count: i64 = value.parse().unwrap_
|
||||
let plural_pattern = format!("{{ ${key} ->");
|
||||
|
||||
if let Some(start) = result.find(&plural_pattern) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue