Skip to content
Clone
main
166 lines 5.2 KB Rust
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<Self> {
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, Self::Err> {
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::<f32>().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<C: ConnectionTrait>(
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<C: ConnectionTrait>(
db: &C,
uids: &[i64],
fallback: UiLocale,
) -> std::collections::HashMap<i64, UiLocale> {
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
}