From e4a14b98131e2892bb7a761e5db2729108697086 Mon Sep 17 00:00:00 2001 From: Jean Date: Mon, 20 Jul 2026 02:25:54 +0000 Subject: [PATCH] Localize engine notices, transactional emails, and operator-access denials in all six languages --- api/src/email.rs | 71 ++++++++++++++++++++----------- api/src/lib.rs | 26 +++++++---- lang/de.json | 27 +++++++++++- lang/es-ar.json | 27 +++++++++++- lang/es.json | 27 +++++++++++- lang/fr.json | 27 +++++++++++- lang/pt-br.json | 27 +++++++++++- lang/pt.json | 27 +++++++++++- modules/nickserv/src/resetpass.rs | 3 +- src/engine/kicker.rs | 3 +- src/engine/mod.rs | 27 +++++++++--- src/engine/register.rs | 18 ++++---- src/i18n_check.rs | 44 +++++++++++++++++++ 13 files changed, 299 insertions(+), 55 deletions(-) diff --git a/api/src/email.rs b/api/src/email.rs index 92e888b..a41beba 100644 --- a/api/src/email.rs +++ b/api/src/email.rs @@ -33,38 +33,44 @@ fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, cod .replace("{{note}}", &escape(note)) } -pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str) -> Mail { +pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail { + let acc = || vec![("account", account.to_string())]; Mail { - subject: format!("Password reset for {account}"), - text: format!( - "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n" + subject: crate::render(lang, "Password reset for {account}", &acc()), + text: crate::render( + lang, + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n", + &[("account", account.to_string()), ("code", code.to_string())], ), html: render( brand, accent, logo, - "Password reset", - &format!("Use this code to reset the password for your account {account}."), + &crate::render(lang, "Password reset", &[]), + &crate::render(lang, "Use this code to reset the password for your account {account}.", &acc()), code, - "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.", + &crate::render(lang, "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.", &[]), ), } } -pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str) -> Mail { +pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail { + let acc = || vec![("account", account.to_string())]; Mail { - subject: format!("Confirm your {account} registration"), - text: format!( - "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n" + subject: crate::render(lang, "Confirm your {account} registration", &acc()), + text: crate::render( + lang, + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n", + &[("account", account.to_string()), ("code", code.to_string())], ), html: render( brand, accent, logo, - "Confirm your account", - &format!("Welcome! Confirm the email for your account {account} with the code below."), + &crate::render(lang, "Confirm your account", &[]), + &crate::render(lang, "Welcome! Confirm the email for your account {account} with the code below.", &acc()), code, - "This code expires in 15 minutes.", + &crate::render(lang, "This code expires in 15 minutes.", &[]), ), } } @@ -87,26 +93,39 @@ impl ExpiryTarget { // Warn the owner of an account or channel that inactivity will soon expire it. // `remaining` is a human span ("7 days") and takes the prominent code slot. -pub fn expiry_warning(brand: &str, accent: &str, logo: &str, kind: ExpiryTarget, name: &str, remaining: &str) -> Mail { - let word = kind.word(); - let keep = if kind == ExpiryTarget::Channel { - "To keep it, have a member join the channel before then. Otherwise it will be removed." - } else { - "To keep it, just identify to it before then. Otherwise it will be removed." +pub fn expiry_warning(brand: &str, accent: &str, logo: &str, kind: ExpiryTarget, name: &str, remaining: &str, lang: &str) -> Mail { + let word = crate::render(lang, kind.word(), &[]); + let keep = crate::render( + lang, + if kind == ExpiryTarget::Channel { + "To keep it, have a member join the channel before then. Otherwise it will be removed." + } else { + "To keep it, just identify to it before then. Otherwise it will be removed." + }, + &[], + ); + let base = || { + vec![ + ("word", word.clone()), + ("name", name.to_string()), + ("remaining", remaining.to_string()), + ] }; Mail { - subject: format!("Your {word} {name} is about to expire"), - text: format!( - "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n" + subject: crate::render(lang, "Your {word} {name} is about to expire", &[("word", word.clone()), ("name", name.to_string())]), + text: crate::render( + lang, + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n", + &[("word", word.clone()), ("name", name.to_string()), ("remaining", remaining.to_string()), ("keep", keep.clone())], ), html: render( brand, accent, logo, - "About to expire", - &format!("Your {word} {name} has been inactive and will expire in {remaining}."), + &crate::render(lang, "About to expire", &[]), + &crate::render(lang, "Your {word} {name} has been inactive and will expire in {remaining}.", &base()), remaining, - keep, + &keep, ), } } diff --git a/api/src/lib.rs b/api/src/lib.rs index 55d3195..a2eba54 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -545,19 +545,27 @@ pub fn plural_category(lang: &str, n: u64) -> Plural { } } +/// Render the `one` or `other` English template for `n` under `lang`'s plural +/// rule, then substitute `{name}` args. The [`plural!`] macro wraps this for +/// service call sites; the engine and email layer (which have no `ctx`) call it +/// directly with an explicit language. +pub fn render_plural(lang: &str, n: u64, one: &str, other: &str, args: &[(&str, String)]) -> String { + let key = match plural_category(lang, n) { + Plural::One => one, + Plural::Other => other, + }; + render(lang, key, args) +} + /// Localize a count-dependent reply. Selects the `one` or `other` English /// template by the target language's plural rule, then renders it like [`t!`]. /// Both forms are message ids, so both need a catalog entry in each language. /// `plural!(ctx, n, one = "…{n} entry…", other = "…{n} entries…", n = n, …)`. #[macro_export] macro_rules! plural { - ($ctx:expr, $n:expr, one = $one:literal, other = $other:literal $(, $name:ident = $val:expr)* $(,)?) => {{ - let __key = match $crate::plural_category($ctx.lang(), ($n) as u64) { - $crate::Plural::One => $one, - $crate::Plural::Other => $other, - }; - $crate::render($ctx.lang(), __key, &[$((stringify!($name), format!("{}", $val))),*]) - }}; + ($ctx:expr, $n:expr, one = $one:literal, other = $other:literal $(, $name:ident = $val:expr)* $(,)?) => { + $crate::render_plural($ctx.lang(), ($n) as u64, $one, $other, &[$((stringify!($name), format!("{}", $val))),*]) + }; } /// The intent sink a service writes to. A service never mutates the network or @@ -2594,7 +2602,9 @@ pub fn require_oper(me: &str, from: &Sender, ctx: &mut ServiceCtx, need: Option< None => from.privs.any(), }; if !ok { - ctx.notice(me, from.uid, format!("Access denied — {what} is for services operators.")); + // Translate the action fragment, then the sentence around it. + let what = render(ctx.lang(), what, &[]); + ctx.notice(me, from.uid, render(ctx.lang(), "Access denied — {what} is for services operators.", &[("what", what)])); } ok } diff --git a/lang/de.json b/lang/de.json index 87b750e..2761f88 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1308,5 +1308,30 @@ "{n} report. \u0002VIEW\u0002 for detail.": "{n} Meldung. \u0002VIEW\u0002 für Details.", "{n} reports. \u0002VIEW\u0002 for detail.": "{n} Meldungen. \u0002VIEW\u0002 für Details.", "End of list ({count} group).": "Ende der Liste ({count} Gruppe).", - "End of list ({count} groups).": "Ende der Liste ({count} Gruppen)." + "End of list ({count} groups).": "Ende der Liste ({count} Gruppen).", + "Password reset for {account}": "Passwort-Zurücksetzung für {account}", + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n": "Dein Code zum Zurücksetzen des Passworts für {account} lautet: {code}\nEr läuft in 15 Minuten ab.\nSetze es zurück mit:\n /msg NickServ RESETPASS {account} {code} \n", + "Password reset": "Passwort zurücksetzen", + "Use this code to reset the password for your account {account}.": "Verwende diesen Code, um das Passwort für dein Konto {account} zurückzusetzen.", + "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.": "Dieser Code läuft in 15 Minuten ab. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.", + "Confirm your {account} registration": "Bestätige deine Registrierung für {account}", + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n": "Bestätige dein Konto {account} mit:\n /msg NickServ CONFIRM {code}\nDer Code läuft in 15 Minuten ab.\n", + "Confirm your account": "Bestätige dein Konto", + "Welcome! Confirm the email for your account {account} with the code below.": "Willkommen! Bestätige die E-Mail für dein Konto {account} mit dem folgenden Code.", + "This code expires in 15 minutes.": "Dieser Code läuft in 15 Minuten ab.", + "Your {word} {name} is about to expire": "Dein {word} {name} läuft bald ab", + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n": "Dein {word} {name} war inaktiv und läuft in {remaining} ab.\n{keep}\n", + "About to expire": "Läuft bald ab", + "Your {word} {name} has been inactive and will expire in {remaining}.": "Dein {word} {name} war inaktiv und läuft in {remaining} ab.", + "To keep it, have a member join the channel before then. Otherwise it will be removed.": "Damit er erhalten bleibt, lass vorher ein Mitglied den Kanal betreten. Andernfalls wird er entfernt.", + "To keep it, just identify to it before then. Otherwise it will be removed.": "Damit er erhalten bleibt, identifiziere dich vorher einfach dafür. Andernfalls wird er entfernt.", + "account": "Konto", + "channel": "Kanal", + "You're now identified as \u0002{account}\u0002. Welcome back!": "Du bist jetzt als \u0002{account}\u0002 identifiziert. Willkommen zurück!", + "You have been logged out of \u0002{account}\u0002.": "Du wurdest von \u0002{account}\u0002 abgemeldet.", + "A confirmation code has been emailed to you. Confirm with \u0002CONFIRM \u0002.": "Ein Bestätigungscode wurde dir per E-Mail geschickt. Bestätige mit \u0002CONFIRM \u0002.", + "You are now the founder of \u0002{chan}\u0002 (inherited from \u0002{account}\u0002).": "Du bist jetzt der Gründer von \u0002{chan}\u0002 (übernommen von \u0002{account}\u0002).", + "You didn't identify in time; you've been renamed.": "Du hast dich nicht rechtzeitig identifiziert; du wurdest umbenannt.", + "Please mind the channel rules — {reason} Next time you'll be kicked.": "Bitte beachte die Kanalregeln — {reason} Beim nächsten Mal wirst du gekickt.", + "Access denied — {what} is for services operators.": "Zugriff verweigert — {what} ist für Services-Operatoren." } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 20054c7..cd81319 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1308,5 +1308,30 @@ "{n} report. \u0002VIEW\u0002 for detail.": "{n} reporte. \u0002VIEW\u0002 para el detalle.", "{n} reports. \u0002VIEW\u0002 for detail.": "{n} reportes. \u0002VIEW\u0002 para el detalle.", "End of list ({count} group).": "Fin de la lista ({count} grupo).", - "End of list ({count} groups).": "Fin de la lista ({count} grupos)." + "End of list ({count} groups).": "Fin de la lista ({count} grupos).", + "Password reset for {account}": "Restablecimiento de contraseña para {account}", + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n": "Tu código para restablecer la contraseña de {account} es: {code}\nVence en 15 minutos.\nRestablecela con:\n /msg NickServ RESETPASS {account} {code} \n", + "Password reset": "Restablecimiento de contraseña", + "Use this code to reset the password for your account {account}.": "Usá este código para restablecer la contraseña de tu cuenta {account}.", + "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.": "Este código vence en 15 minutos. Si no pediste restablecerla, ignorá este correo.", + "Confirm your {account} registration": "Confirmá el registro de {account}", + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n": "Confirmá tu cuenta {account} con:\n /msg NickServ CONFIRM {code}\nEl código vence en 15 minutos.\n", + "Confirm your account": "Confirmá tu cuenta", + "Welcome! Confirm the email for your account {account} with the code below.": "¡Bienvenido! Confirmá el correo de tu cuenta {account} con el código de abajo.", + "This code expires in 15 minutes.": "Este código vence en 15 minutos.", + "Your {word} {name} is about to expire": "Tu {word} {name} está por vencer", + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n": "Tu {word} {name} estuvo inactivo y va a vencer en {remaining}.\n{keep}\n", + "About to expire": "Está por vencer", + "Your {word} {name} has been inactive and will expire in {remaining}.": "Tu {word} {name} estuvo inactivo y va a vencer en {remaining}.", + "To keep it, have a member join the channel before then. Otherwise it will be removed.": "Para conservarlo, hacé que un miembro entre al canal antes de eso. Si no, se va a eliminar.", + "To keep it, just identify to it before then. Otherwise it will be removed.": "Para conservarlo, identificate a él antes de eso. Si no, se va a eliminar.", + "account": "cuenta", + "channel": "canal", + "You're now identified as \u0002{account}\u0002. Welcome back!": "Ahora estás identificado como \u0002{account}\u0002. ¡Bienvenido de nuevo!", + "You have been logged out of \u0002{account}\u0002.": "Se cerró tu sesión de \u0002{account}\u0002.", + "A confirmation code has been emailed to you. Confirm with \u0002CONFIRM \u0002.": "Se te envió un código de confirmación por correo. Confirmá con \u0002CONFIRM \u0002.", + "You are now the founder of \u0002{chan}\u0002 (inherited from \u0002{account}\u0002).": "Ahora sos el fundador de \u0002{chan}\u0002 (heredado de \u0002{account}\u0002).", + "You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te cambió el nombre.", + "Please mind the channel rules — {reason} Next time you'll be kicked.": "Prestá atención a las reglas del canal — {reason} La próxima vez te van a expulsar.", + "Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de los servicios." } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 5194a24..ee377df 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1308,5 +1308,30 @@ "{n} report. \u0002VIEW\u0002 for detail.": "{n} reporte. \u0002VIEW\u0002 para ver el detalle.", "{n} reports. \u0002VIEW\u0002 for detail.": "{n} reportes. \u0002VIEW\u0002 para ver el detalle.", "End of list ({count} group).": "Fin de la lista ({count} grupo).", - "End of list ({count} groups).": "Fin de la lista ({count} grupos)." + "End of list ({count} groups).": "Fin de la lista ({count} grupos).", + "Password reset for {account}": "Restablecimiento de contraseña para {account}", + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n": "Tu código para restablecer la contraseña de {account} es: {code}\nCaduca en 15 minutos.\nRestablécela con:\n /msg NickServ RESETPASS {account} {code} \n", + "Password reset": "Restablecimiento de contraseña", + "Use this code to reset the password for your account {account}.": "Usa este código para restablecer la contraseña de tu cuenta {account}.", + "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.": "Este código caduca en 15 minutos. Si no pediste restablecerla, ignora este correo.", + "Confirm your {account} registration": "Confirma tu registro de {account}", + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n": "Confirma tu cuenta {account} con:\n /msg NickServ CONFIRM {code}\nEl código caduca en 15 minutos.\n", + "Confirm your account": "Confirma tu cuenta", + "Welcome! Confirm the email for your account {account} with the code below.": "¡Bienvenido! Confirma el correo de tu cuenta {account} con el código de abajo.", + "This code expires in 15 minutes.": "Este código caduca en 15 minutos.", + "Your {word} {name} is about to expire": "Tu {word} {name} está a punto de caducar", + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n": "Tu {word} {name} ha estado inactivo y caducará en {remaining}.\n{keep}\n", + "About to expire": "A punto de caducar", + "Your {word} {name} has been inactive and will expire in {remaining}.": "Tu {word} {name} ha estado inactivo y caducará en {remaining}.", + "To keep it, have a member join the channel before then. Otherwise it will be removed.": "Para conservarlo, haz que un miembro entre en el canal antes de esa fecha. De lo contrario se eliminará.", + "To keep it, just identify to it before then. Otherwise it will be removed.": "Para conservarla, solo identifícate con ella antes de esa fecha. De lo contrario se eliminará.", + "account": "cuenta", + "channel": "canal", + "You're now identified as \u0002{account}\u0002. Welcome back!": "Ahora estás identificado como \u0002{account}\u0002. ¡Bienvenido de nuevo!", + "You have been logged out of \u0002{account}\u0002.": "Se ha cerrado tu sesión de \u0002{account}\u0002.", + "A confirmation code has been emailed to you. Confirm with \u0002CONFIRM \u0002.": "Se te ha enviado un código de confirmación por correo. Confírmalo con \u0002CONFIRM \u0002.", + "You are now the founder of \u0002{chan}\u0002 (inherited from \u0002{account}\u0002).": "Ahora eres el fundador de \u0002{chan}\u0002 (heredado de \u0002{account}\u0002).", + "You didn't identify in time; you've been renamed.": "No te identificaste a tiempo; se te ha cambiado el nombre.", + "Please mind the channel rules — {reason} Next time you'll be kicked.": "Ten en cuenta las reglas del canal — {reason} La próxima vez se te expulsará.", + "Access denied — {what} is for services operators.": "Acceso denegado — {what} es solo para operadores de servicios." } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index ee8757d..825e607 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1308,5 +1308,30 @@ "{n} report. \u0002VIEW\u0002 for detail.": "{n} signalement. \u0002VIEW\u0002 pour le détail.", "{n} reports. \u0002VIEW\u0002 for detail.": "{n} signalements. \u0002VIEW\u0002 pour le détail.", "End of list ({count} group).": "Fin de la liste ({count} groupe).", - "End of list ({count} groups).": "Fin de la liste ({count} groupes)." + "End of list ({count} groups).": "Fin de la liste ({count} groupes).", + "Password reset for {account}": "Réinitialisation du mot de passe pour {account}", + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n": "Votre code de réinitialisation de mot de passe pour {account} est : {code}\nIl expire dans 15 minutes.\nRéinitialisez-le avec :\n /msg NickServ RESETPASS {account} {code} \n", + "Password reset": "Réinitialisation du mot de passe", + "Use this code to reset the password for your account {account}.": "Utilisez ce code pour réinitialiser le mot de passe de votre compte {account}.", + "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.": "Ce code expire dans 15 minutes. Si vous n'avez pas demandé cette réinitialisation, ignorez cet e-mail.", + "Confirm your {account} registration": "Confirmez l'enregistrement de {account}", + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n": "Confirmez votre compte {account} avec :\n /msg NickServ CONFIRM {code}\nLe code expire dans 15 minutes.\n", + "Confirm your account": "Confirmez votre compte", + "Welcome! Confirm the email for your account {account} with the code below.": "Bienvenue ! Confirmez l'e-mail de votre compte {account} avec le code ci-dessous.", + "This code expires in 15 minutes.": "Ce code expire dans 15 minutes.", + "Your {word} {name} is about to expire": "Votre {word} {name} est sur le point d'expirer", + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n": "Votre {word} {name} est resté inactif et expirera dans {remaining}.\n{keep}\n", + "About to expire": "Sur le point d'expirer", + "Your {word} {name} has been inactive and will expire in {remaining}.": "Votre {word} {name} est resté inactif et expirera dans {remaining}.", + "To keep it, have a member join the channel before then. Otherwise it will be removed.": "Pour le conserver, faites en sorte qu'un membre rejoigne le canal avant cette échéance. Sinon, il sera supprimé.", + "To keep it, just identify to it before then. Otherwise it will be removed.": "Pour le conserver, il vous suffit de vous y identifier avant cette échéance. Sinon, il sera supprimé.", + "account": "compte", + "channel": "canal", + "You're now identified as \u0002{account}\u0002. Welcome back!": "Vous êtes maintenant identifié en tant que \u0002{account}\u0002. Bon retour !", + "You have been logged out of \u0002{account}\u0002.": "Vous avez été déconnecté de \u0002{account}\u0002.", + "A confirmation code has been emailed to you. Confirm with \u0002CONFIRM \u0002.": "Un code de confirmation vous a été envoyé par e-mail. Confirmez avec \u0002CONFIRM \u0002.", + "You are now the founder of \u0002{chan}\u0002 (inherited from \u0002{account}\u0002).": "Vous êtes maintenant le fondateur de \u0002{chan}\u0002 (hérité de \u0002{account}\u0002).", + "You didn't identify in time; you've been renamed.": "Vous ne vous êtes pas identifié à temps ; vous avez été renommé.", + "Please mind the channel rules — {reason} Next time you'll be kicked.": "Veuillez respecter les règles du canal — {reason} La prochaine fois, vous serez expulsé.", + "Access denied — {what} is for services operators.": "Accès refusé — {what} est réservé aux opérateurs des services." } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index da2e12e..a17532d 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1308,5 +1308,30 @@ "{n} report. \u0002VIEW\u0002 for detail.": "{n} denúncia. \u0002VIEW\u0002 para detalhes.", "{n} reports. \u0002VIEW\u0002 for detail.": "{n} denúncias. \u0002VIEW\u0002 para detalhes.", "End of list ({count} group).": "Fim da lista ({count} grupo).", - "End of list ({count} groups).": "Fim da lista ({count} grupos)." + "End of list ({count} groups).": "Fim da lista ({count} grupos).", + "Password reset for {account}": "Redefinição de senha para {account}", + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n": "Seu código de redefinição de senha para {account} é: {code}\nEle expira em 15 minutos.\nRedefina com:\n /msg NickServ RESETPASS {account} {code} \n", + "Password reset": "Redefinição de senha", + "Use this code to reset the password for your account {account}.": "Use este código para redefinir a senha da sua conta {account}.", + "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.": "Este código expira em 15 minutos. Se você não pediu para redefini-la, ignore este e-mail.", + "Confirm your {account} registration": "Confirme o registro de {account}", + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n": "Confirme sua conta {account} com:\n /msg NickServ CONFIRM {code}\nO código expira em 15 minutos.\n", + "Confirm your account": "Confirme sua conta", + "Welcome! Confirm the email for your account {account} with the code below.": "Boas-vindas! Confirme o e-mail da sua conta {account} com o código abaixo.", + "This code expires in 15 minutes.": "Este código expira em 15 minutos.", + "Your {word} {name} is about to expire": "{word} {name} está prestes a expirar", + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n": "{word} {name} está sem atividade e vai expirar em {remaining}.\n{keep}\n", + "About to expire": "Prestes a expirar", + "Your {word} {name} has been inactive and will expire in {remaining}.": "{word} {name} está sem atividade e vai expirar em {remaining}.", + "To keep it, have a member join the channel before then. Otherwise it will be removed.": "Para mantê-lo, peça que um membro entre no canal antes disso. Caso contrário, ele será removido.", + "To keep it, just identify to it before then. Otherwise it will be removed.": "Para mantê-la, basta identificar-se nela antes disso. Caso contrário, ela será removida.", + "account": "conta", + "channel": "canal", + "You're now identified as \u0002{account}\u0002. Welcome back!": "Você agora está identificado como \u0002{account}\u0002. Bem-vindo de volta!", + "You have been logged out of \u0002{account}\u0002.": "Você saiu de \u0002{account}\u0002.", + "A confirmation code has been emailed to you. Confirm with \u0002CONFIRM \u0002.": "Um código de confirmação foi enviado para o seu e-mail. Confirme com \u0002CONFIRM \u0002.", + "You are now the founder of \u0002{chan}\u0002 (inherited from \u0002{account}\u0002).": "Você agora é o fundador de \u0002{chan}\u0002 (herdado de \u0002{account}\u0002).", + "You didn't identify in time; you've been renamed.": "Você não se identificou a tempo; seu apelido foi alterado.", + "Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez você será expulso.", + "Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços." } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index bab27b4..edd3a32 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1308,5 +1308,30 @@ "{n} report. \u0002VIEW\u0002 for detail.": "{n} denúncia. \u0002VIEW\u0002 para detalhes.", "{n} reports. \u0002VIEW\u0002 for detail.": "{n} denúncias. \u0002VIEW\u0002 para detalhes.", "End of list ({count} group).": "Fim da lista ({count} grupo).", - "End of list ({count} groups).": "Fim da lista ({count} grupos)." + "End of list ({count} groups).": "Fim da lista ({count} grupos).", + "Password reset for {account}": "Reposição de palavra-passe para {account}", + "Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} \n": "O seu código de reposição de palavra-passe para {account} é: {code}\nExpira dentro de 15 minutos.\nReponha com:\n /msg NickServ RESETPASS {account} {code} \n", + "Password reset": "Reposição de palavra-passe", + "Use this code to reset the password for your account {account}.": "Utilize este código para repor a palavra-passe da sua conta {account}.", + "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.": "Este código expira dentro de 15 minutos. Se não pediu a reposição, ignore este email.", + "Confirm your {account} registration": "Confirme o registo de {account}", + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n": "Confirme a sua conta {account} com:\n /msg NickServ CONFIRM {code}\nO código expira dentro de 15 minutos.\n", + "Confirm your account": "Confirme a sua conta", + "Welcome! Confirm the email for your account {account} with the code below.": "Bem-vindo! Confirme o email da sua conta {account} com o código abaixo.", + "This code expires in 15 minutes.": "Este código expira dentro de 15 minutos.", + "Your {word} {name} is about to expire": "O seu {word} {name} está prestes a expirar", + "Your {word} {name} has been inactive and will expire in {remaining}.\n{keep}\n": "O seu {word} {name} tem estado inativo e vai expirar em {remaining}.\n{keep}\n", + "About to expire": "Prestes a expirar", + "Your {word} {name} has been inactive and will expire in {remaining}.": "O seu {word} {name} tem estado inativo e vai expirar em {remaining}.", + "To keep it, have a member join the channel before then. Otherwise it will be removed.": "Para o manter, peça a um membro que entre no canal antes disso. Caso contrário, será removido.", + "To keep it, just identify to it before then. Otherwise it will be removed.": "Para a manter, basta identificar-se nela antes disso. Caso contrário, será removida.", + "account": "conta", + "channel": "canal", + "You're now identified as \u0002{account}\u0002. Welcome back!": "Está agora identificado como \u0002{account}\u0002. Bem-vindo de volta!", + "You have been logged out of \u0002{account}\u0002.": "A sua sessão em \u0002{account}\u0002 foi terminada.", + "A confirmation code has been emailed to you. Confirm with \u0002CONFIRM \u0002.": "Foi-lhe enviado um código de confirmação por email. Confirme com \u0002CONFIRM \u0002.", + "You are now the founder of \u0002{chan}\u0002 (inherited from \u0002{account}\u0002).": "É agora o fundador de \u0002{chan}\u0002 (herdado de \u0002{account}\u0002).", + "You didn't identify in time; you've been renamed.": "Não se identificou a tempo; o seu nome foi alterado.", + "Please mind the channel rules — {reason} Next time you'll be kicked.": "Respeite as regras do canal — {reason} Da próxima vez será expulso.", + "Access denied — {what} is for services operators.": "Acesso negado — {what} é para operadores de serviços." } \ No newline at end of file diff --git a/modules/nickserv/src/resetpass.rs b/modules/nickserv/src/resetpass.rs index 933b24b..33f3e45 100644 --- a/modules/nickserv/src/resetpass.rs +++ b/modules/nickserv/src/resetpass.rs @@ -25,7 +25,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } let code = db.issue_code(&canonical, CodeKind::Reset); - let mail = echo_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code); + let lang = db.language_of(&canonical).unwrap_or_else(|| db.default_language()); + let mail = echo_api::email::reset(db.email_brand(), db.email_accent(), db.email_logo(), &canonical, &code, &lang); ctx.send_email(email, mail.subject, mail.text, Some(mail.html)); ctx.notice(me, from.uid, t!(ctx, "A reset code has been emailed to the address on file for \x02{canonical}\x02.", canonical = canonical)); } diff --git a/src/engine/kicker.rs b/src/engine/kicker.rs index 9992630..bef7ff7 100644 --- a/src/engine/kicker.rs +++ b/src/engine/kicker.rs @@ -87,7 +87,8 @@ impl Engine { }; if !already { self.bump("botserv.warn"); - return vec![NetAction::Notice { from: botuid, to: from.to_string(), text: format!("Please mind the channel rules — {reason} Next time you'll be kicked.") }]; + let text = echo_api::render(&self.lang_for_uid(from), "Please mind the channel rules — {reason} Next time you'll be kicked.", &[("reason", reason.to_string())]); + return vec![NetAction::Notice { from: botuid, to: from.to_string(), text }]; } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 63f3fdf..8fc484c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -620,7 +620,7 @@ impl Engine { out.push(NetAction::Notice { from: ns.clone(), to: uid.to_string(), - text: format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."), + text: echo_api::render_plural(&self.lang_for_account(account), unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())]), }); } } @@ -718,7 +718,8 @@ impl Engine { // Tell any online successor they inherited a channel. for (chan, s) in &inherited { if let (Some(ns), Some(uid)) = (&ns, self.network.uids_logged_into(s).into_iter().next()) { - self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You are now the founder of \x02{chan}\x02 (inherited from \x02{account}\x02).") }); + let text = echo_api::render(&self.lang_for_account(s), "You are now the founder of \x02{chan}\x02 (inherited from \x02{account}\x02).", &[("chan", chan.to_string()), ("account", account.to_string())]); + self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text }); } } // Log out and inform each local session that held the name. @@ -809,6 +810,21 @@ impl Engine { // no per-record timer. Opers, accounts with a live session, and occupied // channels are spared; each expiry is announced to the audit channel. Emits // cleanup directly over the outbound path (like a takeover cleanup). + // The language to address `account`'s owner in: their stored preference, or + // the network default. + fn lang_for_account(&self, account: &str) -> String { + self.db.language_of(account).unwrap_or_else(|| self.db.default_language()) + } + + // The language to address the user on `uid` in: their logged-in account's + // preference, or the network default (e.g. an unidentified user). + fn lang_for_uid(&self, uid: &str) -> String { + self.network + .account_of(uid) + .and_then(|a| self.db.language_of(a)) + .unwrap_or_else(|| self.db.default_language()) + } + pub fn expire_sweep(&mut self) { let now = self.now_secs(); // First, warn owners whose account/channel is approaching expiry and for @@ -816,14 +832,14 @@ impl Engine { if let Some(lead) = self.expire_warn.filter(|_| self.db.email_enabled()) { if let Some(ttl) = self.account_ttl { for (account, email, left) in self.db.accounts_to_warn(now, ttl, lead) { - let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Account, &account, &human_duration(left)); + let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Account, &account, &human_duration(left), &self.lang_for_account(&account)); self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) }); self.db.mark_account_warned(&account); } } if let Some(ttl) = self.channel_ttl { for (channel, email, left) in self.db.channels_to_warn(now, ttl, lead) { - let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left)); + let mail = echo_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), echo_api::email::ExpiryTarget::Channel, &channel, &human_duration(left), &self.db.default_language()); self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) }); self.db.mark_channel_warned(&channel); } @@ -1032,7 +1048,8 @@ impl Engine { for uid in fire { let guest = echo_api::next_guest_nick(&self.guest_nick, &mut self.enforce_seq, &self.network, &self.db); if let Some(ns) = self.nick_service.clone() { - self.emit_irc(NetAction::Notice { from: ns, to: uid.clone(), text: "You didn't identify in time; you've been renamed.".to_string() }); + let text = echo_api::render(&self.db.default_language(), "You didn't identify in time; you've been renamed.", &[]); + self.emit_irc(NetAction::Notice { from: ns, to: uid.clone(), text }); } self.emit_irc(NetAction::ForceNick { uid, nick: guest }); } diff --git a/src/engine/register.rs b/src/engine/register.rs index a8cb6c7..a1e68ba 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -90,7 +90,7 @@ impl Engine { if status == AuthorityStatus::Ok && !self.db.is_verified(name) { if let Some(addr) = addr { let code = self.db.issue_code(name, db::CodeKind::Confirm); - let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code, &self.lang_for_account(name)); self.emit_irc(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); } } @@ -191,7 +191,8 @@ impl Engine { self.network.clear_account(&uid); self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() }); if let Some(ns) = &ns { - self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You have been logged out of \x02{account}\x02.") }); + let text = echo_api::render(&self.lang_for_account(account), "You have been logged out of \x02{account}\x02.", &[("account", account.to_string())]); + self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text }); } } n @@ -273,7 +274,7 @@ impl Engine { } Some((false, Some(addr))) => { let code = self.db.issue_code(&account, db::CodeKind::Confirm); - let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code, &self.lang_for_account(&account)); let mut out = resp("verification_required", "VERIFICATION_REQUIRED", "A new confirmation code has been emailed."); out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); out @@ -338,10 +339,10 @@ impl Engine { if needs_verify { if let Some(addr) = addr { let code = self.db.issue_code(account, db::CodeKind::Confirm); - let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code, &self.lang_for_account(account)); out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); if let RegReply::NickServ { agent, uid, .. } = &reply { - out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM \x02.".to_string() }); + out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: echo_api::render(&self.lang_for_account(account), "A confirmation code has been emailed to you. Confirm with \x02CONFIRM \x02.", &[]) }); } } } @@ -366,14 +367,15 @@ impl Engine { let actions = match then { AuthThen::Identify { uid, agent, name, account } => { self.db.note_auth(&name, ok); - let mut ctx = ServiceCtx::default(); + let lang = self.lang_for_account(&account); + let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() }; if !ok { ctx.count("nickserv.identify_fail"); ctx.fail(&agent, &uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again."); } else { ctx.login(&uid, &account); ctx.count("nickserv.identify"); - ctx.notice(&agent, &uid, format!("You're now identified as \x02{account}\x02. Welcome back!")); + ctx.notice(&agent, &uid, echo_api::render(&lang, "You're now identified as \x02{account}\x02. Welcome back!", &[("account", account.clone())])); for entry in self.db.ajoin_list(&account) { ctx.force_join(&uid, &entry.channel, &entry.key); } @@ -386,7 +388,7 @@ impl Engine { } let unread = self.db.unread_memos(&account); if unread > 0 && self.db.memo_notify_on(&account) { - ctx.notice(&agent, &uid, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02.")); + ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())])); } } for key in std::mem::take(&mut ctx.stats) { diff --git a/src/i18n_check.rs b/src/i18n_check.rs index c338e79..1863759 100644 --- a/src/i18n_check.rs +++ b/src/i18n_check.rs @@ -167,6 +167,50 @@ fn every_template_is_translated() { } } } + // Also guard the engine- and email-layer strings, which localise via + // echo_api::render / crate::render (with a literal msgid) rather than the + // module macros. Scoped to those call prefixes so email.rs's own private + // `render` HTML helper and test literals aren't picked up. + let render_re = + Regex::new(&format!(r"(?:echo_api|crate)::render\s*\(\s*[^,]+,\s*{str_lit}")).unwrap(); + let render_plural_re = Regex::new(&format!( + r"(?:echo_api|crate)::render_plural\s*\(\s*[^,]+,\s*[^,]+,\s*{str_lit}\s*,\s*{str_lit}" + )) + .unwrap(); + let mut extra = vec![PathBuf::from(ROOT).join("api/src/email.rs")]; + let mut stack = vec![PathBuf::from(ROOT).join("src/engine")]; + while let Some(dir) = stack.pop() { + let Ok(rd) = fs::read_dir(&dir) else { continue }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.extension().and_then(|s| s.to_str()) == Some("rs") + && p.file_name().and_then(|s| s.to_str()) != Some("tests.rs") + { + extra.push(p); + } + } + } + for path in extra { + let Ok(src) = fs::read_to_string(&path) else { continue }; + let rel = path.strip_prefix(ROOT).unwrap_or(&path).display().to_string(); + for cap in render_re.captures_iter(&src) { + let id = unescape(&cap[1]); + if !reference.contains_key(&id) { + missing.push(format!("{rel}: render {id:?}")); + } + } + for cap in render_plural_re.captures_iter(&src) { + for g in [1usize, 2] { + let id = unescape(&cap[g]); + if !reference.contains_key(&id) { + missing.push(format!("{rel}: render_plural {id:?}")); + } + } + } + } + assert!( missing.is_empty(), "these templates have no catalog entry:\n{}",