Localize engine notices, transactional emails, and operator-access denials in all six languages
All checks were successful
CI / check (push) Successful in 5m18s

This commit is contained in:
Jean Chevronnet 2026-07-20 02:25:54 +00:00
parent ecdead8278
commit e4a14b9813
No known key found for this signature in database
13 changed files with 299 additions and 55 deletions

View file

@ -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} <newpassword>\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} <newpassword>\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,
),
}
}

View file

@ -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
}

View file

@ -1308,5 +1308,30 @@
"{n} report. \u0002VIEW\u0002 <id> for detail.": "{n} Meldung. \u0002VIEW\u0002 <id> für Details.",
"{n} reports. \u0002VIEW\u0002 <id> for detail.": "{n} Meldungen. \u0002VIEW\u0002 <id> 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} <newpassword>\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} <newpassword>\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 <code>\u0002.": "Ein Bestätigungscode wurde dir per E-Mail geschickt. Bestätige mit \u0002CONFIRM <code>\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."
}

View file

@ -1308,5 +1308,30 @@
"{n} report. \u0002VIEW\u0002 <id> for detail.": "{n} reporte. \u0002VIEW\u0002 <id> para el detalle.",
"{n} reports. \u0002VIEW\u0002 <id> for detail.": "{n} reportes. \u0002VIEW\u0002 <id> 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} <newpassword>\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} <newpassword>\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 <code>\u0002.": "Se te envió un código de confirmación por correo. Confirmá con \u0002CONFIRM <code>\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."
}

View file

@ -1308,5 +1308,30 @@
"{n} report. \u0002VIEW\u0002 <id> for detail.": "{n} reporte. \u0002VIEW\u0002 <id> para ver el detalle.",
"{n} reports. \u0002VIEW\u0002 <id> for detail.": "{n} reportes. \u0002VIEW\u0002 <id> 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} <newpassword>\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} <newpassword>\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 <code>\u0002.": "Se te ha enviado un código de confirmación por correo. Confírmalo con \u0002CONFIRM <code>\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."
}

View file

@ -1308,5 +1308,30 @@
"{n} report. \u0002VIEW\u0002 <id> for detail.": "{n} signalement. \u0002VIEW\u0002 <id> pour le détail.",
"{n} reports. \u0002VIEW\u0002 <id> for detail.": "{n} signalements. \u0002VIEW\u0002 <id> 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} <newpassword>\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} <newpassword>\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 <code>\u0002.": "Un code de confirmation vous a été envoyé par e-mail. Confirmez avec \u0002CONFIRM <code>\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."
}

View file

@ -1308,5 +1308,30 @@
"{n} report. \u0002VIEW\u0002 <id> for detail.": "{n} denúncia. \u0002VIEW\u0002 <id> para detalhes.",
"{n} reports. \u0002VIEW\u0002 <id> for detail.": "{n} denúncias. \u0002VIEW\u0002 <id> 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} <newpassword>\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} <newpassword>\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 <code>\u0002.": "Um código de confirmação foi enviado para o seu e-mail. Confirme com \u0002CONFIRM <code>\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."
}

View file

@ -1308,5 +1308,30 @@
"{n} report. \u0002VIEW\u0002 <id> for detail.": "{n} denúncia. \u0002VIEW\u0002 <id> para detalhes.",
"{n} reports. \u0002VIEW\u0002 <id> for detail.": "{n} denúncias. \u0002VIEW\u0002 <id> 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} <newpassword>\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} <newpassword>\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 <code>\u0002.": "Foi-lhe enviado um código de confirmação por email. Confirme com \u0002CONFIRM <code>\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."
}

View file

@ -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));
}

View file

@ -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 }];
}
}

View file

@ -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 });
}

View file

@ -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 <code>\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 <code>\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) {

View file

@ -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{}",