From b462d37bd5b5fbaa4f5274b5fa68a44676097c7c Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 22:57:45 +0000 Subject: [PATCH] Add i18n framework: per-account language, catalogs, t! macro, SET LANGUAGE --- api/src/lib.rs | 80 +++++++++++++++++++++++++++++++++++++ lang/fr.json | 6 +++ modules/nickserv/src/set.rs | 22 ++++++++++ src/config.rs | 26 ++++++++++++ src/engine/db/account.rs | 20 +++++++++- src/engine/db/event.rs | 7 ++++ src/engine/db/mod.rs | 7 +++- src/engine/db/network.rs | 23 +++++++++++ src/engine/db/store.rs | 12 ++++++ src/engine/db/tests.rs | 8 ++-- src/engine/dispatch.rs | 5 ++- src/engine/mod.rs | 2 +- src/engine/tests.rs | 38 +++++++++++++++++- src/grpc.rs | 3 +- src/main.rs | 23 +++++++++++ src/migrate.rs | 2 +- 16 files changed, 272 insertions(+), 12 deletions(-) create mode 100644 lang/fr.json diff --git a/api/src/lib.rs b/api/src/lib.rs index 8213295..fac36bd 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -473,6 +473,53 @@ pub struct Sender<'a> { pub privs: Privs, } +// ── Localization (i18n) ───────────────────────────────────────────────────── +// Messages are authored in English at the call site (the English string is the +// message id). A per-language catalog maps that id to a translation; `render` +// looks it up for the target language and substitutes `{name}` placeholders. With +// no catalog loaded, or no entry, the English id is used verbatim — so English is +// always the zero-config fallback and untranslated messages degrade gracefully. +static CATALOG: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +/// Install the translation catalog: `lang code -> (english msgid -> translation)`. +/// Called once at startup; a no-op if already set. +pub fn load_catalog(catalog: std::collections::HashMap>) { + let _ = CATALOG.set(catalog); +} + +/// Render `msgid` (the English template) into `lang`, substituting `{name}` +/// placeholders from `args`. Falls back to the English `msgid` when there is no +/// translation. Used via the [`t!`] macro; rarely called directly. +pub fn render(lang: &str, msgid: &str, args: &[(&str, String)]) -> String { + let template = CATALOG + .get() + .and_then(|c| c.get(lang)) + .and_then(|m| m.get(msgid)) + .map(String::as_str) + .unwrap_or(msgid); + if args.is_empty() { + return template.to_string(); + } + let mut out = template.to_string(); + for (name, val) in args { + out = out.replace(&format!("{{{name}}}"), val); + } + out +} + +/// Localize a reply. `t!(ctx, "english template", name = value, …)` looks the +/// template up for `ctx`'s language and fills its `{name}` placeholders. The +/// English string IS the message id, so it also reads as the source text. +#[macro_export] +macro_rules! t { + ($ctx:expr, $msgid:literal $(,)?) => { + $crate::render($ctx.lang(), $msgid, &[]) + }; + ($ctx:expr, $msgid:literal, $($name:ident = $val:expr),+ $(,)?) => { + $crate::render($ctx.lang(), $msgid, &[$((stringify!($name), format!("{}", $val))),+]) + }; +} + /// The intent sink a service writes to. A service never mutates the network or /// the store itself; it pushes normalized actions (via [`ServiceCtx::notice`] /// and friends) that the engine drains and applies. @@ -492,6 +539,10 @@ pub struct ServiceCtx { // module can report a reject the engine's verify path never sees — e.g. a // failed IDENTIFY against a cert-only account with no password verifier. pub auth_reports: Vec, + // The language to render this command's replies in (the sender's account + // preference, else the network default). Set by the engine before dispatch; + // empty means English. Read via [`ServiceCtx::lang`] / the `t!` macro. + pub lang: String, } // A login-attempt outcome a module hands back for the auth audit feed. @@ -505,6 +556,15 @@ pub struct AuthReport { } impl ServiceCtx { + /// The language replies should render in (defaults to English). + pub fn lang(&self) -> &str { + if self.lang.is_empty() { + "en" + } else { + &self.lang + } + } + pub fn notice(&mut self, from: &str, to: &str, text: impl Into) { self.actions.push(NetAction::Notice { from: from.to_string(), @@ -1948,6 +2008,10 @@ pub trait Store { fn verify_account(&mut self, account: &str) -> Result<(), RegError>; fn set_email(&mut self, account: &str, email: Option) -> Result<(), RegError>; fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError>; + fn set_language(&mut self, account: &str, language: Option) -> Result<(), RegError>; + fn language_of(&self, account: &str) -> Option; + fn available_languages(&self) -> Vec; + fn default_language(&self) -> String; // NickServ SET AUTOOP: whether this account is auto-opped on join. fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError>; fn account_wants_autoop(&self, account: &str) -> bool; @@ -2798,6 +2862,22 @@ mod help_tests { assert!(texts(&ctx)[0].contains("No help")); } + #[test] + fn render_translates_substitutes_and_falls_back() { + // No/other catalog: the English msgid is used, with args substituted. + assert_eq!(render("en", "Hello \x02{name}\x02!", &[("name", "world".into())]), "Hello \x02world\x02!"); + // Load a French catalog, then translate + substitute. + let mut fr = std::collections::HashMap::new(); + fr.insert("Your language is now \x02{lang}\x02.".to_string(), "Votre langue est désormais \x02{lang}\x02.".to_string()); + load_catalog(std::collections::HashMap::from([("fr".to_string(), fr)])); + assert_eq!( + render("fr", "Your language is now \x02{lang}\x02.", &[("lang", "fr".into())]), + "Votre langue est désormais \x02fr\x02." + ); + // An untranslated id still falls back to the English source even in fr. + assert_eq!(render("fr", "not translated", &[]), "not translated"); + } + #[test] fn valid_email_rejects_header_injection() { assert!(valid_email("alice@example.com")); diff --git a/lang/fr.json b/lang/fr.json new file mode 100644 index 0000000..3c7050e --- /dev/null +++ b/lang/fr.json @@ -0,0 +1,6 @@ +{ + "Your language is \u0002{lang}\u0002. Available: {list}. Change it with \u0002SET LANGUAGE \u0002.": "Votre langue est \u0002{lang}\u0002. Disponibles : {list}. Changez-la avec \u0002SET LANGUAGE \u0002.", + "\u0002{code}\u0002 isn't an available language. Available: {list}.": "\u0002{code}\u0002 n'est pas une langue disponible. Disponibles : {list}.", + "Your language is now \u0002{lang}\u0002.": "Votre langue est maintenant \u0002{lang}\u0002.", + "Sorry, that didn't work. Please try again in a moment.": "Désolé, une erreur est survenue. Veuillez réessayer dans un instant." +} \ No newline at end of file diff --git a/modules/nickserv/src/set.rs b/modules/nickserv/src/set.rs index e06b6d4..83547d7 100644 --- a/modules/nickserv/src/set.rs +++ b/modules/nickserv/src/set.rs @@ -1,5 +1,6 @@ use echo_api::{ForbidKind, Store}; use echo_api::{Sender, ServiceCtx}; +use echo_api::t; // SET PASSWORD | SET EMAIL [address]: change your account settings. pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) { @@ -45,6 +46,27 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."), } } + Some("LANGUAGE") | Some("LANG") => { + let available = db.available_languages(); + match args.get(2) { + None => { + let current = db.language_of(account).unwrap_or_else(|| db.default_language()); + ctx.notice(me, from.uid, t!(ctx, "Your language is \x02{lang}\x02. Available: {list}. Change it with \x02SET LANGUAGE \x02.", lang = current, list = available.join(", "))); + } + Some(&code) => { + let code = code.to_ascii_lowercase(); + if !available.contains(&code) { + ctx.notice(me, from.uid, t!(ctx, "\x02{code}\x02 isn't an available language. Available: {list}.", code = code, list = available.join(", "))); + return; + } + match db.set_language(account, Some(code.clone())) { + // Confirm in the NEWLY chosen language, so it's visibly applied. + Ok(()) => ctx.notice(me, from.uid, echo_api::render(&code, "Your language is now \x02{lang}\x02.", &[("lang", code.clone())])), + Err(_) => ctx.notice(me, from.uid, t!(ctx, "Sorry, that didn't work. Please try again in a moment.")), + } + } + } + } Some("HIDE") => { // Anope grammar is SET HIDE {ON|OFF}. The only field that is // public here is the last-seen/online line (STATUS); USERMASK is an diff --git a/src/config.rs b/src/config.rs index 5c83b7f..a93cf3a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,11 @@ pub struct Config { // start. Bind to localhost; unauthenticated by design (read-only gauges). #[serde(default)] pub health: Option, + // Localization. Absent = English only. `default` is the reply language for + // users with no preference; `dir` holds `.json` catalogs; `available` + // lists the codes a user may pick with NickServ SET LANGUAGE. + #[serde(default)] + pub language: Option, // Which service modules to start. Absent = the full standard suite (all the // pseudo-clients); listing it trims that set. Every service is first-class. #[serde(default)] @@ -63,6 +68,27 @@ pub struct Config { pub extban: Option, } +#[derive(Debug, Deserialize, Clone)] +pub struct Language { + // Reply language for users who haven't picked one (a code like "en" or "fr"). + #[serde(default = "default_language")] + pub default: String, + // Directory holding the `.json` translation catalogs (english id -> text). + #[serde(default = "default_language_dir")] + pub dir: String, + // Codes a user may select with NickServ SET LANGUAGE. Empty = only the default. + #[serde(default)] + pub available: Vec, +} + +fn default_language() -> String { + "en".to_string() +} + +fn default_language_dir() -> String { + "lang".to_string() +} + #[derive(Debug, Deserialize, Clone)] pub struct Extban { // Enabled extban names ("account", "realname", "country", …). Empty = all. diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index f5060dc..461006c 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -22,7 +22,7 @@ impl Db { ajoin: Vec::new(), suspension: None, memos: Vec::new(), memo_ignore: Vec::new(), memo_notify: true, memo_limit: None, - greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, + greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: ts, @@ -78,7 +78,7 @@ impl Db { ajoin: Vec::new(), suspension: None, memos: Vec::new(), memo_ignore: Vec::new(), memo_notify: true, memo_limit: None, - greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, + greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: ts, @@ -524,6 +524,22 @@ impl Db { Ok(()) } + /// Set (or clear, with None) `account`'s preferred reply language (SET LANGUAGE). + pub fn set_language(&mut self, account: &str, language: Option) -> Result<(), RegError> { + let k = key(account); + if !self.accounts.contains_key(&k) { + return Err(RegError::Internal); + } + self.log.append(Event::AccountLanguageSet { account: account.to_string(), language: language.clone() }).map_err(|_| RegError::Internal)?; + self.accounts.get_mut(&k).unwrap().language = language; + Ok(()) + } + + /// `account`'s preferred language, if it set one. + pub fn language_of(&self, account: &str) -> Option { + self.accounts.get(&key(account)).and_then(|a| a.language.clone()) + } + /// Set whether `account` wants to be auto-opped on join (NickServ SET AUTOOP). pub fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError> { let k = key(account); diff --git a/src/engine/db/event.rs b/src/engine/db/event.rs index 8028432..05e5f46 100644 --- a/src/engine/db/event.rs +++ b/src/engine/db/event.rs @@ -15,6 +15,7 @@ pub enum Event { CertRemoved { account: String, fp: String }, AccountEmailSet { account: String, email: Option }, AccountGreetSet { account: String, greet: String }, + AccountLanguageSet { account: String, language: Option }, AccountAutoOpSet { account: String, on: bool }, AccountKillSet { account: String, on: bool }, AccountHideStatusSet { account: String, on: bool }, @@ -186,6 +187,7 @@ impl Event { | Event::CertRemoved { .. } | Event::AccountEmailSet { .. } | Event::AccountGreetSet { .. } + | Event::AccountLanguageSet { .. } | Event::AccountAutoOpSet { .. } | Event::AccountKillSet { .. } | Event::AccountHideStatusSet { .. } @@ -358,6 +360,11 @@ pub(crate) fn apply(accounts: &mut HashMap, channels: &mut Hash a.greet = greet; } } + Event::AccountLanguageSet { account, language } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.language = language; + } + } Event::AccountAutoOpSet { account, on } => { if let Some(a) = accounts.get_mut(&key(&account)) { a.no_autoop = !on; diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 0b60e88..5950d3c 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -112,6 +112,9 @@ pub struct Account { // pseudoclient. Default (normal notice) is the zero value. #[serde(default)] pub snotice: bool, + // NickServ SET LANGUAGE: preferred reply language (None = the network default). + #[serde(default)] + pub language: Option, // Assigned vhost (HostServ), applied to the displayed host on identify. #[serde(default)] pub vhost: Option, @@ -1166,6 +1169,8 @@ pub struct Db { // against accounts pushed in (via the gRPC Accounts API). Node-local config. external_accounts: bool, confusable_check: bool, // reject look-alike / mixed-script REGISTER names + default_language: String, // network-wide reply language for users with no preference + available_languages: Vec, // language codes a user may pick with SET LANGUAGE } // A juped server: the held name, the fake server id we allocated for it, and why. @@ -1234,7 +1239,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true } + Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true, default_language: "en".to_string(), available_languages: vec!["en".to_string()] } } /// Fold an entry authored by another node into the store — the services-side diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index 339b5dc..6682309 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -355,6 +355,29 @@ impl Db { self.confusable_check = on; } + /// The network-wide reply language for users who haven't set a preference. + pub fn set_default_language(&mut self, lang: &str) { + if !lang.is_empty() { + self.default_language = lang.to_string(); + } + } + + pub fn default_language(&self) -> String { + self.default_language.clone() + } + + /// The language codes a user may select (always includes the default). + pub fn set_available_languages(&mut self, langs: Vec) { + self.available_languages = langs; + if !self.available_languages.contains(&self.default_language) { + self.available_languages.push(self.default_language.clone()); + } + } + + pub fn available_languages(&self) -> &[String] { + &self.available_languages + } + pub fn confusable_check_enabled(&self) -> bool { self.confusable_check } diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 7c4881d..be033c1 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -105,6 +105,18 @@ impl Store for Db { fn set_greet(&mut self, account: &str, greet: &str) -> Result<(), RegError> { Db::set_greet(self, account, greet) } + fn set_language(&mut self, account: &str, language: Option) -> Result<(), RegError> { + Db::set_language(self, account, language) + } + fn language_of(&self, account: &str) -> Option { + Db::language_of(self, account) + } + fn available_languages(&self) -> Vec { + Db::available_languages(self).to_vec() + } + fn default_language(&self) -> String { + Db::default_language(self) + } fn set_account_autoop(&mut self, account: &str, on: bool) -> Result<(), RegError> { Db::set_account_autoop(self, account, on) } diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index 8dce71f..cf66719 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -32,7 +32,7 @@ // which of the two competing registrations we're looking at. let alice = |tag: &str, ts: u64, home: &str| Account { name: "alice".into(), email: Some(tag.into()), - ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None, + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default()); @@ -81,7 +81,7 @@ scram256: Some(verifier.into()), scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, - hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, + hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, oper_note: None, }; let reg = |origin: &str, a: Account| LogEntry::for_test(origin, 0, 1, Event::AccountRegistered(Box::new(a))); @@ -439,7 +439,7 @@ db.register("alice", "pw", None).unwrap(); let bob = Account { name: "bob".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, epoch: 0, sig: None, event: Event::AccountRegistered(Box::new(bob)) }; db.ingest(entry).unwrap(); @@ -550,7 +550,7 @@ scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, - hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 1, + hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 1, noexpire: false, expiry_warned: false, oper_note: None, }))).unwrap(); } diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index 0ce5488..4a16ba2 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -7,7 +7,10 @@ impl Engine { let nick = self.network.nick_of(from).unwrap_or(from).to_string(); let account = self.network.account_of(from).map(str::to_string); let privs = account.as_deref().map(|a| self.oper_privs(a)).unwrap_or_default(); - let mut ctx = ServiceCtx::default(); + // Render this command's replies in the sender's language: their account + // preference if set, otherwise the network default. + let lang = account.as_deref().and_then(|a| self.db.language_of(a)).unwrap_or_else(|| self.db.default_language()); + let mut ctx = ServiceCtx { lang, ..Default::default() }; // Mark the log so we can tell exactly which events this command appends. let audit_mark = self.db.log_len(); let sender = Sender { uid: from, nick: &nick, account: account.as_deref(), privs }; diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 28393fd..63f3fdf 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1935,7 +1935,7 @@ fn audit_summary(event: &db::Event) -> Option { format!("{verb} channel \x02{channel}\x02 against expiry") } // Private, self-service, or cosmetic — not surfaced. - AjoinAdded { .. } | AjoinRemoved { .. } | AccountGreetSet { .. } | AccountAutoOpSet { .. } | AccountKillSet { .. } | AccountHideStatusSet { .. } | AccountSnoticeSet { .. } | VhostRequested { .. } + AjoinAdded { .. } | AjoinRemoved { .. } | AccountGreetSet { .. } | AccountLanguageSet { .. } | AccountAutoOpSet { .. } | AccountKillSet { .. } | AccountHideStatusSet { .. } | AccountSnoticeSet { .. } | VhostRequested { .. } | VhostRequestCleared { .. } | MemoSent { .. } | MemoRead { .. } | MemoDeleted { .. } | MemoIgnoreAdd { .. } | MemoIgnoreDel { .. } | MemoPrefsSet { .. } | ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelUrlSet { .. } | ChannelEmailSet { .. } | ChannelSettingsSet { .. } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 639a105..8d4eea0 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -418,6 +418,42 @@ e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "LOGOUT".into() }) } + // SET LANGUAGE stores the preference and switches replies to that language: + // the confirmation renders in the newly-chosen language, and a later command + // resolves the language from the account via the dispatch path. + #[test] + fn set_language_switches_reply_language() { + use echo_nickserv::NickServ; + // Minimal French catalog for this test process. English tests are unaffected: + // they resolve to "en" and fall back to the English source string. + let mut fr = std::collections::HashMap::new(); + fr.insert("Your language is now \x02{lang}\x02.".to_string(), "Votre langue est maintenant \x02{lang}\x02.".to_string()); + fr.insert( + "Your language is \x02{lang}\x02. Available: {list}. Change it with \x02SET LANGUAGE \x02.".to_string(), + "Votre langue est \x02{lang}\x02. Disponibles : {list}.".to_string(), + ); + echo_api::load_catalog(std::collections::HashMap::from([("fr".to_string(), fr)])); + + let path = std::env::temp_dir().join("echo-setlang.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.set_available_languages(vec!["fr".to_string()]); + db.register("bob", "password1", None).unwrap(); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "bob".into(), host: "h".into() , ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY password1".into() }); + let ns = |e: &mut Engine, t: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: t.into() }); + + let out = ns(&mut e, "SET LANGUAGE fr"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Votre langue est maintenant"))), "confirmation is French: {out:?}"); + assert_eq!(e.db.language_of("bob").as_deref(), Some("fr"), "the preference is stored"); + + // A later command now resolves to French via the dispatch path (bob's pref). + let out2 = ns(&mut e, "SET LANGUAGE"); + assert!(out2.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Disponibles"))), "later reply is French via ctx.lang: {out2:?}"); + } + // A guest rename must skip a nick that's already online, or it would collide // with (and get) an innocent user instead of freeing the name. #[test] @@ -902,7 +938,7 @@ // An earlier claim from another node wins and takes the name over. let winner = db::Account { name: "alice".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner))); e.gossip_ingest(entry).unwrap(); diff --git a/src/grpc.rs b/src/grpc.rs index 0d7a2e0..95656e2 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -128,6 +128,7 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::CertRemoved { .. } | Event::AccountPasswordSet { .. } | Event::AccountGreetSet { .. } + | Event::AccountLanguageSet { .. } | Event::AccountAutoOpSet { .. } | Event::AccountKillSet { .. } | Event::AccountHideStatusSet { .. } @@ -493,7 +494,7 @@ mod tests { memo_ignore: vec![], memo_notify: true, memo_limit: None, - greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, + greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, language: None, vhost: None, vhost_request: None, last_seen: 111, diff --git a/src/main.rs b/src/main.rs index b1dce74..7d84b6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -209,6 +209,29 @@ async fn main() -> Result<()> { db.set_email_enabled(cfg.email.is_some()); db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); db.set_confusable_check(cfg.register.confusable_check); + if let Some(lang) = &cfg.language { + db.set_default_language(&lang.default); + db.set_available_languages(lang.available.clone()); + // Load a JSON catalog (english msgid -> translation) per non-English code. + let mut catalog = std::collections::HashMap::new(); + for code in db.available_languages().to_vec() { + if code == "en" { + continue; // English needs no catalog: the msgid is the English text + } + let path = format!("{}/{}.json", lang.dir, code); + match std::fs::read_to_string(&path) { + Ok(data) => match serde_json::from_str::>(&data) { + Ok(map) => { + tracing::info!(code = %code, entries = map.len(), "loaded translation catalog"); + catalog.insert(code, map); + } + Err(e) => tracing::error!(%e, %path, "invalid translation catalog JSON"), + }, + Err(e) => tracing::warn!(%e, %path, "translation catalog not found"), + } + } + echo_api::load_catalog(catalog); + } match cfg.extban.as_ref().map(|e| e.enabled.as_slice()) { Some(list) if !list.is_empty() => { db.set_extban_enabled(list.to_vec()); diff --git a/src/migrate.rs b/src/migrate.rs index b11a2be..3a9498c 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -204,7 +204,7 @@ pub fn import_anope(anope_path: &str, out_path: &str, node: &str) -> std::io::Re greet: String::new(), no_autoop: !flag(nc, "AUTOOP"), no_protect: !flag(nc, "PROTECT"), - hide_status: flag(nc, "HIDE_MASK"), snotice: false, + hide_status: flag(nc, "HIDE_MASK"), snotice: false, language: None, vhost, vhost_request: None, last_seen: last_seen_of.get(name).copied().unwrap_or(ts),