diff --git a/README.md b/README.md index 42b04aa..f68c64e 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ over TLS. ```sh cargo build --release --bin karma-web -./target/release/karma-web # omit out.html to print to stdout +./target/release/karma-web [locale] # locale: en|es|fr (default en) ``` On mars: `karma-web.service` + `karma-web.timer` write `/var/www/karma/index.html`, @@ -123,8 +123,24 @@ RUSTBOT_CONFIG=/etc/rustbot.conf cargo run Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, `channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default -on), `password`, `sasl_user`, `sasl_pass`, and `name` (a network's log label). -Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. +on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and +`locale` (`en`/`es`/`fr`, default `en`). Whole-line `#`/`;` comments only — +inline comments would clash with channel `#`s. + +### Locales + +`locale` sets a network's language, so a community sees the bot in its own +tongue — e.g. `#argentina` on libera runs in Spanish. It currently drives the +`karma` module's replies and milestone announcements (English, Spanish, French); +other modules fall back to English. The web board takes its own locale as +`karma-web`'s third argument (or `RUSTBOT_LOCALE`), so the page can match the +audience. + +```ini +[libera] +channels = #argentina +locale = es # karma replies + milestones in Spanish +``` Web modules write per-network state under `RUSTBOT_DATA_DIR` (default `.`). diff --git a/src/bin/karma-web.rs b/src/bin/karma-web.rs index 8514c54..282eb3f 100644 --- a/src/bin/karma-web.rs +++ b/src/bin/karma-web.rs @@ -2,6 +2,7 @@ use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; +use rustbot::i18n::Locale; use rustbot::karma_web::{parse_board, parse_events, render, Board}; fn main() { @@ -11,6 +12,11 @@ fn main() { .or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok()) .unwrap_or_else(|| ".".to_string()); let out = args.next(); + let locale = args + .next() + .or_else(|| std::env::var("RUSTBOT_LOCALE").ok()) + .map(|s| Locale::from_code(&s)) + .unwrap_or_default(); let mut boards: Vec = Vec::new(); if let Ok(rd) = fs::read_dir(&dir) { @@ -38,7 +44,7 @@ fn main() { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let html = render(&boards, epoch); + let html = render(&boards, epoch, locale); match out { Some(path) => { diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 6bb98e0..c308558 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -28,7 +28,7 @@ impl Bot { pub fn new(config: Config, conn: Connection) -> Bot { let current_nick = config.nick.clone(); - let mods = modules::all(&config.name); + let mods = modules::all(&config.name, crate::i18n::Locale::from_code(&config.locale)); let mut routes: HashMap<&'static str, usize> = HashMap::new(); for (i, m) in mods.iter().enumerate() { for spec in m.commands() { diff --git a/src/bot/modules/karma.rs b/src/bot/modules/karma.rs index cfa76b6..2fb4737 100644 --- a/src/bot/modules/karma.rs +++ b/src/bot/modules/karma.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::bot::module::{Action, Chat, Command, CommandSpec, Module}; +use crate::i18n::Locale; use crate::json::{self, Json}; const COMMANDS: &[CommandSpec] = &[ @@ -29,21 +30,30 @@ const ZWSP: char = '\u{200B}'; pub struct Karma { store: Store, + locale: Locale, } impl Karma { - pub fn new(network: &str) -> Karma { + pub fn new(network: &str, locale: Locale) -> Karma { let dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string()); let safe: String = network .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect(); - Karma::with_path(PathBuf::from(dir).join(format!("karma.{safe}.json"))) + Karma::with_path_locale( + PathBuf::from(dir).join(format!("karma.{safe}.json")), + locale, + ) } pub fn with_path(path: PathBuf) -> Karma { + Karma::with_path_locale(path, Locale::En) + } + + pub fn with_path_locale(path: PathBuf, locale: Locale) -> Karma { Karma { store: Store::load(path), + locale, } } @@ -52,19 +62,30 @@ impl Karma { } fn report(&self, thing: &str) -> String { + let dh = dehighlight(thing); let Some(e) = self.store.entry(thing) else { - return format!("no karma for '{}' yet", dehighlight(thing)); + return match self.locale { + Locale::En => format!("no karma for '{dh}' yet"), + Locale::Es => format!("todavía no hay karma para '{dh}'"), + Locale::Fr => format!("pas encore de karma pour '{dh}'"), + }; }; let (rank, total) = self.store.rank(thing); - let mut out = format!( - "{} has {} karma (#{rank} of {total})", - dehighlight(thing), - e.count - ); + let n = e.count; + let mut out = match self.locale { + Locale::En => format!("{dh} has {n} karma (#{rank} of {total})"), + Locale::Es => format!("{dh} tiene {n} de karma (#{rank} de {total})"), + Locale::Fr => format!("{dh} a {n} karma (#{rank} sur {total})"), + }; if let Some(reason) = &e.reason { + let last = match self.locale { + Locale::En => "last", + Locale::Es => "último", + Locale::Fr => "dernier", + }; match &e.by { - Some(by) => out.push_str(&format!(" — last: \"{reason}\" ({})", dehighlight(by))), - None => out.push_str(&format!(" — last: \"{reason}\"")), + Some(by) => out.push_str(&format!(" — {last}: \"{reason}\" ({})", dehighlight(by))), + None => out.push_str(&format!(" — {last}: \"{reason}\"")), } } out @@ -73,17 +94,25 @@ impl Karma { fn leaderboard(&self, bottom: bool) -> String { let entries = self.store.ranked(TOP_N, bottom); if entries.is_empty() { - return "no karma yet".to_string(); + return match self.locale { + Locale::En => "no karma yet".to_string(), + Locale::Es => "todavía no hay karma".to_string(), + Locale::Fr => "pas encore de karma".to_string(), + }; } let items: Vec = entries .iter() .map(|(k, v)| format!("{} ({v})", dehighlight(k))) .collect(); - format!( - "karma {}: {}", - if bottom { "bottom" } else { "top" }, - items.join(", ") - ) + let label = match (self.locale, bottom) { + (Locale::En, false) => "karma top", + (Locale::En, true) => "karma bottom", + (Locale::Es, false) => "top de karma", + (Locale::Es, true) => "peor karma", + (Locale::Fr, false) => "top karma", + (Locale::Fr, true) => "pire karma", + }; + format!("{label}: {}", items.join(", ")) } } @@ -127,10 +156,12 @@ impl Module for Karma { .log_event(&format_event(t, thing, *delta, chat.sender, r)); let new = self.store.bump(thing, *delta, r, chat.sender); if *delta > 0 && MILESTONES.contains(&new) { - announces.push(Action::reply(format!( - "🎉 {} reached {new} karma!", - dehighlight(thing) - ))); + let dh = dehighlight(thing); + announces.push(Action::reply(match self.locale { + Locale::En => format!("🎉 {dh} reached {new} karma!"), + Locale::Es => format!("🎉 ¡{dh} alcanzó {new} de karma!"), + Locale::Fr => format!("🎉 {dh} a atteint {new} karma !"), + })); } } if let Err(e) = self.store.save() { @@ -188,7 +219,16 @@ pub fn extract_reason(text: &str) -> Option { } let mut reason = toks[bumps[0] + 1..].join(" "); reason = reason.trim().to_string(); - for prefix in ["for ", "because ", "cause ", "b/c "] { + for prefix in [ + "for ", + "because ", + "cause ", + "b/c ", // en + "porque ", + "por ", // es + "parce que ", + "pour ", // fr + ] { if let Some(r) = reason.strip_prefix(prefix) { reason = r.to_string(); break; diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index 5e44b0b..ea6ba53 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -11,13 +11,14 @@ pub mod weather; pub mod wiki; use super::module::Module; +use crate::i18n::Locale; -pub fn all(network: &str) -> Vec> { +pub fn all(network: &str, locale: Locale) -> Vec> { vec![ Box::new(builtins::Builtins), Box::new(dice::Dice::new()), Box::new(hash::Hash), - Box::new(karma::Karma::new(network)), + Box::new(karma::Karma::new(network, locale)), Box::new(crypto::Crypto), Box::new(define::Define), Box::new(down::Down), diff --git a/src/config.rs b/src/config.rs index 55210d6..6bd3d73 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub password: Option, pub sasl_user: Option, pub sasl_pass: Option, + pub locale: String, } impl Default for Config { @@ -36,6 +37,7 @@ impl Default for Config { password: None, sasl_user: None, sasl_pass: None, + locale: "en".to_string(), } } } @@ -160,6 +162,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) { "password" => c.password = (!value.is_empty()).then(|| value.to_string()), "sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()), "sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()), + "locale" | "lang" => c.locale = value.to_string(), other => log(&format!("{where_}: unknown key '{other}'")), } } @@ -217,6 +220,9 @@ fn apply_env(c: &mut Config) { if let Ok(v) = std::env::var("IRC_SASL_PASS") { c.sasl_pass = Some(v); } + if let Ok(v) = std::env::var("IRC_LOCALE") { + c.locale = v; + } } fn parse_bool(value: &str) -> bool { diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..c4b3e99 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,33 @@ +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Locale { + #[default] + En, + Es, + Fr, +} + +impl Locale { + /// Accepts codes like `es`, `ES`, `es-AR`, `fr_FR`; anything unknown is English. + pub fn from_code(s: &str) -> Locale { + let base = s + .trim() + .to_ascii_lowercase() + .split(['-', '_']) + .next() + .unwrap_or("") + .to_string(); + match base.as_str() { + "es" => Locale::Es, + "fr" => Locale::Fr, + _ => Locale::En, + } + } + + pub fn code(self) -> &'static str { + match self { + Locale::En => "en", + Locale::Es => "es", + Locale::Fr => "fr", + } + } +} diff --git a/src/karma_web.rs b/src/karma_web.rs index 85813ea..5c3441d 100644 --- a/src/karma_web.rs +++ b/src/karma_web.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; -use crate::json::Json; +use crate::i18n::Locale; +use crate::json::{self, Json}; pub struct Row { pub thing: String, @@ -253,7 +254,104 @@ fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec { v } -pub fn render(boards: &[Board], updated_epoch: u64) -> String { +/// The user-facing strings, one set per locale. IRC-supplied content (nicks, +/// reasons) is never translated — only the chrome around it. +struct Lex { + tagline: &'static str, + s_tracked: &'static str, + s_net: &'static str, + s_gifts: &'static str, + s_givers: &'static str, + network: &'static str, + ledger: &'static str, + recent: &'static str, + top_givers: &'static str, + gave: &'static str, + someone: &'static str, + gift_one: &'static str, + gift_many: &'static str, + empty_title: &'static str, + empty_sub: &'static str, + empty_activity: &'static str, + empty_givers: &'static str, + give: &'static str, + built: &'static str, + updated: &'static str, +} + +fn lex(l: Locale) -> Lex { + match l { + Locale::En => Lex { + tagline: "reputation earned one nick++ at a time, across the channels.", + s_tracked: "tracked", + s_net: "net karma", + s_gifts: "gifts logged", + s_givers: "givers", + network: "network", + ledger: "the ledger", + recent: "recent activity", + top_givers: "top givers", + gave: "gave", + someone: "someone", + gift_one: "gift", + gift_many: "gifts", + empty_title: "No karma yet.", + empty_sub: "Say nick++ in the channel and this board comes alive.", + empty_activity: "Nothing yet. The next nick++ shows up here.", + empty_givers: "No one has handed out karma yet. Be the first.", + give: "give karma by saying nick++ in the channel", + built: "built by", + updated: "updated", + }, + Locale::Es => Lex { + tagline: "reputación ganada de a un nick++, en todos los canales.", + s_tracked: "seguidos", + s_net: "karma neto", + s_gifts: "regalos", + s_givers: "donantes", + network: "red", + ledger: "el ranking", + recent: "actividad reciente", + top_givers: "top donantes", + gave: "le dio a", + someone: "alguien", + gift_one: "regalo", + gift_many: "regalos", + empty_title: "Aún no hay karma.", + empty_sub: "Escribí nick++ en el canal y este tablero cobra vida.", + empty_activity: "Todavía nada. El próximo nick++ aparece acá.", + empty_givers: "Nadie repartió karma todavía. Sé el primero.", + give: "da karma escribiendo nick++ en el canal", + built: "hecho con", + updated: "actualizado", + }, + Locale::Fr => Lex { + tagline: "la réputation gagnée un nick++ à la fois, sur les canaux.", + s_tracked: "suivis", + s_net: "karma net", + s_gifts: "dons", + s_givers: "donneurs", + network: "réseau", + ledger: "le classement", + recent: "activité récente", + top_givers: "meilleurs donneurs", + gave: "a donné à", + someone: "quelqu'un", + gift_one: "don", + gift_many: "dons", + empty_title: "Pas encore de karma.", + empty_sub: "Dis nick++ dans le canal et ce tableau prend vie.", + empty_activity: "Rien pour l'instant. Le prochain nick++ apparaît ici.", + empty_givers: "Personne n'a encore donné de karma. Sois le premier.", + give: "donne du karma en disant nick++ dans le canal", + built: "fait avec", + updated: "mis à jour", + }, + } +} + +pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String { + let lx = lex(locale); let things: usize = boards.iter().map(|b| b.rows.len()).sum(); let total_points: i64 = boards.iter().flat_map(|b| &b.rows).map(|r| r.count).sum(); @@ -271,28 +369,24 @@ pub fn render(boards: &[Board], updated_epoch: u64) -> String { let mut s = String::from(HEAD); let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1; - // hero s.push_str("
rubot@devtronic
"); s.push_str("

karma_

"); - s.push_str( - "

reputation earned one nick++ at a time, across the channels.

", - ); + s.push_str(&format!("

{}

", lx.tagline)); s.push_str("
"); - s.push_str(&stat(&things.to_string(), "tracked")); - s.push_str(&stat(&signed(total_points), "net karma")); - s.push_str(&stat(&gifts.to_string(), "gifts logged")); - s.push_str(&stat(&givers_set.len().to_string(), "givers")); + s.push_str(&stat(&things.to_string(), lx.s_tracked)); + s.push_str(&stat(&signed(total_points), lx.s_net)); + s.push_str(&stat(&gifts.to_string(), lx.s_gifts)); + s.push_str(&stat(&givers_set.len().to_string(), lx.s_givers)); s.push_str("
"); if things == 0 { - s.push_str(EMPTY); - s.push_str(&footer(updated_epoch)); - s.push_str(SCRIPT); - s.push_str(TAIL); - return s; + s.push_str(&format!( + "
{}
{}
", + lx.empty_title, lx.empty_sub + )); + return finish(s, updated_epoch, locale, &lx); } - // per-network board(s): champion podium + the rest as a ledger for b in boards { if b.rows.is_empty() { continue; @@ -300,23 +394,31 @@ pub fn render(boards: &[Board], updated_epoch: u64) -> String { s.push_str("
"); if multi { s.push_str(&format!( - "
network{}
", + "
{}{}
", + lx.network, esc(&b.network) )); } s.push_str(&podium(b)); - s.push_str(&ledger(b)); + s.push_str(&ledger(b, &lx)); s.push_str("
"); } - // activity + givers band s.push_str("
"); - s.push_str(&activity(&feed, multi)); - s.push_str(&givers_panel(&feed)); + s.push_str(&activity(&feed, multi, &lx)); + s.push_str(&givers_panel(&feed, &lx)); s.push_str("
"); - s.push_str(&footer(updated_epoch)); - s.push_str(SCRIPT); + finish(s, updated_epoch, locale, &lx) +} + +fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String { + s.push_str(&footer(epoch, lx)); + s.push_str(""); s.push_str(TAIL); s } @@ -363,11 +465,14 @@ fn podium(b: &Board) -> String { out } -fn ledger(b: &Board) -> String { +fn ledger(b: &Board, lx: &Lex) -> String { if b.rows.len() <= 3 { return String::new(); } - let mut out = String::from("
the ledger
    "); + let mut out = format!( + "
    {}
      ", + lx.ledger + ); for (i, r) in b.rows.iter().enumerate().skip(3) { let rank = i + 1; let spark = sparkline(&series_for(&r.thing, r.count, &b.events)); @@ -394,20 +499,22 @@ fn ledger(b: &Board) -> String { out } -fn activity(feed: &[(&str, &Event)], multi: bool) -> String { - let mut out = String::from( +fn activity(feed: &[(&str, &Event)], multi: bool, lx: &Lex) -> String { + let mut out = format!( "
      \ - recent activity
      ", + {}", + lx.recent ); if feed.is_empty() { - out.push_str( - "
      Nothing yet. The next nick++ shows up here.
      ", - ); + out.push_str(&format!( + "
      {}
      ", + lx.empty_activity + )); return out; } out.push_str("
        "); for (net, e) in feed.iter().take(40) { - let actor = e.by.clone().unwrap_or_else(|| "someone".to_string()); + let actor = e.by.clone().unwrap_or_else(|| lx.someone.to_string()); let dchip = if e.delta >= 0 { "++" } else { "--" }; let why = match &e.why { Some(w) => format!("\u{201C}{}\u{201D}", esc(w)), @@ -420,12 +527,13 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String { }; out.push_str(&format!( "
      • {}
        \ - {} gave {} \ + {} {} {} \ {dchip}{netlabel}
        {why}\
      • ", klass(e.delta), avatar(&actor, "sm"), esc(&actor), + lx.gave, esc(&e.thing), klass(e.delta), e.t, @@ -435,28 +543,35 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String { out } -fn givers_panel(feed: &[(&str, &Event)]) -> String { +fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String { let givers = top_givers(feed, 8); - let mut out = - String::from("
        top givers
        "); + let mut out = format!( + "
        {}
        ", + lx.top_givers + ); if givers.is_empty() { - out.push_str( - "
        No one has handed out karma yet. Be the first.
        ", - ); + out.push_str(&format!( + "
        {}
        ", + lx.empty_givers + )); return out; } out.push_str("
          "); for (i, g) in givers.iter().enumerate() { + let unit = if g.gives == 1 { + lx.gift_one + } else { + lx.gift_many + }; out.push_str(&format!( "
        1. {}{}\
          {}
          \ - {} gift{}
          \ + {} {unit}\ {}
        2. ", i + 1, avatar(&g.name, "sm"), esc(&g.name), g.gives, - if g.gives == 1 { "" } else { "s" }, klass(g.points), signed(g.points), )); @@ -465,11 +580,41 @@ fn givers_panel(feed: &[(&str, &Event)]) -> String { out } -fn footer(epoch: u64) -> String { +fn footer(epoch: u64, lx: &Lex) -> String { format!( - "
          give karma by saying nick++ in the channel \ - · built by rubot \ - · updated
          " + "
          {} · {} \ + rubot \ + · {}
          ", + lx.give, lx.built, lx.updated + ) +} + +fn time_cfg(l: Locale) -> String { + let (just, pre, post, u): (&str, &str, &str, [&str; 7]) = match l { + Locale::En => ("just now", "", " ago", ["y", "mo", "w", "d", "h", "m", "s"]), + Locale::Es => ( + "ahora", + "hace ", + "", + ["a", "mes", "sem", "d", "h", "min", "s"], + ), + Locale::Fr => ( + "à l'instant", + "il y a ", + "", + ["a", "mois", "sem", "j", "h", "min", "s"], + ), + }; + let units = u + .iter() + .map(|x| format!("\"{}\"", json::escape(x))) + .collect::>() + .join(","); + format!( + "{{\"just\":\"{}\",\"pre\":\"{}\",\"post\":\"{}\",\"u\":[{units}]}}", + json::escape(just), + json::escape(pre), + json::escape(post) ) } @@ -499,7 +644,6 @@ code{font-family:var(--mono);font-size:.88em;background:var(--panel2);border:1px border-radius:6px;padding:1px 6px;color:var(--gold)} :focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px} -/* hero */ .hero{text-align:center;padding:14px 0 30px} .prompt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;color:var(--faint);margin-bottom:10px} .prompt::before{content:"$ ";color:var(--up)} @@ -507,7 +651,7 @@ border-radius:6px;padding:1px 6px;color:var(--gold)} margin:0;letter-spacing:-.04em; background:linear-gradient(180deg,#fff,#c9c7db);-webkit-background-clip:text;background-clip:text;color:transparent} .cursor{color:var(--gold);-webkit-text-fill-color:var(--gold)} -.tag{color:var(--muted);max-width:44ch;margin:16px auto 0;text-wrap:balance} +.tag{color:var(--muted);max-width:46ch;margin:16px auto 0;text-wrap:balance} .stats{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:26px} .stat{background:var(--panel);border:1px solid var(--line);border-radius:14px; padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px} @@ -518,7 +662,6 @@ padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px} margin:34px 2px 14px;display:flex;align-items:center;gap:10px} .eyebrow span{color:var(--gold);font-family:var(--mono);letter-spacing:.04em} -/* podium */ .podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end; gap:14px;flex-wrap:wrap} .pillar{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line); @@ -533,24 +676,20 @@ box-shadow:0 0 0 1px rgba(245,182,66,.18),0 20px 50px -24px rgba(245,182,66,.4)} .pillar .why{font-size:12px;color:var(--muted);text-align:center;text-wrap:balance;margin-top:2px} .pillar .by{color:var(--faint)} -/* avatars */ .ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%; font-family:var(--mono);font-weight:600;color:#fff;flex:none; background:hsl(var(--h) 42% 42%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)} .ava.lg{width:56px;height:56px;font-size:19px} .ava.sm{width:34px;height:34px;font-size:13px} -/* score colors */ .score.up,.chip.up{color:var(--up)}.score.down,.chip.down{color:var(--down)}.score.zero{color:var(--muted)} -/* sparkline */ .spark{width:104px;height:30px;flex:none} .spark polyline{fill:none;stroke-width:1.6;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round} .spark.up polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)} .spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)} .pillar .spark{width:150px;margin-top:4px} -/* ledger + list rows */ .list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px} .row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line); border-radius:13px;padding:11px 15px} @@ -563,7 +702,6 @@ font-variant-numeric:tabular-nums} .row .score{font-family:var(--mono);font-weight:700;font-size:18px;font-variant-numeric:tabular-nums; min-width:46px;text-align:right;flex:none} -/* activity + givers band */ .band{display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px} @media(min-width:720px){.band{grid-template-columns:1.35fr 1fr;align-items:start}} .panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:6px 18px 18px} @@ -584,8 +722,6 @@ border-radius:6px;padding:0 6px;margin-left:2px} .ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums} footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2} - -/* empty (no karma at all) */ .empty{text-align:center;color:var(--muted);padding:56px 10px} @media(max-width:560px){ @@ -609,23 +745,18 @@ footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;lin
          "##; -const EMPTY: &str = r##"
          No karma yet.
          Say nick++ in the channel and this board comes alive.
          "##; - -const SCRIPT: &str = r##" -"##; +})();"##; const TAIL: &str = "
          \n"; diff --git a/src/lib.rs b/src/lib.rs index c36544c..d5c042c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod bot; pub mod config; pub mod http; +pub mod i18n; pub mod irc; pub mod json; pub mod karma_web; diff --git a/tests/karma.rs b/tests/karma.rs index b3692b7..159b1ff 100644 --- a/tests/karma.rs +++ b/tests/karma.rs @@ -1,5 +1,6 @@ use rustbot::bot::module::{Action, Chat, Command, Module}; use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma}; +use rustbot::i18n::Locale; fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> { Chat { @@ -208,3 +209,53 @@ fn on_message_appends_to_the_event_log() { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(&log); } + +#[test] +fn spanish_reason_connector_is_stripped() { + assert_eq!( + extract_reason("rust++ porque es rápido").as_deref(), + Some("es rápido") + ); + assert_eq!( + extract_reason("rust++ por los tests").as_deref(), + Some("los tests") + ); +} + +#[test] +fn replies_in_spanish_for_es_locale() { + let path = std::env::temp_dir().join("rubot-test-karma-es.json"); + let log = std::env::temp_dir().join("rubot-test-karma-es.events.jsonl"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&log); + let mut k = Karma::with_path_locale(path.clone(), Locale::Es); + k.on_message(&chat("alice", "rust++ porque es bueno")); + // report + let cmd = Command { + sender: "z", + reply_to: "#argentina", + name: "karma", + args: &["rust"], + prefix: ">", + network: "libera", + }; + let r = match k.on_command(&cmd).as_slice() { + [Action::Reply(s)] => visible(s), + _ => panic!("expected one reply"), + }; + assert!(r.contains("tiene 1 de karma"), "{r}"); + assert!(r.contains("(#1 de 1)"), "{r}"); + assert!(r.contains("último: \"es bueno\""), "{r}"); + // unseen thing + let cmd2 = Command { + args: &["ghost"], + ..cmd + }; + let ghost = match k.on_command(&cmd2).as_slice() { + [Action::Reply(s)] => visible(s), + _ => panic!("expected one reply"), + }; + assert!(ghost.contains("todavía no hay karma para"), "{ghost}"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&log); +} diff --git a/tests/karma_web.rs b/tests/karma_web.rs index 6412986..6c37d1f 100644 --- a/tests/karma_web.rs +++ b/tests/karma_web.rs @@ -1,3 +1,4 @@ +use rustbot::i18n::Locale; use rustbot::karma_web::{parse_board, parse_events, render}; fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board { @@ -51,11 +52,8 @@ fn renders_hero_podium_and_scores() { "libera", r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#, ); - let html = render(&[b], 0); - assert!( - html.contains(">karma<"), - "wordmark missing: has no karma mark" - ); + let html = render(&[b], 0, Locale::En); + assert!(html.contains(">karma<"), "wordmark missing"); assert!(html.contains("podium"), "no podium"); assert!(html.contains(">rust<"), "champion nick missing"); assert!(html.contains(">+5<"), "score missing"); @@ -67,34 +65,42 @@ fn renders_activity_and_givers_from_events() { let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\ {\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n"; let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log); - let html = render(&[b], 0); + let html = render(&[b], 0, Locale::En); assert!(html.contains("recent activity"), "no activity section"); assert!(html.contains("top givers"), "no givers section"); assert!(html.contains("gave"), "no feed verb"); - // alice made two gifts -> shown in givers panel - assert!( - html.contains("2 gifts"), - "giver count wrong: {}", - html.contains("gift") - ); - assert!( - html.contains("data-t=\"200\""), - "feed sorted/newest-first missing" - ); + assert!(html.contains("2 gifts"), "giver count wrong"); + assert!(html.contains("data-t=\"200\""), "feed newest-first missing"); } #[test] fn empty_states_are_friendly() { - // no karma at all - let html = render(&[], 0); + let html = render(&[], 0, Locale::En); assert!(html.contains("No karma yet"), "{html}"); - // karma but no event history yet let b = parse_board("libera", r#"{"romaka":2}"#); - let html = render(&[b], 0); + let html = render(&[b], 0, Locale::En); assert!(html.contains("Nothing yet"), "empty activity state missing"); assert!(html.contains("Be the first"), "empty givers state missing"); } +#[test] +fn renders_spanish_chrome() { + let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"nadia\",\"why\":\"buen PR\"}\n"; + let b = board_with_events("libera", r#"{"rust":1}"#, log); + let html = render(&[b], 0, Locale::Es); + assert!(html.contains("karma neto"), "es stat label missing"); + assert!( + html.contains("actividad reciente"), + "es activity heading missing" + ); + assert!(html.contains("top donantes"), "es givers heading missing"); + assert!(html.contains("le dio a"), "es feed verb missing"); + // relative-time config for the client is Spanish + assert!(html.contains("\"pre\":\"hace \""), "es time config missing"); + // user content is never translated + assert!(html.contains("buen PR") && html.contains(">rust<")); +} + #[test] fn escapes_untrusted_irc_input_everywhere() { let log = "{\"t\":1,\"o\":\"