i18n: per-network locale (en/es/fr) for karma replies and the web board
All checks were successful
ci / check (push) Successful in 45s
All checks were successful
ci / check (push) Successful in 45s
This commit is contained in:
parent
f290554325
commit
739fc8ad94
11 changed files with 406 additions and 115 deletions
22
README.md
22
README.md
|
|
@ -90,7 +90,7 @@ over TLS.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --release --bin karma-web
|
cargo build --release --bin karma-web
|
||||||
./target/release/karma-web <data-dir> <out.html> # omit out.html to print to stdout
|
./target/release/karma-web <data-dir> <out.html> [locale] # locale: en|es|fr (default en)
|
||||||
```
|
```
|
||||||
|
|
||||||
On mars: `karma-web.service` + `karma-web.timer` write `/var/www/karma/index.html`,
|
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`,
|
Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`,
|
||||||
`channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default
|
`channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default
|
||||||
on), `password`, `sasl_user`, `sasl_pass`, and `name` (a network's log label).
|
on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and
|
||||||
Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s.
|
`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 `.`).
|
Web modules write per-network state under `RUSTBOT_DATA_DIR` (default `.`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use rustbot::i18n::Locale;
|
||||||
use rustbot::karma_web::{parse_board, parse_events, render, Board};
|
use rustbot::karma_web::{parse_board, parse_events, render, Board};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
@ -11,6 +12,11 @@ fn main() {
|
||||||
.or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok())
|
.or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok())
|
||||||
.unwrap_or_else(|| ".".to_string());
|
.unwrap_or_else(|| ".".to_string());
|
||||||
let out = args.next();
|
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<Board> = Vec::new();
|
let mut boards: Vec<Board> = Vec::new();
|
||||||
if let Ok(rd) = fs::read_dir(&dir) {
|
if let Ok(rd) = fs::read_dir(&dir) {
|
||||||
|
|
@ -38,7 +44,7 @@ fn main() {
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_secs())
|
.map(|d| d.as_secs())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let html = render(&boards, epoch);
|
let html = render(&boards, epoch, locale);
|
||||||
|
|
||||||
match out {
|
match out {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ impl Bot {
|
||||||
pub fn new(config: Config, conn: Connection) -> Bot {
|
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||||
let current_nick = config.nick.clone();
|
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();
|
let mut routes: HashMap<&'static str, usize> = HashMap::new();
|
||||||
for (i, m) in mods.iter().enumerate() {
|
for (i, m) in mods.iter().enumerate() {
|
||||||
for spec in m.commands() {
|
for spec in m.commands() {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::bot::module::{Action, Chat, Command, CommandSpec, Module};
|
use crate::bot::module::{Action, Chat, Command, CommandSpec, Module};
|
||||||
|
use crate::i18n::Locale;
|
||||||
use crate::json::{self, Json};
|
use crate::json::{self, Json};
|
||||||
|
|
||||||
const COMMANDS: &[CommandSpec] = &[
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
|
@ -29,21 +30,30 @@ const ZWSP: char = '\u{200B}';
|
||||||
|
|
||||||
pub struct Karma {
|
pub struct Karma {
|
||||||
store: Store,
|
store: Store,
|
||||||
|
locale: Locale,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Karma {
|
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 dir = std::env::var("RUSTBOT_DATA_DIR").unwrap_or_else(|_| ".".to_string());
|
||||||
let safe: String = network
|
let safe: String = network
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||||
.collect();
|
.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 {
|
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 {
|
Karma {
|
||||||
store: Store::load(path),
|
store: Store::load(path),
|
||||||
|
locale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,19 +62,30 @@ impl Karma {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report(&self, thing: &str) -> String {
|
fn report(&self, thing: &str) -> String {
|
||||||
|
let dh = dehighlight(thing);
|
||||||
let Some(e) = self.store.entry(thing) else {
|
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 (rank, total) = self.store.rank(thing);
|
||||||
let mut out = format!(
|
let n = e.count;
|
||||||
"{} has {} karma (#{rank} of {total})",
|
let mut out = match self.locale {
|
||||||
dehighlight(thing),
|
Locale::En => format!("{dh} has {n} karma (#{rank} of {total})"),
|
||||||
e.count
|
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 {
|
if let Some(reason) = &e.reason {
|
||||||
|
let last = match self.locale {
|
||||||
|
Locale::En => "last",
|
||||||
|
Locale::Es => "último",
|
||||||
|
Locale::Fr => "dernier",
|
||||||
|
};
|
||||||
match &e.by {
|
match &e.by {
|
||||||
Some(by) => out.push_str(&format!(" — last: \"{reason}\" ({})", dehighlight(by))),
|
Some(by) => out.push_str(&format!(" — {last}: \"{reason}\" ({})", dehighlight(by))),
|
||||||
None => out.push_str(&format!(" — last: \"{reason}\"")),
|
None => out.push_str(&format!(" — {last}: \"{reason}\"")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
@ -73,17 +94,25 @@ impl Karma {
|
||||||
fn leaderboard(&self, bottom: bool) -> String {
|
fn leaderboard(&self, bottom: bool) -> String {
|
||||||
let entries = self.store.ranked(TOP_N, bottom);
|
let entries = self.store.ranked(TOP_N, bottom);
|
||||||
if entries.is_empty() {
|
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<String> = entries
|
let items: Vec<String> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| format!("{} ({v})", dehighlight(k)))
|
.map(|(k, v)| format!("{} ({v})", dehighlight(k)))
|
||||||
.collect();
|
.collect();
|
||||||
format!(
|
let label = match (self.locale, bottom) {
|
||||||
"karma {}: {}",
|
(Locale::En, false) => "karma top",
|
||||||
if bottom { "bottom" } else { "top" },
|
(Locale::En, true) => "karma bottom",
|
||||||
items.join(", ")
|
(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));
|
.log_event(&format_event(t, thing, *delta, chat.sender, r));
|
||||||
let new = self.store.bump(thing, *delta, r, chat.sender);
|
let new = self.store.bump(thing, *delta, r, chat.sender);
|
||||||
if *delta > 0 && MILESTONES.contains(&new) {
|
if *delta > 0 && MILESTONES.contains(&new) {
|
||||||
announces.push(Action::reply(format!(
|
let dh = dehighlight(thing);
|
||||||
"🎉 {} reached {new} karma!",
|
announces.push(Action::reply(match self.locale {
|
||||||
dehighlight(thing)
|
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() {
|
if let Err(e) = self.store.save() {
|
||||||
|
|
@ -188,7 +219,16 @@ pub fn extract_reason(text: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
let mut reason = toks[bumps[0] + 1..].join(" ");
|
let mut reason = toks[bumps[0] + 1..].join(" ");
|
||||||
reason = reason.trim().to_string();
|
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) {
|
if let Some(r) = reason.strip_prefix(prefix) {
|
||||||
reason = r.to_string();
|
reason = r.to_string();
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,14 @@ pub mod weather;
|
||||||
pub mod wiki;
|
pub mod wiki;
|
||||||
|
|
||||||
use super::module::Module;
|
use super::module::Module;
|
||||||
|
use crate::i18n::Locale;
|
||||||
|
|
||||||
pub fn all(network: &str) -> Vec<Box<dyn Module>> {
|
pub fn all(network: &str, locale: Locale) -> Vec<Box<dyn Module>> {
|
||||||
vec![
|
vec![
|
||||||
Box::new(builtins::Builtins),
|
Box::new(builtins::Builtins),
|
||||||
Box::new(dice::Dice::new()),
|
Box::new(dice::Dice::new()),
|
||||||
Box::new(hash::Hash),
|
Box::new(hash::Hash),
|
||||||
Box::new(karma::Karma::new(network)),
|
Box::new(karma::Karma::new(network, locale)),
|
||||||
Box::new(crypto::Crypto),
|
Box::new(crypto::Crypto),
|
||||||
Box::new(define::Define),
|
Box::new(define::Define),
|
||||||
Box::new(down::Down),
|
Box::new(down::Down),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub struct Config {
|
||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub sasl_user: Option<String>,
|
pub sasl_user: Option<String>,
|
||||||
pub sasl_pass: Option<String>,
|
pub sasl_pass: Option<String>,
|
||||||
|
pub locale: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -36,6 +37,7 @@ impl Default for Config {
|
||||||
password: None,
|
password: None,
|
||||||
sasl_user: None,
|
sasl_user: None,
|
||||||
sasl_pass: 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()),
|
"password" => c.password = (!value.is_empty()).then(|| value.to_string()),
|
||||||
"sasl_user" => c.sasl_user = (!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()),
|
"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}'")),
|
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") {
|
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
|
||||||
c.sasl_pass = Some(v);
|
c.sasl_pass = Some(v);
|
||||||
}
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_LOCALE") {
|
||||||
|
c.locale = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(value: &str) -> bool {
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
|
|
||||||
33
src/i18n.rs
Normal file
33
src/i18n.rs
Normal file
|
|
@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
263
src/karma_web.rs
263
src/karma_web.rs
|
|
@ -1,6 +1,7 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::json::Json;
|
use crate::i18n::Locale;
|
||||||
|
use crate::json::{self, Json};
|
||||||
|
|
||||||
pub struct Row {
|
pub struct Row {
|
||||||
pub thing: String,
|
pub thing: String,
|
||||||
|
|
@ -253,7 +254,104 @@ fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec<Giver> {
|
||||||
v
|
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 <code>nick++</code> 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 <code>nick++</code> in the channel and this board comes alive.",
|
||||||
|
empty_activity: "Nothing yet. The next <code>nick++</code> shows up here.",
|
||||||
|
empty_givers: "No one has handed out karma yet. Be the first.",
|
||||||
|
give: "give karma by saying <code>nick++</code> in the channel",
|
||||||
|
built: "built by",
|
||||||
|
updated: "updated",
|
||||||
|
},
|
||||||
|
Locale::Es => Lex {
|
||||||
|
tagline: "reputación ganada de a un <code>nick++</code>, 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í <code>nick++</code> en el canal y este tablero cobra vida.",
|
||||||
|
empty_activity: "Todavía nada. El próximo <code>nick++</code> aparece acá.",
|
||||||
|
empty_givers: "Nadie repartió karma todavía. Sé el primero.",
|
||||||
|
give: "da karma escribiendo <code>nick++</code> en el canal",
|
||||||
|
built: "hecho con",
|
||||||
|
updated: "actualizado",
|
||||||
|
},
|
||||||
|
Locale::Fr => Lex {
|
||||||
|
tagline: "la réputation gagnée un <code>nick++</code> à 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 <code>nick++</code> dans le canal et ce tableau prend vie.",
|
||||||
|
empty_activity: "Rien pour l'instant. Le prochain <code>nick++</code> apparaît ici.",
|
||||||
|
empty_givers: "Personne n'a encore donné de karma. Sois le premier.",
|
||||||
|
give: "donne du karma en disant <code>nick++</code> 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 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();
|
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 mut s = String::from(HEAD);
|
||||||
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
|
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
|
||||||
|
|
||||||
// hero
|
|
||||||
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
|
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
|
||||||
s.push_str("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>");
|
s.push_str("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>");
|
||||||
s.push_str(
|
s.push_str(&format!("<p class=\"tag\">{}</p>", lx.tagline));
|
||||||
"<p class=\"tag\">reputation earned one <code>nick++</code> at a time, across the channels.</p>",
|
|
||||||
);
|
|
||||||
s.push_str("<div class=\"stats\">");
|
s.push_str("<div class=\"stats\">");
|
||||||
s.push_str(&stat(&things.to_string(), "tracked"));
|
s.push_str(&stat(&things.to_string(), lx.s_tracked));
|
||||||
s.push_str(&stat(&signed(total_points), "net karma"));
|
s.push_str(&stat(&signed(total_points), lx.s_net));
|
||||||
s.push_str(&stat(&gifts.to_string(), "gifts logged"));
|
s.push_str(&stat(&gifts.to_string(), lx.s_gifts));
|
||||||
s.push_str(&stat(&givers_set.len().to_string(), "givers"));
|
s.push_str(&stat(&givers_set.len().to_string(), lx.s_givers));
|
||||||
s.push_str("</div></header>");
|
s.push_str("</div></header>");
|
||||||
|
|
||||||
if things == 0 {
|
if things == 0 {
|
||||||
s.push_str(EMPTY);
|
s.push_str(&format!(
|
||||||
s.push_str(&footer(updated_epoch));
|
"<div class=\"empty fade\">{}<br>{}</div>",
|
||||||
s.push_str(SCRIPT);
|
lx.empty_title, lx.empty_sub
|
||||||
s.push_str(TAIL);
|
));
|
||||||
return s;
|
return finish(s, updated_epoch, locale, &lx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// per-network board(s): champion podium + the rest as a ledger
|
|
||||||
for b in boards {
|
for b in boards {
|
||||||
if b.rows.is_empty() {
|
if b.rows.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -300,23 +394,31 @@ pub fn render(boards: &[Board], updated_epoch: u64) -> String {
|
||||||
s.push_str("<section class=\"board fade\">");
|
s.push_str("<section class=\"board fade\">");
|
||||||
if multi {
|
if multi {
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
"<div class=\"eyebrow\">network<span>{}</span></div>",
|
"<div class=\"eyebrow\">{}<span>{}</span></div>",
|
||||||
|
lx.network,
|
||||||
esc(&b.network)
|
esc(&b.network)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
s.push_str(&podium(b));
|
s.push_str(&podium(b));
|
||||||
s.push_str(&ledger(b));
|
s.push_str(&ledger(b, &lx));
|
||||||
s.push_str("</section>");
|
s.push_str("</section>");
|
||||||
}
|
}
|
||||||
|
|
||||||
// activity + givers band
|
|
||||||
s.push_str("<div class=\"band\">");
|
s.push_str("<div class=\"band\">");
|
||||||
s.push_str(&activity(&feed, multi));
|
s.push_str(&activity(&feed, multi, &lx));
|
||||||
s.push_str(&givers_panel(&feed));
|
s.push_str(&givers_panel(&feed, &lx));
|
||||||
s.push_str("</div>");
|
s.push_str("</div>");
|
||||||
|
|
||||||
s.push_str(&footer(updated_epoch));
|
finish(s, updated_epoch, locale, &lx)
|
||||||
s.push_str(SCRIPT);
|
}
|
||||||
|
|
||||||
|
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
|
||||||
|
s.push_str(&footer(epoch, lx));
|
||||||
|
s.push_str("<script>var C=");
|
||||||
|
s.push_str(&time_cfg(locale));
|
||||||
|
s.push_str(";\n");
|
||||||
|
s.push_str(SCRIPT_BODY);
|
||||||
|
s.push_str("</script>");
|
||||||
s.push_str(TAIL);
|
s.push_str(TAIL);
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
@ -363,11 +465,14 @@ fn podium(b: &Board) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ledger(b: &Board) -> String {
|
fn ledger(b: &Board, lx: &Lex) -> String {
|
||||||
if b.rows.len() <= 3 {
|
if b.rows.len() <= 3 {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let mut out = String::from("<div class=\"eyebrow\">the ledger</div><ol class=\"list\">");
|
let mut out = format!(
|
||||||
|
"<div class=\"eyebrow\">{}</div><ol class=\"list\">",
|
||||||
|
lx.ledger
|
||||||
|
);
|
||||||
for (i, r) in b.rows.iter().enumerate().skip(3) {
|
for (i, r) in b.rows.iter().enumerate().skip(3) {
|
||||||
let rank = i + 1;
|
let rank = i + 1;
|
||||||
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
|
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
|
||||||
|
|
@ -394,20 +499,22 @@ fn ledger(b: &Board) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
|
fn activity(feed: &[(&str, &Event)], multi: bool, lx: &Lex) -> String {
|
||||||
let mut out = String::from(
|
let mut out = format!(
|
||||||
"<section class=\"panel fade\"><div class=\"eyebrow live-h\">\
|
"<section class=\"panel fade\"><div class=\"eyebrow live-h\">\
|
||||||
<span class=\"live\"></span>recent activity</div>",
|
<span class=\"live\"></span>{}</div>",
|
||||||
|
lx.recent
|
||||||
);
|
);
|
||||||
if feed.is_empty() {
|
if feed.is_empty() {
|
||||||
out.push_str(
|
out.push_str(&format!(
|
||||||
"<div class=\"empty-panel\">Nothing yet. The next <code>nick++</code> shows up here.</div></section>",
|
"<div class=\"empty-panel\">{}</div></section>",
|
||||||
);
|
lx.empty_activity
|
||||||
|
));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
out.push_str("<ul class=\"timeline\">");
|
out.push_str("<ul class=\"timeline\">");
|
||||||
for (net, e) in feed.iter().take(40) {
|
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 dchip = if e.delta >= 0 { "++" } else { "--" };
|
||||||
let why = match &e.why {
|
let why = match &e.why {
|
||||||
Some(w) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
|
Some(w) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
|
||||||
|
|
@ -420,12 +527,13 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
|
||||||
};
|
};
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<li class=\"ev {}\">{}<div class=\"evbody\"><div class=\"evline\">\
|
"<li class=\"ev {}\">{}<div class=\"evbody\"><div class=\"evline\">\
|
||||||
<b>{}</b> <span class=\"verb\">gave</span> <b>{}</b> \
|
<b>{}</b> <span class=\"verb\">{}</span> <b>{}</b> \
|
||||||
<span class=\"chip {}\">{dchip}</span>{netlabel}</div>{why}\
|
<span class=\"chip {}\">{dchip}</span>{netlabel}</div>{why}\
|
||||||
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
|
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
|
||||||
klass(e.delta),
|
klass(e.delta),
|
||||||
avatar(&actor, "sm"),
|
avatar(&actor, "sm"),
|
||||||
esc(&actor),
|
esc(&actor),
|
||||||
|
lx.gave,
|
||||||
esc(&e.thing),
|
esc(&e.thing),
|
||||||
klass(e.delta),
|
klass(e.delta),
|
||||||
e.t,
|
e.t,
|
||||||
|
|
@ -435,28 +543,35 @@ fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn givers_panel(feed: &[(&str, &Event)]) -> String {
|
fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||||
let givers = top_givers(feed, 8);
|
let givers = top_givers(feed, 8);
|
||||||
let mut out =
|
let mut out = format!(
|
||||||
String::from("<section class=\"panel fade\"><div class=\"eyebrow\">top givers</div>");
|
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div>",
|
||||||
|
lx.top_givers
|
||||||
|
);
|
||||||
if givers.is_empty() {
|
if givers.is_empty() {
|
||||||
out.push_str(
|
out.push_str(&format!(
|
||||||
"<div class=\"empty-panel\">No one has handed out karma yet. Be the first.</div></section>",
|
"<div class=\"empty-panel\">{}</div></section>",
|
||||||
);
|
lx.empty_givers
|
||||||
|
));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
out.push_str("<ol class=\"list givers\">");
|
out.push_str("<ol class=\"list givers\">");
|
||||||
for (i, g) in givers.iter().enumerate() {
|
for (i, g) in givers.iter().enumerate() {
|
||||||
|
let unit = if g.gives == 1 {
|
||||||
|
lx.gift_one
|
||||||
|
} else {
|
||||||
|
lx.gift_many
|
||||||
|
};
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<li class=\"row\"><span class=\"rank\">{}</span>{}\
|
"<li class=\"row\"><span class=\"rank\">{}</span>{}\
|
||||||
<div class=\"who\"><div class=\"nick\">{}</div>\
|
<div class=\"who\"><div class=\"nick\">{}</div>\
|
||||||
<span class=\"why\">{} gift{}</span></div>\
|
<span class=\"why\">{} {unit}</span></div>\
|
||||||
<span class=\"score {}\">{}</span></li>",
|
<span class=\"score {}\">{}</span></li>",
|
||||||
i + 1,
|
i + 1,
|
||||||
avatar(&g.name, "sm"),
|
avatar(&g.name, "sm"),
|
||||||
esc(&g.name),
|
esc(&g.name),
|
||||||
g.gives,
|
g.gives,
|
||||||
if g.gives == 1 { "" } else { "s" },
|
|
||||||
klass(g.points),
|
klass(g.points),
|
||||||
signed(g.points),
|
signed(g.points),
|
||||||
));
|
));
|
||||||
|
|
@ -465,11 +580,41 @@ fn givers_panel(feed: &[(&str, &Event)]) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn footer(epoch: u64) -> String {
|
fn footer(epoch: u64, lx: &Lex) -> String {
|
||||||
format!(
|
format!(
|
||||||
"<footer class=\"fade\">give karma by saying <code>nick++</code> in the channel \
|
"<footer class=\"fade\">{} · {} \
|
||||||
· built by <a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
|
<a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
|
||||||
· updated <time class=\"ago\" data-t=\"{epoch}\"></time></footer>"
|
· {} <time class=\"ago\" data-t=\"{epoch}\"></time></footer>",
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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)}
|
border-radius:6px;padding:1px 6px;color:var(--gold)}
|
||||||
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
|
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
|
||||||
|
|
||||||
/* hero */
|
|
||||||
.hero{text-align:center;padding:14px 0 30px}
|
.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{font-family:var(--mono);font-size:12px;letter-spacing:.06em;color:var(--faint);margin-bottom:10px}
|
||||||
.prompt::before{content:"$ ";color:var(--up)}
|
.prompt::before{content:"$ ";color:var(--up)}
|
||||||
|
|
@ -507,7 +651,7 @@ border-radius:6px;padding:1px 6px;color:var(--gold)}
|
||||||
margin:0;letter-spacing:-.04em;
|
margin:0;letter-spacing:-.04em;
|
||||||
background:linear-gradient(180deg,#fff,#c9c7db);-webkit-background-clip:text;background-clip:text;color:transparent}
|
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)}
|
.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}
|
.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;
|
.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}
|
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}
|
margin:34px 2px 14px;display:flex;align-items:center;gap:10px}
|
||||||
.eyebrow span{color:var(--gold);font-family:var(--mono);letter-spacing:.04em}
|
.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;
|
.podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end;
|
||||||
gap:14px;flex-wrap:wrap}
|
gap:14px;flex-wrap:wrap}
|
||||||
.pillar{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);
|
.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 .why{font-size:12px;color:var(--muted);text-align:center;text-wrap:balance;margin-top:2px}
|
||||||
.pillar .by{color:var(--faint)}
|
.pillar .by{color:var(--faint)}
|
||||||
|
|
||||||
/* avatars */
|
|
||||||
.ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
|
.ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
|
||||||
font-family:var(--mono);font-weight:600;color:#fff;flex:none;
|
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)}
|
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.lg{width:56px;height:56px;font-size:19px}
|
||||||
.ava.sm{width:34px;height:34px;font-size:13px}
|
.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)}
|
.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{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 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 polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)}
|
||||||
.spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)}
|
.spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)}
|
||||||
.pillar .spark{width:150px;margin-top:4px}
|
.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}
|
.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);
|
.row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);
|
||||||
border-radius:13px;padding:11px 15px}
|
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;
|
.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}
|
min-width:46px;text-align:right;flex:none}
|
||||||
|
|
||||||
/* activity + givers band */
|
|
||||||
.band{display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px}
|
.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}}
|
@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}
|
.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}
|
.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}
|
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}
|
.empty{text-align:center;color:var(--muted);padding:56px 10px}
|
||||||
|
|
||||||
@media(max-width:560px){
|
@media(max-width:560px){
|
||||||
|
|
@ -609,23 +745,18 @@ footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;lin
|
||||||
<div class="page">
|
<div class="page">
|
||||||
"##;
|
"##;
|
||||||
|
|
||||||
const EMPTY: &str = r##"<div class="empty fade">No karma yet.<br>Say <code>nick++</code> in the channel and this board comes alive.</div>"##;
|
const SCRIPT_BODY: &str = r##"(function(){
|
||||||
|
|
||||||
const SCRIPT: &str = r##"<script>
|
|
||||||
(function(){
|
|
||||||
function ago(sec){
|
function ago(sec){
|
||||||
var d=Math.max(0,Math.floor(Date.now()/1000)-sec);
|
var d=Math.max(0,Math.floor(Date.now()/1000)-sec);
|
||||||
if(d<45)return"just now";
|
if(d<45)return C.just;
|
||||||
var u=[[31536000,"y"],[2592000,"mo"],[604800,"w"],[86400,"d"],[3600,"h"],[60,"m"]];
|
var u=[[31536000,0],[2592000,1],[604800,2],[86400,3],[3600,4],[60,5]];
|
||||||
for(var i=0;i<u.length;i++){if(d>=u[i][0])return Math.floor(d/u[i][0])+u[i][1]+" ago";}
|
for(var i=0;i<u.length;i++){if(d>=u[i][0]){return C.pre+Math.floor(d/u[i][0])+C.u[u[i][1]]+C.post;}}
|
||||||
return Math.floor(d/1)+"s ago";
|
return C.pre+d+C.u[6]+C.post;
|
||||||
}
|
}
|
||||||
document.querySelectorAll("time.ago[data-t]").forEach(function(t){
|
document.querySelectorAll("time.ago[data-t]").forEach(function(t){
|
||||||
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
|
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
|
||||||
});
|
});
|
||||||
setTimeout(function(){location.reload();},60000);
|
setTimeout(function(){location.reload();},60000);
|
||||||
})();
|
})();"##;
|
||||||
</script>
|
|
||||||
"##;
|
|
||||||
|
|
||||||
const TAIL: &str = "</div></body></html>\n";
|
const TAIL: &str = "</div></body></html>\n";
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
pub mod bot;
|
pub mod bot;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod i18n;
|
||||||
pub mod irc;
|
pub mod irc;
|
||||||
pub mod json;
|
pub mod json;
|
||||||
pub mod karma_web;
|
pub mod karma_web;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use rustbot::bot::module::{Action, Chat, Command, Module};
|
use rustbot::bot::module::{Action, Chat, Command, Module};
|
||||||
use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma};
|
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> {
|
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
|
||||||
Chat {
|
Chat {
|
||||||
|
|
@ -208,3 +209,53 @@ fn on_message_appends_to_the_event_log() {
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let _ = std::fs::remove_file(&log);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use rustbot::i18n::Locale;
|
||||||
use rustbot::karma_web::{parse_board, parse_events, render};
|
use rustbot::karma_web::{parse_board, parse_events, render};
|
||||||
|
|
||||||
fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board {
|
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",
|
"libera",
|
||||||
r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#,
|
r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#,
|
||||||
);
|
);
|
||||||
let html = render(&[b], 0);
|
let html = render(&[b], 0, Locale::En);
|
||||||
assert!(
|
assert!(html.contains(">karma<"), "wordmark missing");
|
||||||
html.contains(">karma<"),
|
|
||||||
"wordmark missing: has no karma mark"
|
|
||||||
);
|
|
||||||
assert!(html.contains("podium"), "no podium");
|
assert!(html.contains("podium"), "no podium");
|
||||||
assert!(html.contains(">rust<"), "champion nick missing");
|
assert!(html.contains(">rust<"), "champion nick missing");
|
||||||
assert!(html.contains(">+5<"), "score 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\
|
let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\
|
||||||
{\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n";
|
{\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n";
|
||||||
let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log);
|
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("recent activity"), "no activity section");
|
||||||
assert!(html.contains("top givers"), "no givers section");
|
assert!(html.contains("top givers"), "no givers section");
|
||||||
assert!(html.contains("gave"), "no feed verb");
|
assert!(html.contains("gave"), "no feed verb");
|
||||||
// alice made two gifts -> shown in givers panel
|
assert!(html.contains("2 gifts"), "giver count wrong");
|
||||||
assert!(
|
assert!(html.contains("data-t=\"200\""), "feed newest-first missing");
|
||||||
html.contains("2 gifts"),
|
|
||||||
"giver count wrong: {}",
|
|
||||||
html.contains("gift")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
html.contains("data-t=\"200\""),
|
|
||||||
"feed sorted/newest-first missing"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_states_are_friendly() {
|
fn empty_states_are_friendly() {
|
||||||
// no karma at all
|
let html = render(&[], 0, Locale::En);
|
||||||
let html = render(&[], 0);
|
|
||||||
assert!(html.contains("No karma yet"), "{html}");
|
assert!(html.contains("No karma yet"), "{html}");
|
||||||
// karma but no event history yet
|
|
||||||
let b = parse_board("libera", r#"{"romaka":2}"#);
|
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("Nothing yet"), "empty activity state missing");
|
||||||
assert!(html.contains("Be the first"), "empty givers 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]
|
#[test]
|
||||||
fn escapes_untrusted_irc_input_everywhere() {
|
fn escapes_untrusted_irc_input_everywhere() {
|
||||||
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";
|
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";
|
||||||
|
|
@ -103,7 +109,7 @@ fn escapes_untrusted_irc_input_everywhere() {
|
||||||
r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#,
|
r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#,
|
||||||
log,
|
log,
|
||||||
);
|
);
|
||||||
let html = render(&[b], 0);
|
let html = render(&[b], 0, Locale::En);
|
||||||
assert!(!html.contains("<img"), "raw img tag leaked");
|
assert!(!html.contains("<img"), "raw img tag leaked");
|
||||||
assert!(html.contains("<script>"), "nick not escaped");
|
assert!(html.contains("<script>"), "nick not escaped");
|
||||||
assert!(html.contains("a&b"), "giver not escaped");
|
assert!(html.contains("a&b"), "giver not escaped");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue