use std::str::FromStr; use sea_orm::{ConnectionTrait, EntityTrait}; /// A locale with a shipped server-side catalogue. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum UiLocale { ZhCn, En, } impl UiLocale { /// BCP-47 tag, as written into `lang=""` and into the mail headers. pub fn tag(self) -> &'static str { match self { Self::ZhCn => "zh-CN", Self::En => "en", } } /// Parse one tag, tolerating POSIX spellings (`zh_CN`) and script/region /// variants (`zh-Hans-CN`, `en-US`). /// /// `zh-Hant`/`zh-TW`/`zh-HK`/`zh-MO` deliberately return `None`: serving /// Simplified copy to a Traditional reader is worse than falling back. pub fn parse(raw: &str) -> Option { let tag = raw.trim().to_ascii_lowercase().replace('_', "-"); if tag.is_empty() { return None; } if tag == "zh" || tag.starts_with("zh-") { let rest = &tag[2..]; if rest.contains("hant") || ["-tw", "-hk", "-mo"].iter().any(|s| rest.contains(s)) { return None; } return Some(Self::ZhCn); } if tag == "en" || tag.starts_with("en-") { return Some(Self::En); } None } /// Locale tag for HTTP headers that want an underscore-free form. pub fn header(self) -> &'static str { self.tag() } } impl FromStr for UiLocale { type Err = (); fn from_str(s: &str) -> Result { Self::parse(s).ok_or(()) } } /// Pick a locale from an `Accept-Language` header value. /// /// Honours q-values (including `q=0` as an explicit refusal) and walks the /// client's preference order, so `zh-CN,zh;q=0.9,en;q=0.8` resolves to zh-CN /// while `en-GB,en;q=0.9` resolves to en. pub fn negotiate_accept_language(header: Option<&str>, fallback: UiLocale) -> UiLocale { let Some(header) = header else { return fallback; }; let mut candidates: Vec<(f32, usize, UiLocale)> = Vec::new(); for (index, entry) in header.split(',').enumerate() { let entry = entry.trim(); if entry.is_empty() { continue; } let (tag, q) = match entry.split_once(';') { Some((tag, params)) => { let q = params .trim() .strip_prefix("q=") .and_then(|value| value.trim().parse::().ok()) .unwrap_or(1.0); (tag.trim(), q) } None => (entry, 1.0), }; if q <= 0.0 { continue; } if let Some(locale) = UiLocale::parse(tag) { candidates.push((q, index, locale)); } } // Stable by (descending q, ascending header position); header order wins ties. candidates.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1))); candidates .first() .map(|(_, _, locale)| *locale) .unwrap_or(fallback) } /// Resolve the language for a specific message recipient. /// /// Preference order is fixed and intentional: an account-level choice always /// beats the ambient request, because one request can produce several mails to /// recipients who read different languages. pub fn resolve_locale( user_language: Option<&str>, user_locale: Option<&str>, accept_language: Option<&str>, fallback: UiLocale, ) -> UiLocale { UiLocale::parse(user_language.filter(|v| !v.trim().is_empty()).unwrap_or("")) .or_else(|| UiLocale::parse(user_locale.filter(|v| !v.trim().is_empty()).unwrap_or(""))) .unwrap_or_else(|| negotiate_accept_language(accept_language, fallback)) } /// Batch-read a single user's rendered-locale preference straight from the DB. /// /// Returns `fallback` when the user has no usable preference, so callers can /// pass the result directly to a template. pub async fn resolve_user_locale( db: &C, uid: i64, fallback: UiLocale, ) -> UiLocale { use models::users::user; let row = user::Entity::find_by_id(uid).one(db).await.ok().flatten(); match row { Some(row) => resolve_locale( row.language.as_deref(), row.locale.as_deref(), None, fallback, ), None => fallback, } } /// Batch variant: one query for many recipients, keyed by user id. /// /// Callers that fan a single event out to a whole room or issue watcher list /// must not issue one query per recipient. pub async fn resolve_user_locales( db: &C, uids: &[i64], fallback: UiLocale, ) -> std::collections::HashMap { use models::users::user; use sea_orm::{ColumnTrait, QueryFilter}; let mut out = std::collections::HashMap::new(); if uids.is_empty() { return out; } let rows = user::Entity::find() .filter(user::Column::Uid.is_in(uids.to_vec())) .all(db) .await .unwrap_or_default(); for row in rows { let locale = resolve_locale( row.language.as_deref(), row.locale.as_deref(), None, fallback, ); out.insert(row.uid, locale); } out }