Pluralize count-dependent messages with a CLDR one/other selector and gate catalog consistency with a test
All checks were successful
CI / check (push) Successful in 5m20s

This commit is contained in:
Jean Chevronnet 2026-07-20 02:03:05 +00:00
parent 74a58c5425
commit ecdead8278
No known key found for this signature in database
34 changed files with 572 additions and 186 deletions

View file

@ -40,7 +40,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
}
}
Some("CLEAR") => match db.badword_clear(chan) {
Ok(n) => ctx.notice(me, from.uid, t!(ctx, "Cleared \x02{n}\x02 badword pattern(s) from \x02{chan}\x02.", n = n, chan = chan)),
Ok(n) => ctx.notice(me, from.uid, echo_api::plural!(ctx, n, one = "Cleared \x02{n}\x02 badword pattern from \x02{chan}\x02.", other = "Cleared \x02{n}\x02 badword patterns from \x02{chan}\x02.", n = n, chan = chan)),
Err(_) => reg_error(me, from, chan, ctx),
},
None | Some("LIST") => {

View file

@ -45,7 +45,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
};
if nick == "*" {
match db.bot_del_all() {
Ok(n) => ctx.notice(me, from.uid, t!(ctx, "Removed all \x02{n}\x02 bot(s).", n = n)),
Ok(n) => ctx.notice(me, from.uid, echo_api::plural!(ctx, n, one = "Removed all \x02{n}\x02 bot.", other = "Removed all \x02{n}\x02 bots.", n = n)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
}
return;

View file

@ -50,6 +50,6 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
if channels.is_empty() {
ctx.notice(me, from.uid, " Not assigned to any channel.");
} else {
ctx.notice(me, from.uid, t!(ctx, " Serving {count} channel(s): {list}", count = channels.len(), list = channels.join(", ")));
ctx.notice(me, from.uid, echo_api::plural!(ctx, channels.len(), one = " Serving {count} channel: {list}", other = " Serving {count} channels: {list}", count = channels.len(), list = channels.join(", ")));
}
}

View file

@ -17,7 +17,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
match args.get(3).and_then(|s| s.parse::<u16>().ok()) {
Some(n) => match db.set_ttb(chan, n) {
Ok(()) if n == 0 => ctx.notice(me, from.uid, t!(ctx, "The bot will only kick (not ban) in \x02{chan}\x02.", chan = chan)),
Ok(()) => ctx.notice(me, from.uid, t!(ctx, "The bot will ban a user after \x02{n}\x02 kick(s) in \x02{chan}\x02.", n = n, chan = chan)),
Ok(()) => ctx.notice(me, from.uid, echo_api::plural!(ctx, n, one = "The bot will ban a user after \x02{n}\x02 kick in \x02{chan}\x02.", other = "The bot will ban a user after \x02{n}\x02 kicks in \x02{chan}\x02.", n = n, chan = chan)),
Err(_) => reg_error(me, from, chan, ctx),
},
None => ctx.notice(me, from.uid, "Syntax: KICK <#channel> TTB <number>"),

View file

@ -47,7 +47,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
match args.get(3).and_then(|s| s.parse::<u16>().ok()) {
Some(n) => match db.set_votekick(chan, n) {
Ok(()) if n == 0 => ctx.notice(me, from.uid, t!(ctx, "\x02!votekick\x02 is now disabled in \x02{chan}\x02.", chan = chan)),
Ok(()) => ctx.notice(me, from.uid, t!(ctx, "\x02{n}\x02 vote(s) will now carry a \x02!votekick\x02/\x02!voteban\x02 in \x02{chan}\x02.", n = n, chan = chan)),
Ok(()) => ctx.notice(me, from.uid, echo_api::plural!(ctx, n, one = "\x02{n}\x02 vote will now carry a \x02!votekick\x02/\x02!voteban\x02 in \x02{chan}\x02.", other = "\x02{n}\x02 votes will now carry a \x02!votekick\x02/\x02!voteban\x02 in \x02{chan}\x02.", n = n, chan = chan)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
None => ctx.notice(me, from.uid, "Syntax: SET <#channel> VOTEKICK <number> (0 to disable)"),

View file

@ -53,7 +53,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
}
}
Some("CLEAR") => match db.trigger_clear(chan) {
Ok(n) => ctx.notice(me, from.uid, t!(ctx, "Cleared \x02{n}\x02 trigger(s) from \x02{chan}\x02.", n = n, chan = chan)),
Ok(n) => ctx.notice(me, from.uid, echo_api::plural!(ctx, n, one = "Cleared \x02{n}\x02 trigger from \x02{chan}\x02.", other = "Cleared \x02{n}\x02 triggers from \x02{chan}\x02.", n = n, chan = chan)),
Err(_) => reg_error(me, from, chan, ctx),
},
None | Some("LIST") => {

View file

@ -49,6 +49,6 @@ pub fn handle(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx,
if opped == 0 {
ctx.notice(me, from.uid, t!(ctx, "None of \x02{chan}\x02's trusted regulars are here right now.", chan = chan));
} else {
ctx.notice(me, from.uid, t!(ctx, "Reopped \x02{opped}\x02 trusted regular(s) in \x02{chan}\x02.", opped = opped, chan = chan));
ctx.notice(me, from.uid, echo_api::plural!(ctx, opped, one = "Reopped \x02{opped}\x02 trusted regular in \x02{chan}\x02.", other = "Reopped \x02{opped}\x02 trusted regulars in \x02{chan}\x02.", opped = opped, chan = chan));
}
}

View file

@ -14,5 +14,5 @@ pub fn handle(me: &str, from: &Sender, chan: Option<&str>, ctx: &mut ServiceCtx,
for (id, score) in scores.iter().take(15) {
ctx.notice(me, from.uid, t!(ctx, " \x02{score}\x02 {id}", score = score, id = id));
}
ctx.notice(me, from.uid, t!(ctx, "Top {shown} of {total} scored identit{suffix} for \x02{chan}\x02.", shown = scores.len().min(15), total = scores.len(), suffix = if scores.len() == 1 { "y" } else { "ies" }, chan = chan));
ctx.notice(me, from.uid, echo_api::plural!(ctx, scores.len(), one = "Top {shown} of {total} scored identity for \x02{chan}\x02.", other = "Top {shown} of {total} scored identities for \x02{chan}\x02.", shown = scores.len().min(15), total = scores.len(), chan = chan));
}

View file

@ -111,7 +111,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.channel_mode(me, chan, &format!("-b {mask}"));
}
}
ctx.notice(me, from.uid, t!(ctx, "Cleared \x02{count}\x02 entr{suffix} from \x02{chan}\x02's auto-kick list.", count = masks.len(), suffix = if masks.len() == 1 { "y" } else { "ies" }, chan = chan));
ctx.notice(me, from.uid, echo_api::plural!(ctx, masks.len(), one = "Cleared \x02{count}\x02 entry from \x02{chan}\x02's auto-kick list.", other = "Cleared \x02{count}\x02 entries from \x02{chan}\x02's auto-kick list.", count = masks.len(), chan = chan));
}
_ => ctx.notice(me, from.uid, "Syntax: AKICK <#channel> ADD <mask> [reason] | DEL <mask> | LIST | CLEAR"),
}

View file

@ -26,7 +26,7 @@ pub fn handle(me: &str, from: &Sender, chan: &str, args: &[&str], ctx: &mut Serv
for a in &info.access {
ctx.notice(me, from.uid, t!(ctx, " \x02{account}\x02: \x02{flags}\x02", account = a.account, flags = Flags::from_level(&a.level).to_letters()));
}
ctx.notice(me, from.uid, t!(ctx, "End of flags ({count} entr{suffix}).", count = info.access.len() + 1, suffix = if info.access.is_empty() { "y" } else { "ies" }));
ctx.notice(me, from.uid, echo_api::plural!(ctx, info.access.len() + 1, one = "End of flags ({count} entry).", other = "End of flags ({count} entries).", count = info.access.len() + 1));
return;
};

View file

@ -16,5 +16,5 @@ pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &mut dyn Store)
for n in &names {
ctx.notice(me, from.uid, t!(ctx, " \x02{n}\x02", n = n));
}
ctx.notice(me, from.uid, t!(ctx, "End of list ({count} group(s)).", count = names.len()));
ctx.notice(me, from.uid, echo_api::plural!(ctx, names.len(), one = "End of list ({count} group).", other = "End of list ({count} groups).", count = names.len()));
}

View file

@ -20,5 +20,5 @@ pub fn handle(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx,
let short: String = t.message.chars().take(60).collect();
ctx.notice(me, from.uid, t!(ctx, "\x02#{id}\x02 {requester} — {short}{state}", id = t.id, requester = t.requester, short = short, state = state));
}
ctx.notice(me, from.uid, t!(ctx, "{count} ticket(s). \x02VIEW\x02 <id> for detail.", count = tickets.len()));
ctx.notice(me, from.uid, echo_api::plural!(ctx, tickets.len(), one = "{count} ticket. \x02VIEW\x02 <id> for detail.", other = "{count} tickets. \x02VIEW\x02 <id> for detail.", count = tickets.len()));
}

View file

@ -9,7 +9,7 @@ pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut S
for i in (0..count).rev() {
db.memo_del(account, i);
}
ctx.notice(me, from.uid, t!(ctx, "Deleted all \x02{count}\x02 memo(s).", count = count));
ctx.notice(me, from.uid, echo_api::plural!(ctx, count, one = "Deleted all \x02{count}\x02 memo.", other = "Deleted all \x02{count}\x02 memos.", count = count));
}
Some(numstr) => {
let Some(n) = numstr.parse::<usize>().ok().filter(|n| *n >= 1) else {

View file

@ -1,4 +1,4 @@
use echo_api::{t, Sender, ServiceCtx, Store};
use echo_api::{Sender, ServiceCtx, Store};
// INFO: a summary of your mailbox (total, unread, capacity).
pub fn handle(me: &str, from: &Sender, account: &str, ctx: &mut ServiceCtx, db: &mut dyn Store) {
@ -7,6 +7,6 @@ pub fn handle(me: &str, from: &Sender, account: &str, ctx: &mut ServiceCtx, db:
ctx.notice(
me,
from.uid,
t!(ctx, "You have \x02{total}\x02 memo(s), \x02{unread}\x02 unread, of a maximum \x02{max}\x02.", total = total, unread = unread, max = super::MAX_MEMOS),
echo_api::plural!(ctx, total, one = "You have \x02{total}\x02 memo, \x02{unread}\x02 unread, of a maximum \x02{max}\x02.", other = "You have \x02{total}\x02 memos, \x02{unread}\x02 unread, of a maximum \x02{max}\x02.", total = total, unread = unread, max = super::MAX_MEMOS),
);
}

View file

@ -1,4 +1,4 @@
use echo_api::{t, Priv, Sender, ServiceCtx, Store};
use echo_api::{Priv, Sender, ServiceCtx, Store};
// SENDALL <text>: leave a memo on every registered account. Admin-only, for
// network-wide announcements that persist until read.
@ -20,5 +20,5 @@ pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut S
sent += 1;
}
}
ctx.notice(me, from.uid, t!(ctx, "Memo sent to \x02{sent}\x02 account(s).", sent = sent));
ctx.notice(me, from.uid, echo_api::plural!(ctx, sent, one = "Memo sent to \x02{sent}\x02 account.", other = "Memo sent to \x02{sent}\x02 accounts.", sent = sent));
}

View file

@ -1,4 +1,4 @@
use echo_api::{t, NetView, Priv, Sender, ServiceCtx, Store};
use echo_api::{NetView, Priv, Sender, ServiceCtx, Store};
// STAFF <text>: leave a memo on every operator's account. Admin-only, for
// notes to the staff team that persist until read. Recipients are the union of
@ -29,5 +29,5 @@ pub fn handle(me: &str, from: &Sender, account: &str, args: &[&str], ctx: &mut S
sent += 1;
}
}
ctx.notice(me, from.uid, t!(ctx, "Memo sent to \x02{sent}\x02 operator(s).", sent = sent));
ctx.notice(me, from.uid, echo_api::plural!(ctx, sent, one = "Memo sent to \x02{sent}\x02 operator.", other = "Memo sent to \x02{sent}\x02 operators.", sent = sent));
}

View file

@ -39,7 +39,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
}
let ajoin = db.ajoin_list(&acct.name);
if !ajoin.is_empty() {
ctx.notice(me, from.uid, t!(ctx, " Auto-join : {count} channel(s) — see \x02AJOIN LIST\x02", count = ajoin.len()));
ctx.notice(me, from.uid, echo_api::plural!(ctx, ajoin.len(), one = " Auto-join : {count} channel — see \x02AJOIN LIST\x02", other = " Auto-join : {count} channels — see \x02AJOIN LIST\x02", count = ajoin.len()));
}
}
// A staff note is for operators' eyes only, never the account's owner.

View file

@ -20,5 +20,5 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, t!(ctx, " \x02{name}\x02 registered {when}{flag}", name = a.name, when = human_time(a.ts), flag = flag));
}
let more = if matches.len() > MAX_SHOWN { t!(ctx, ", showing the first {max}", max = MAX_SHOWN) } else { String::new() };
ctx.notice(me, from.uid, t!(ctx, "End of list — {count} match(es){more}.", count = matches.len(), more = more));
ctx.notice(me, from.uid, echo_api::plural!(ctx, matches.len(), one = "End of list — {count} match{more}.", other = "End of list — {count} matches{more}.", count = matches.len(), more = more));
}

View file

@ -1,5 +1,4 @@
use echo_api::{Sender, ServiceCtx, Store};
use echo_api::t;
// UPDATE: re-apply your account's auto-joins and vhost, and re-check for waiting
// memos, without having to re-identify.
@ -16,7 +15,7 @@ pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &dyn Store) {
}
let unread = db.unread_memos(account);
if unread > 0 {
ctx.notice(me, from.uid, t!(ctx, "You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02.", unread = unread));
ctx.notice(me, from.uid, echo_api::plural!(ctx, unread, one = "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", other = "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", unread = unread));
}
ctx.notice(me, from.uid, "Your status has been refreshed.");
}

View file

@ -34,5 +34,5 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net:
banned += 1;
}
}
ctx.notice(me, from.uid, t!(ctx, "CHANKILL on \x02{chan}\x02: \x02{banned}\x02 host(s) AKILL'd.", chan = chan, banned = banned));
ctx.notice(me, from.uid, echo_api::plural!(ctx, banned, one = "CHANKILL on \x02{chan}\x02: \x02{banned}\x02 host AKILL'd.", other = "CHANKILL on \x02{chan}\x02: \x02{banned}\x02 hosts AKILL'd.", chan = chan, banned = banned));
}

View file

@ -20,7 +20,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
Some("VIEW") => list(me, from, true, args.get(2).copied(), ctx, db),
Some("CLEAR") => match db.notify_clear() {
Ok(0) => ctx.notice(me, from.uid, "The notify list is already empty."),
Ok(n) => ctx.notice(me, from.uid, t!(ctx, "Cleared \x02{n}\x02 notify watch(es).", n = n)),
Ok(n) => ctx.notice(me, from.uid, echo_api::plural!(ctx, n, one = "Cleared \x02{n}\x02 notify watch.", other = "Cleared \x02{n}\x02 notify watches.", n = n)),
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
},
_ => ctx.notice(me, from.uid, SYNTAX),

View file

@ -19,14 +19,14 @@ pub fn handle_session(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceC
for (ip, n) in &over {
ctx.notice(me, from.uid, t!(ctx, " \x02{n}\x02 sessions from \x02{ip}\x02", n = n, ip = ip));
}
ctx.notice(me, from.uid, t!(ctx, "End of session list ({count} IP(s)).", count = over.len()));
ctx.notice(me, from.uid, echo_api::plural!(ctx, over.len(), one = "End of session list ({count} IP).", other = "End of session list ({count} IPs).", count = over.len()));
}
Some("VIEW") => {
let Some(&ip) = args.get(2) else {
ctx.notice(me, from.uid, "Syntax: SESSION VIEW <ip>");
return;
};
ctx.notice(me, from.uid, t!(ctx, "\x02{ip}\x02 has \x02{count}\x02 live session(s).", ip = ip, count = net.session_count(ip)));
ctx.notice(me, from.uid, echo_api::plural!(ctx, net.session_count(ip), one = "\x02{ip}\x02 has \x02{count}\x02 live session.", other = "\x02{ip}\x02 has \x02{count}\x02 live sessions.", ip = ip, count = net.session_count(ip)));
}
_ => ctx.notice(me, from.uid, "Syntax: SESSION LIST <min> | SESSION VIEW <ip>"),
}

View file

@ -16,5 +16,5 @@ pub fn handle(me: &str, from: &Sender, arg: Option<&str>, ctx: &mut ServiceCtx,
let short: String = r.reason.chars().take(60).collect();
ctx.notice(me, from.uid, t!(ctx, "\x02#{id}\x02 {reporter} → \x02{target}\x02: {short}{flag}", id = r.id, reporter = r.reporter, target = r.target, short = short, flag = flag));
}
ctx.notice(me, from.uid, t!(ctx, "{n} report(s). \x02VIEW\x02 <id> for detail.", n = reports.len()));
ctx.notice(me, from.uid, echo_api::plural!(ctx, reports.len(), one = "{n} report. \x02VIEW\x02 <id> for detail.", other = "{n} reports. \x02VIEW\x02 <id> for detail.", n = reports.len()));
}

View file

@ -10,7 +10,7 @@ pub fn handle(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, net: &d
ctx.notice(me, from.uid, t!(ctx, "No activity recorded for \x02{chan}\x02 yet.", chan = chan));
return;
};
ctx.notice(me, from.uid, t!(ctx, "\x02{chan}\x02\x02{lines}\x02 line(s) seen.", chan = chan, lines = lines));
ctx.notice(me, from.uid, echo_api::plural!(ctx, lines, one = "\x02{chan}\x02\x02{lines}\x02 line seen.", other = "\x02{chan}\x02\x02{lines}\x02 lines seen.", chan = chan, lines = lines));
if top.is_empty() {
return;
}