karma-web: online status, user counts, join links, rising/chart/most-debated/milestones/reasons, filter+sort, expandable history, OG tags, JSON API
All checks were successful
ci / check (push) Successful in 46s
All checks were successful
ci / check (push) Successful in 46s
This commit is contained in:
parent
26496b5147
commit
6af92c1217
7 changed files with 714 additions and 101 deletions
33
README.md
33
README.md
|
|
@ -79,17 +79,24 @@ lists them at runtime and `help <command>` describes one.
|
||||||
|
|
||||||
## Karma web page
|
## Karma web page
|
||||||
|
|
||||||
The live karma board is published at **<https://karma.devtronic.pro>** — a hero
|
The live karma board is published at **<https://karma.devtronic.pro>**. It has a
|
||||||
with live stats, a top-3 podium, a ranked ledger with per-thing trend
|
hero with live stats, a top-3 podium, a ranked ledger (filter + sort, click a
|
||||||
sparklines, a recent-activity feed, and a top-givers board. Each network's board
|
row to expand its full history), per-thing trend sparklines, a "rising this
|
||||||
shows where its karma comes from — server, channels, and each channel's IRC
|
week" strip, a karma-over-time chart, a recent-activity feed, top givers, most
|
||||||
topic (e.g. `#argentina`). A second binary, `karma-web`, reads the per-network
|
debated, and a milestones timeline. Each network's board shows where its karma
|
||||||
`karma.*.json` totals, the append-only `karma.*.events.jsonl` history the bot
|
comes from — an online/offline dot, server, joinable channel links, live user
|
||||||
writes on every bump, and a `karma.*.meta.json` source sidecar (server +
|
counts, and each channel's IRC topic (URLs linkified). The page carries Open
|
||||||
channels + topics, which the bot refreshes as it joins and as topics change),
|
Graph tags for rich link previews, and the same data is exposed as JSON at
|
||||||
and renders one self-contained HTML page (no CSS/JS/font dependencies,
|
`/karma.json`.
|
||||||
all IRC-supplied text HTML-escaped). A systemd timer regenerates it every minute
|
|
||||||
and nginx serves it over TLS.
|
A second binary, `karma-web`, reads the per-network `karma.*.json` totals, the
|
||||||
|
append-only `karma.*.events.jsonl` history the bot writes on every bump, and a
|
||||||
|
`karma.*.meta.json` source sidecar (server, webchat base, a liveness heartbeat,
|
||||||
|
and per-channel topic + user count, which the bot refreshes as it joins, on
|
||||||
|
topic/NAMES changes, and on each server PING). It renders one self-contained
|
||||||
|
HTML page (no CSS/JS/font dependencies, all IRC-supplied text HTML-escaped) plus
|
||||||
|
`karma.json`. A systemd timer regenerates them every minute and nginx serves
|
||||||
|
over TLS.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --release --bin karma-web
|
cargo build --release --bin karma-web
|
||||||
|
|
@ -127,7 +134,9 @@ 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`, `name` (a network's log label), and
|
on), `password`, `sasl_user`, `sasl_pass`, `name` (a network's log label), and
|
||||||
`locale` (`en`/`es`/`fr`, default `en`), and `karma_url` (public web board —
|
`locale` (`en`/`es`/`fr`, default `en`), `webchat` (per-network webchat base
|
||||||
|
for the board's channel "join" links, e.g. `https://web.libera.chat/`), and
|
||||||
|
`karma_url` (public web board —
|
||||||
enables the `karmaweb` command and a link on the `karma`/`karmabottom` top
|
enables the `karmaweb` command and a link on the `karma`/`karmabottom` top
|
||||||
lists; empty disables both). Whole-line `#`/`;` comments only —
|
lists; empty disables both). Whole-line `#`/`;` comments only —
|
||||||
inline comments would clash with channel `#`s.
|
inline comments would clash with channel `#`s.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use rustbot::i18n::Locale;
|
use rustbot::i18n::Locale;
|
||||||
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Board};
|
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Board};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let mut args = std::env::args().skip(1);
|
let mut args = std::env::args().skip(1);
|
||||||
|
|
@ -40,9 +40,11 @@ fn main() {
|
||||||
}
|
}
|
||||||
let meta = p.with_file_name(format!("karma.{net}.meta.json"));
|
let meta = p.with_file_name(format!("karma.{net}.meta.json"));
|
||||||
if let Ok(mbody) = fs::read_to_string(&meta) {
|
if let Ok(mbody) = fs::read_to_string(&meta) {
|
||||||
let (server, channels) = parse_meta(&mbody);
|
let m = parse_meta(&mbody);
|
||||||
board.server = server;
|
board.server = m.server;
|
||||||
board.channels = channels;
|
board.webchat = m.webchat;
|
||||||
|
board.seen = m.seen;
|
||||||
|
board.channels = m.channels;
|
||||||
}
|
}
|
||||||
boards.push(board);
|
boards.push(board);
|
||||||
}
|
}
|
||||||
|
|
@ -58,12 +60,22 @@ fn main() {
|
||||||
|
|
||||||
match out {
|
match out {
|
||||||
Some(path) => {
|
Some(path) => {
|
||||||
let tmp = format!("{path}.tmp");
|
if let Err(e) = write_atomic(&path, &html) {
|
||||||
if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) {
|
|
||||||
eprintln!("karma-web: could not write {path}: {e}");
|
eprintln!("karma-web: could not write {path}: {e}");
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
// emit the JSON API next to the page
|
||||||
|
if let Some(dir) = PathBuf::from(&path).parent() {
|
||||||
|
let json_path = dir.join("karma.json").to_string_lossy().into_owned();
|
||||||
|
let _ = write_atomic(&json_path, &render_json(&boards, epoch));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => print!("{html}"),
|
None => print!("{html}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_atomic(path: &str, body: &str) -> std::io::Result<()> {
|
||||||
|
let tmp = format!("{path}.tmp");
|
||||||
|
fs::write(&tmp, body)?;
|
||||||
|
fs::rename(&tmp, path)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ pub struct Bot {
|
||||||
modules: Vec<Box<dyn Module>>,
|
modules: Vec<Box<dyn Module>>,
|
||||||
routes: HashMap<&'static str, usize>,
|
routes: HashMap<&'static str, usize>,
|
||||||
topics: HashMap<String, String>,
|
topics: HashMap<String, String>,
|
||||||
|
users: HashMap<String, u32>,
|
||||||
|
names_building: HashMap<String, u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Bot {
|
impl Bot {
|
||||||
|
|
@ -59,28 +61,44 @@ impl Bot {
|
||||||
modules: mods,
|
modules: mods,
|
||||||
routes,
|
routes,
|
||||||
topics: HashMap::new(),
|
topics: HashMap::new(),
|
||||||
|
users: HashMap::new(),
|
||||||
|
names_building: HashMap::new(),
|
||||||
};
|
};
|
||||||
bot.write_source_meta();
|
bot.write_source_meta();
|
||||||
bot
|
bot
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite the per-network source sidecar the web board reads: server,
|
/// Rewrite the per-network source sidecar the web board reads: server,
|
||||||
/// the configured channels, and whatever topics we've learned so far.
|
/// webchat base, a liveness timestamp, and per-channel topic + user count.
|
||||||
fn write_source_meta(&self) {
|
fn write_source_meta(&self) {
|
||||||
let channels: Vec<(String, Option<String>)> = self
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let channels: Vec<String> = self
|
||||||
.config
|
.config
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
let topic = self
|
let key = c.to_lowercase();
|
||||||
.topics
|
let mut obj = format!("{{\"name\":\"{}\"", crate::json::escape(c));
|
||||||
.get(&c.to_lowercase())
|
if let Some(t) = self.topics.get(&key).filter(|t| !t.is_empty()) {
|
||||||
.filter(|t| !t.is_empty())
|
obj.push_str(&format!(",\"topic\":\"{}\"", crate::json::escape(t)));
|
||||||
.cloned();
|
}
|
||||||
(c.clone(), topic)
|
if let Some(u) = self.users.get(&key) {
|
||||||
|
obj.push_str(&format!(",\"users\":{u}"));
|
||||||
|
}
|
||||||
|
obj.push('}');
|
||||||
|
obj
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
modules::karma::write_meta(&self.config.name, &self.config.server, &channels);
|
let body = format!(
|
||||||
|
"{{\"server\":\"{}\",\"webchat\":\"{}\",\"seen\":{now},\"channels\":[{}]}}\n",
|
||||||
|
crate::json::escape(&self.config.server),
|
||||||
|
crate::json::escape(&self.config.webchat),
|
||||||
|
channels.join(",")
|
||||||
|
);
|
||||||
|
modules::karma::write_meta(&self.config.name, &body);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) {
|
fn set_topic(&mut self, channel: Option<&str>, topic: Option<&str>) {
|
||||||
|
|
@ -117,6 +135,8 @@ impl Bot {
|
||||||
"PING" => {
|
"PING" => {
|
||||||
let token = msg.trailing().unwrap_or("").to_string();
|
let token = msg.trailing().unwrap_or("").to_string();
|
||||||
self.conn.pong(&token)?;
|
self.conn.pong(&token)?;
|
||||||
|
// heartbeat: refresh the sidecar's liveness timestamp
|
||||||
|
self.write_source_meta();
|
||||||
}
|
}
|
||||||
"CAP" => self.handle_cap(msg)?,
|
"CAP" => self.handle_cap(msg)?,
|
||||||
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
||||||
|
|
@ -140,6 +160,22 @@ impl Bot {
|
||||||
"331" => self.set_topic(msg.param(1), Some("")),
|
"331" => self.set_topic(msg.param(1), Some("")),
|
||||||
// live topic change: :who TOPIC <channel> :<topic>
|
// live topic change: :who TOPIC <channel> :<topic>
|
||||||
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
|
"TOPIC" => self.set_topic(msg.param(0), msg.trailing()),
|
||||||
|
// RPL_NAMREPLY: <nick> <sym> <channel> :n1 n2 n3 (may repeat)
|
||||||
|
"353" => {
|
||||||
|
if let (Some(chan), Some(names)) = (msg.param(2), msg.trailing()) {
|
||||||
|
let n = names.split_whitespace().count() as u32;
|
||||||
|
*self.names_building.entry(chan.to_lowercase()).or_insert(0) += n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RPL_ENDOFNAMES: finalize the count for this channel
|
||||||
|
"366" => {
|
||||||
|
if let Some(chan) = msg.param(1) {
|
||||||
|
let key = chan.to_lowercase();
|
||||||
|
let count = self.names_building.remove(&key).unwrap_or(0);
|
||||||
|
self.users.insert(key, count);
|
||||||
|
self.write_source_meta();
|
||||||
|
}
|
||||||
|
}
|
||||||
"PRIVMSG" => self.handle_privmsg(msg)?,
|
"PRIVMSG" => self.handle_privmsg(msg)?,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -438,23 +438,9 @@ fn data_path(network: &str, ext: &str) -> PathBuf {
|
||||||
|
|
||||||
/// Record where a network's karma comes from (server + channels) so the web
|
/// Record where a network's karma comes from (server + channels) so the web
|
||||||
/// page can show its source. Best-effort — a failure just omits the line.
|
/// page can show its source. Best-effort — a failure just omits the line.
|
||||||
pub fn write_meta(network: &str, server: &str, channels: &[(String, Option<String>)]) {
|
/// Atomically write the per-network source sidecar the web board reads. The
|
||||||
let chans = channels
|
/// caller (`Bot`) owns the JSON schema (server, webchat, seen, channels...).
|
||||||
.iter()
|
pub fn write_meta(network: &str, body: &str) {
|
||||||
.map(|(name, topic)| match topic {
|
|
||||||
Some(t) => format!(
|
|
||||||
"{{\"name\":\"{}\",\"topic\":\"{}\"}}",
|
|
||||||
json::escape(name),
|
|
||||||
json::escape(t)
|
|
||||||
),
|
|
||||||
None => format!("{{\"name\":\"{}\"}}", json::escape(name)),
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",");
|
|
||||||
let body = format!(
|
|
||||||
"{{\"server\":\"{}\",\"channels\":[{chans}]}}\n",
|
|
||||||
json::escape(server)
|
|
||||||
);
|
|
||||||
let path = data_path(network, "meta.json");
|
let path = data_path(network, "meta.json");
|
||||||
let mut tmp = path.clone().into_os_string();
|
let mut tmp = path.clone().into_os_string();
|
||||||
tmp.push(".tmp");
|
tmp.push(".tmp");
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ pub struct Config {
|
||||||
pub sasl_pass: Option<String>,
|
pub sasl_pass: Option<String>,
|
||||||
pub locale: String,
|
pub locale: String,
|
||||||
pub karma_url: String,
|
pub karma_url: String,
|
||||||
|
pub webchat: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -40,6 +41,7 @@ impl Default for Config {
|
||||||
sasl_pass: None,
|
sasl_pass: None,
|
||||||
locale: "en".to_string(),
|
locale: "en".to_string(),
|
||||||
karma_url: String::new(),
|
karma_url: String::new(),
|
||||||
|
webchat: String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +168,7 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||||
"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(),
|
"locale" | "lang" => c.locale = value.to_string(),
|
||||||
"karma_url" | "web_url" => c.karma_url = value.to_string(),
|
"karma_url" | "web_url" => c.karma_url = value.to_string(),
|
||||||
|
"webchat" => c.webchat = value.to_string(),
|
||||||
other => log(&format!("{where_}: unknown key '{other}'")),
|
other => log(&format!("{where_}: unknown key '{other}'")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +232,9 @@ fn apply_env(c: &mut Config) {
|
||||||
if let Ok(v) = std::env::var("IRC_KARMA_URL") {
|
if let Ok(v) = std::env::var("IRC_KARMA_URL") {
|
||||||
c.karma_url = v;
|
c.karma_url = v;
|
||||||
}
|
}
|
||||||
|
if let Ok(v) = std::env::var("IRC_WEBCHAT") {
|
||||||
|
c.webchat = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(value: &str) -> bool {
|
fn parse_bool(value: &str) -> bool {
|
||||||
|
|
|
||||||
559
src/karma_web.rs
559
src/karma_web.rs
|
|
@ -21,6 +21,14 @@ pub struct Event {
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub topic: Option<String>,
|
pub topic: Option<String>,
|
||||||
|
pub users: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Meta {
|
||||||
|
pub server: String,
|
||||||
|
pub webchat: String,
|
||||||
|
pub seen: u64,
|
||||||
|
pub channels: Vec<Channel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Board {
|
pub struct Board {
|
||||||
|
|
@ -28,6 +36,8 @@ pub struct Board {
|
||||||
pub rows: Vec<Row>,
|
pub rows: Vec<Row>,
|
||||||
pub events: Vec<Event>,
|
pub events: Vec<Event>,
|
||||||
pub server: String,
|
pub server: String,
|
||||||
|
pub webchat: String,
|
||||||
|
pub seen: u64,
|
||||||
pub channels: Vec<Channel>,
|
pub channels: Vec<Channel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +64,8 @@ pub fn parse_board(network: &str, body: &str) -> Board {
|
||||||
rows,
|
rows,
|
||||||
events: Vec::new(),
|
events: Vec::new(),
|
||||||
server: String::new(),
|
server: String::new(),
|
||||||
|
webchat: String::new(),
|
||||||
|
seen: 0,
|
||||||
channels: Vec::new(),
|
channels: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,15 +73,17 @@ pub fn parse_board(network: &str, body: &str) -> Board {
|
||||||
/// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
|
/// Parse the `karma.<net>.meta.json` sidecar the bot writes: where this
|
||||||
/// network's karma comes from. Channels may be bare strings (old format) or
|
/// network's karma comes from. Channels may be bare strings (old format) or
|
||||||
/// `{"name","topic"}` objects.
|
/// `{"name","topic"}` objects.
|
||||||
pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
|
pub fn parse_meta(body: &str) -> Meta {
|
||||||
let Some(j) = Json::parse(body) else {
|
let empty = || Meta {
|
||||||
return (String::new(), Vec::new());
|
server: String::new(),
|
||||||
|
webchat: String::new(),
|
||||||
|
seen: 0,
|
||||||
|
channels: Vec::new(),
|
||||||
};
|
};
|
||||||
let server = j
|
let Some(j) = Json::parse(body) else {
|
||||||
.get("server")
|
return empty();
|
||||||
.and_then(Json::as_str)
|
};
|
||||||
.unwrap_or("")
|
let string = |k: &str| j.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||||
.to_string();
|
|
||||||
let channels = match j.get("channels") {
|
let channels = match j.get("channels") {
|
||||||
Some(Json::Arr(items)) => items
|
Some(Json::Arr(items)) => items
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -78,6 +92,7 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
|
||||||
return (!name.is_empty()).then(|| Channel {
|
return (!name.is_empty()).then(|| Channel {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
topic: None,
|
topic: None,
|
||||||
|
users: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let name = it.get("name").and_then(Json::as_str)?;
|
let name = it.get("name").and_then(Json::as_str)?;
|
||||||
|
|
@ -89,15 +104,22 @@ pub fn parse_meta(body: &str) -> (String, Vec<Channel>) {
|
||||||
.and_then(Json::as_str)
|
.and_then(Json::as_str)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
|
let users = it.get("users").and_then(Json::as_f64).map(|n| n as u32);
|
||||||
Some(Channel {
|
Some(Channel {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
topic,
|
topic,
|
||||||
|
users,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
(server, channels)
|
Meta {
|
||||||
|
server: string("server"),
|
||||||
|
webchat: string("webchat"),
|
||||||
|
seen: j.get("seen").and_then(Json::as_f64).unwrap_or(0.0) as u64,
|
||||||
|
channels,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_events(body: &str) -> Vec<Event> {
|
pub fn parse_events(body: &str) -> Vec<Event> {
|
||||||
|
|
@ -328,6 +350,20 @@ struct Lex {
|
||||||
give: &'static str,
|
give: &'static str,
|
||||||
built: &'static str,
|
built: &'static str,
|
||||||
updated: &'static str,
|
updated: &'static str,
|
||||||
|
online: &'static str,
|
||||||
|
offline: &'static str,
|
||||||
|
rising: &'static str,
|
||||||
|
over_time: &'static str,
|
||||||
|
most_debated: &'static str,
|
||||||
|
milestones: &'static str,
|
||||||
|
reasons_recent: &'static str,
|
||||||
|
history: &'static str,
|
||||||
|
reached: &'static str,
|
||||||
|
bumps: &'static str,
|
||||||
|
filter_ph: &'static str,
|
||||||
|
sort_karma: &'static str,
|
||||||
|
sort_name: &'static str,
|
||||||
|
sort_active: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lex(l: Locale) -> Lex {
|
fn lex(l: Locale) -> Lex {
|
||||||
|
|
@ -353,6 +389,20 @@ fn lex(l: Locale) -> Lex {
|
||||||
give: "give karma by saying <code>nick++</code> in the channel",
|
give: "give karma by saying <code>nick++</code> in the channel",
|
||||||
built: "built by",
|
built: "built by",
|
||||||
updated: "updated",
|
updated: "updated",
|
||||||
|
online: "online",
|
||||||
|
offline: "offline",
|
||||||
|
rising: "rising this week",
|
||||||
|
over_time: "karma over time",
|
||||||
|
most_debated: "most debated",
|
||||||
|
milestones: "milestones",
|
||||||
|
reasons_recent: "recent reasons",
|
||||||
|
history: "history",
|
||||||
|
reached: "reached",
|
||||||
|
bumps: "bumps",
|
||||||
|
filter_ph: "filter…",
|
||||||
|
sort_karma: "karma",
|
||||||
|
sort_name: "name",
|
||||||
|
sort_active: "active",
|
||||||
},
|
},
|
||||||
Locale::Es => Lex {
|
Locale::Es => Lex {
|
||||||
tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.",
|
tagline: "reputación ganada de a un <code>nick++</code>, en todos los canales.",
|
||||||
|
|
@ -375,6 +425,20 @@ fn lex(l: Locale) -> Lex {
|
||||||
give: "da karma escribiendo <code>nick++</code> en el canal",
|
give: "da karma escribiendo <code>nick++</code> en el canal",
|
||||||
built: "hecho con",
|
built: "hecho con",
|
||||||
updated: "actualizado",
|
updated: "actualizado",
|
||||||
|
online: "en línea",
|
||||||
|
offline: "desconectado",
|
||||||
|
rising: "en alza esta semana",
|
||||||
|
over_time: "karma en el tiempo",
|
||||||
|
most_debated: "más debatidos",
|
||||||
|
milestones: "hitos",
|
||||||
|
reasons_recent: "razones recientes",
|
||||||
|
history: "historial",
|
||||||
|
reached: "llegó a",
|
||||||
|
bumps: "cambios",
|
||||||
|
filter_ph: "filtrar…",
|
||||||
|
sort_karma: "karma",
|
||||||
|
sort_name: "nombre",
|
||||||
|
sort_active: "activos",
|
||||||
},
|
},
|
||||||
Locale::Fr => Lex {
|
Locale::Fr => Lex {
|
||||||
tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.",
|
tagline: "la réputation gagnée un <code>nick++</code> à la fois, sur les canaux.",
|
||||||
|
|
@ -397,6 +461,20 @@ fn lex(l: Locale) -> Lex {
|
||||||
give: "donne du karma en disant <code>nick++</code> dans le canal",
|
give: "donne du karma en disant <code>nick++</code> dans le canal",
|
||||||
built: "fait avec",
|
built: "fait avec",
|
||||||
updated: "mis à jour",
|
updated: "mis à jour",
|
||||||
|
online: "en ligne",
|
||||||
|
offline: "hors ligne",
|
||||||
|
rising: "en hausse cette semaine",
|
||||||
|
over_time: "karma dans le temps",
|
||||||
|
most_debated: "les plus débattus",
|
||||||
|
milestones: "jalons",
|
||||||
|
reasons_recent: "raisons récentes",
|
||||||
|
history: "historique",
|
||||||
|
reached: "a atteint",
|
||||||
|
bumps: "changements",
|
||||||
|
filter_ph: "filtrer…",
|
||||||
|
sort_karma: "karma",
|
||||||
|
sort_name: "nom",
|
||||||
|
sort_active: "actifs",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -417,7 +495,10 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
|
||||||
.collect();
|
.collect();
|
||||||
let gifts = feed.len();
|
let gifts = feed.len();
|
||||||
|
|
||||||
let mut s = String::from(HEAD);
|
let now = updated_epoch;
|
||||||
|
let mut s = HEAD
|
||||||
|
.replace("__LANG__", locale.code())
|
||||||
|
.replace("__OG__", &og_tags(boards, &lx));
|
||||||
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
|
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
|
||||||
|
|
||||||
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>");
|
||||||
|
|
@ -443,20 +524,102 @@ pub fn render(boards: &[Board], updated_epoch: u64, locale: Locale) -> String {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
s.push_str("<section class=\"board fade\">");
|
s.push_str("<section class=\"board fade\">");
|
||||||
s.push_str(&source_line(b, &lx));
|
s.push_str(&source_line(b, now, &lx));
|
||||||
s.push_str(&podium(b));
|
s.push_str(&podium(b, &lx));
|
||||||
s.push_str(&ledger(b, &lx));
|
s.push_str(&ledger(b, &lx));
|
||||||
s.push_str("</section>");
|
s.push_str("</section>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.push_str(&rising(&feed, now, &lx));
|
||||||
|
s.push_str(&over_time(&feed, now, &lx));
|
||||||
|
|
||||||
s.push_str("<div class=\"band\">");
|
s.push_str("<div class=\"band\">");
|
||||||
s.push_str(&activity(&feed, multi, &lx));
|
s.push_str(&activity(&feed, multi, &lx));
|
||||||
s.push_str(&givers_panel(&feed, &lx));
|
s.push_str(&givers_panel(&feed, &lx));
|
||||||
s.push_str("</div>");
|
s.push_str("</div>");
|
||||||
|
|
||||||
|
s.push_str("<div class=\"band3\">");
|
||||||
|
s.push_str(&most_debated(&feed, &lx));
|
||||||
|
s.push_str(&milestones(&feed, &lx));
|
||||||
|
s.push_str(&reasons_wall(&feed, &lx));
|
||||||
|
s.push_str("</div>");
|
||||||
|
|
||||||
finish(s, updated_epoch, locale, &lx)
|
finish(s, updated_epoch, locale, &lx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A machine-readable snapshot of the board(s) — served alongside the page as
|
||||||
|
/// `karma.json` so other tools/bots can read the data.
|
||||||
|
pub fn render_json(boards: &[Board], updated_epoch: u64) -> String {
|
||||||
|
let nets: Vec<String> = boards
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
let channels: Vec<String> = b
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let mut o = format!("{{\"name\":\"{}\"", json::escape(&c.name));
|
||||||
|
if let Some(t) = &c.topic {
|
||||||
|
o.push_str(&format!(",\"topic\":\"{}\"", json::escape(t)));
|
||||||
|
}
|
||||||
|
if let Some(u) = c.users {
|
||||||
|
o.push_str(&format!(",\"users\":{u}"));
|
||||||
|
}
|
||||||
|
o.push('}');
|
||||||
|
o
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let things: Vec<String> = b
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let mut o = format!(
|
||||||
|
"{{\"thing\":\"{}\",\"karma\":{}",
|
||||||
|
json::escape(&r.thing),
|
||||||
|
r.count
|
||||||
|
);
|
||||||
|
if let Some(w) = &r.reason {
|
||||||
|
o.push_str(&format!(",\"reason\":\"{}\"", json::escape(w)));
|
||||||
|
}
|
||||||
|
if let Some(by) = &r.by {
|
||||||
|
o.push_str(&format!(",\"by\":\"{}\"", json::escape(by)));
|
||||||
|
}
|
||||||
|
o.push('}');
|
||||||
|
o
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"{{\"network\":\"{}\",\"server\":\"{}\",\"channels\":[{}],\"things\":[{}]}}",
|
||||||
|
json::escape(&b.network),
|
||||||
|
json::escape(&b.server),
|
||||||
|
channels.join(","),
|
||||||
|
things.join(",")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"{{\"generated\":{updated_epoch},\"networks\":[{}]}}\n",
|
||||||
|
nets.join(",")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn og_tags(boards: &[Board], lx: &Lex) -> String {
|
||||||
|
let things: usize = boards.iter().map(|b| b.rows.len()).sum();
|
||||||
|
let top = boards
|
||||||
|
.iter()
|
||||||
|
.flat_map(|b| &b.rows)
|
||||||
|
.max_by_key(|r| r.count)
|
||||||
|
.map(|r| format!(" · {} {}", esc(&r.thing), signed(r.count)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let desc = format!("{things} {}{top}", lx.s_tracked);
|
||||||
|
format!(
|
||||||
|
"<meta property=\"og:type\" content=\"website\">\
|
||||||
|
<meta property=\"og:title\" content=\"karma · devtronic\">\
|
||||||
|
<meta property=\"og:description\" content=\"{desc}\">\
|
||||||
|
<meta name=\"twitter:card\" content=\"summary\">\
|
||||||
|
<meta name=\"description\" content=\"{desc}\">"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
|
fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
|
||||||
s.push_str(&footer(epoch, lx));
|
s.push_str(&footer(epoch, lx));
|
||||||
s.push_str("<script>var C=");
|
s.push_str("<script>var C=");
|
||||||
|
|
@ -469,13 +632,29 @@ fn finish(mut s: String, epoch: u64, locale: Locale, lx: &Lex) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "from #argentina · irc.libera.chat" — where this board's karma comes from.
|
/// "from #argentina · irc.libera.chat" — where this board's karma comes from.
|
||||||
fn source_line(b: &Board, lx: &Lex) -> String {
|
fn source_line(b: &Board, now: u64, lx: &Lex) -> String {
|
||||||
|
let online = b.seen > 0 && now.saturating_sub(b.seen) < 300;
|
||||||
|
let dot = format!(
|
||||||
|
"<span class=\"dot {}\" title=\"{}\"></span>",
|
||||||
|
if online { "on" } else { "off" },
|
||||||
|
if online { lx.online } else { lx.offline }
|
||||||
|
);
|
||||||
let mut parts: Vec<String> = Vec::new();
|
let mut parts: Vec<String> = Vec::new();
|
||||||
if !b.channels.is_empty() {
|
if !b.channels.is_empty() {
|
||||||
let chans = b
|
let chans = b
|
||||||
.channels
|
.channels
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| esc(&c.name))
|
.map(|c| {
|
||||||
|
let users = match c.users {
|
||||||
|
Some(u) => format!(" <span class=\"users\">{u} {}</span>", lx.online),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"<a class=\"chan\" href=\"{}\" rel=\"noopener\" target=\"_blank\">{}</a>{users}",
|
||||||
|
join_url(b, &c.name),
|
||||||
|
esc(&c.name)
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
parts.push(format!("<span class=\"chans\">{chans}</span>"));
|
parts.push(format!("<span class=\"chans\">{chans}</span>"));
|
||||||
|
|
@ -487,7 +666,7 @@ fn source_line(b: &Board, lx: &Lex) -> String {
|
||||||
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
|
parts.push(format!("<span class=\"host\">{}</span>", esc(&b.network)));
|
||||||
}
|
}
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"<div class=\"source\"><span class=\"lbl\">{}</span>{}</div>",
|
"<div class=\"source\">{dot}<span class=\"lbl\">{}</span>{}</div>",
|
||||||
lx.from,
|
lx.from,
|
||||||
parts.join("<span class=\"sep\">·</span>")
|
parts.join("<span class=\"sep\">·</span>")
|
||||||
);
|
);
|
||||||
|
|
@ -495,6 +674,19 @@ fn source_line(b: &Board, lx: &Lex) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A clickable join target for a channel: the configured webchat, else an
|
||||||
|
/// `ircs://` URL. Returned already escaped for use in an `href`.
|
||||||
|
fn join_url(b: &Board, channel: &str) -> String {
|
||||||
|
let raw = if !b.webchat.is_empty() {
|
||||||
|
format!("{}{}", b.webchat, channel)
|
||||||
|
} else if !b.server.is_empty() {
|
||||||
|
format!("ircs://{}/{}", b.server, channel.replace('#', "%23"))
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
esc(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
fn topics_block(b: &Board) -> String {
|
fn topics_block(b: &Board) -> String {
|
||||||
let multi = b.channels.len() > 1;
|
let multi = b.channels.len() > 1;
|
||||||
let items: Vec<String> = b
|
let items: Vec<String> = b
|
||||||
|
|
@ -544,7 +736,52 @@ fn stat(num: &str, label: &str) -> String {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn podium(b: &Board) -> String {
|
fn hist_html(thing: &str, events: &[Event], lx: &Lex) -> String {
|
||||||
|
let key = thing.to_lowercase();
|
||||||
|
let mut evs: Vec<&Event> = events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.thing.to_lowercase() == key)
|
||||||
|
.collect();
|
||||||
|
if evs.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
evs.sort_by_key(|e| std::cmp::Reverse(e.t));
|
||||||
|
let items: String = evs
|
||||||
|
.iter()
|
||||||
|
.take(20)
|
||||||
|
.map(|e| {
|
||||||
|
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
|
||||||
|
let chip = if e.delta >= 0 { "++" } else { "--" };
|
||||||
|
let why = match &e.why {
|
||||||
|
Some(w) => format!(" <span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"<li class=\"hev\"><span class=\"chip {kl}\">{chip}</span> <b>{}</b>{why} \
|
||||||
|
<time class=\"ago\" data-t=\"{}\"></time></li>",
|
||||||
|
esc(&by),
|
||||||
|
e.t,
|
||||||
|
kl = klass(e.delta),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("<ol class=\"hist\" hidden>{items}</ol>")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(class-suffix, title-attr)` marking an element expandable when it has a
|
||||||
|
/// non-empty history block.
|
||||||
|
fn expand_attrs(hist: &str, lx: &Lex) -> (String, String) {
|
||||||
|
if hist.is_empty() {
|
||||||
|
(String::new(), String::new())
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
" expand".to_string(),
|
||||||
|
format!(" title=\"{}\"", esc(lx.history)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn podium(b: &Board, lx: &Lex) -> String {
|
||||||
let top = b.rows.iter().take(3).collect::<Vec<_>>();
|
let top = b.rows.iter().take(3).collect::<Vec<_>>();
|
||||||
let mut out = String::from("<ol class=\"podium\">");
|
let mut out = String::from("<ol class=\"podium\">");
|
||||||
for (i, r) in top.iter().enumerate() {
|
for (i, r) in top.iter().enumerate() {
|
||||||
|
|
@ -565,9 +802,11 @@ fn podium(b: &Board) -> String {
|
||||||
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
|
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
|
let hist = hist_html(&r.thing, &b.events, lx);
|
||||||
|
let (expand, title) = expand_attrs(&hist, lx);
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<li class=\"pillar r{rank}\"><div class=\"crown\">{crown}</div>{}\
|
"<li class=\"pillar r{rank}{expand}\"{title}><div class=\"crown\">{crown}</div>{}\
|
||||||
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}</li>",
|
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}{hist}</li>",
|
||||||
avatar(&r.thing, "lg"),
|
avatar(&r.thing, "lg"),
|
||||||
esc(&r.thing),
|
esc(&r.thing),
|
||||||
klass(r.count),
|
klass(r.count),
|
||||||
|
|
@ -582,8 +821,19 @@ fn ledger(b: &Board, lx: &Lex) -> String {
|
||||||
if b.rows.len() <= 3 {
|
if b.rows.len() <= 3 {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
|
let controls = format!(
|
||||||
|
"<div class=\"controls\">\
|
||||||
|
<input id=\"q\" class=\"filter\" type=\"search\" placeholder=\"{ph}\" autocomplete=\"off\" aria-label=\"{ph}\">\
|
||||||
|
<div class=\"sorts\"><button type=\"button\" data-sort=\"score\" class=\"on\">{}</button>\
|
||||||
|
<button type=\"button\" data-sort=\"name\">{}</button>\
|
||||||
|
<button type=\"button\" data-sort=\"active\">{}</button></div></div>",
|
||||||
|
lx.sort_karma,
|
||||||
|
lx.sort_name,
|
||||||
|
lx.sort_active,
|
||||||
|
ph = esc(lx.filter_ph),
|
||||||
|
);
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"<div class=\"eyebrow\">{}</div><ol class=\"list\">",
|
"<div class=\"eyebrow\">{}</div>{controls}<ol class=\"list ledger\">",
|
||||||
lx.ledger
|
lx.ledger
|
||||||
);
|
);
|
||||||
for (i, r) in b.rows.iter().enumerate().skip(3) {
|
for (i, r) in b.rows.iter().enumerate().skip(3) {
|
||||||
|
|
@ -598,14 +848,25 @@ fn ledger(b: &Board, lx: &Lex) -> String {
|
||||||
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
|
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
|
||||||
_ => String::new(),
|
_ => String::new(),
|
||||||
};
|
};
|
||||||
|
let key = r.thing.to_lowercase();
|
||||||
|
let active = b
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.thing.to_lowercase() == key)
|
||||||
|
.count();
|
||||||
|
let hist = hist_html(&r.thing, &b.events, lx);
|
||||||
|
let (expand, title) = expand_attrs(&hist, lx);
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"<li class=\"row\"><span class=\"rank\">{rank}</span>{}\
|
"<li class=\"row thing{expand}\"{title} data-name=\"{namel}\" data-score=\"{score}\" data-active=\"{active}\">\
|
||||||
<div class=\"who\"><div class=\"nick\">{}</div>{reason}</div>\
|
<span class=\"rank\">{rank}</span>{av}\
|
||||||
{spark}<span class=\"score {}\">{}</span></li>",
|
<div class=\"who\"><div class=\"nick\">{name}</div>{reason}</div>\
|
||||||
avatar(&r.thing, "sm"),
|
{spark}<span class=\"score {kl}\">{sg}</span>{hist}</li>",
|
||||||
esc(&r.thing),
|
namel = esc(&key),
|
||||||
klass(r.count),
|
score = r.count,
|
||||||
signed(r.count),
|
av = avatar(&r.thing, "sm"),
|
||||||
|
name = esc(&r.thing),
|
||||||
|
kl = klass(r.count),
|
||||||
|
sg = signed(r.count),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
out.push_str("</ol>");
|
out.push_str("</ol>");
|
||||||
|
|
@ -693,6 +954,182 @@ fn givers_panel(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rising(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
|
||||||
|
let week = now.saturating_sub(7 * 86400);
|
||||||
|
let mut m: HashMap<String, (String, i64)> = HashMap::new();
|
||||||
|
for (_, e) in feed {
|
||||||
|
if e.t >= week {
|
||||||
|
let ent = m
|
||||||
|
.entry(e.thing.to_lowercase())
|
||||||
|
.or_insert((e.thing.clone(), 0));
|
||||||
|
ent.1 += e.delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut v: Vec<(String, i64)> = m.into_values().filter(|(_, d)| *d != 0).collect();
|
||||||
|
if v.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
v.sort_by(|a, b| {
|
||||||
|
b.1.abs()
|
||||||
|
.cmp(&a.1.abs())
|
||||||
|
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
|
||||||
|
});
|
||||||
|
v.truncate(8);
|
||||||
|
let chips: String = v
|
||||||
|
.iter()
|
||||||
|
.map(|(name, d)| {
|
||||||
|
format!(
|
||||||
|
"<span class=\"tchip {}\">{} <b>{}</b></span>",
|
||||||
|
klass(*d),
|
||||||
|
esc(name),
|
||||||
|
signed(*d)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"<section class=\"strip fade\"><div class=\"eyebrow\">🔥 {}</div><div class=\"chips\">{chips}</div></section>",
|
||||||
|
lx.rising
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn over_time(feed: &[(&str, &Event)], now: u64, lx: &Lex) -> String {
|
||||||
|
const DAYS: usize = 30;
|
||||||
|
let day = 86400u64;
|
||||||
|
let start = now.saturating_sub((DAYS as u64 - 1) * day);
|
||||||
|
let mut buckets = [0u32; DAYS];
|
||||||
|
for (_, e) in feed {
|
||||||
|
if e.t >= start {
|
||||||
|
let idx = ((e.t - start) / day) as usize;
|
||||||
|
if idx < DAYS {
|
||||||
|
buckets[idx] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let max = *buckets.iter().max().unwrap_or(&0);
|
||||||
|
if max == 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let bars: String = buckets
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let h = (*c as f64 / max as f64 * 100.0).round() as u32;
|
||||||
|
format!(
|
||||||
|
"<span class=\"bar\" style=\"height:{}%\" title=\"{c}\"></span>",
|
||||||
|
h.max(3)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let total: u32 = buckets.iter().sum();
|
||||||
|
format!(
|
||||||
|
"<section class=\"chart fade\"><div class=\"eyebrow\">{} \
|
||||||
|
<span class=\"count\">{total} · {DAYS}d</span></div><div class=\"bars\">{bars}</div></section>",
|
||||||
|
lx.over_time
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn most_debated(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||||
|
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
|
||||||
|
for (_, e) in feed {
|
||||||
|
let ent = m
|
||||||
|
.entry(e.thing.to_lowercase())
|
||||||
|
.or_insert((e.thing.clone(), 0, 0));
|
||||||
|
ent.1 += 1;
|
||||||
|
ent.2 += e.delta;
|
||||||
|
}
|
||||||
|
let mut v: Vec<(String, i64, i64)> = m.into_values().collect();
|
||||||
|
if v.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
v.sort_by(|a, b| {
|
||||||
|
b.1.cmp(&a.1)
|
||||||
|
.then(a.0.to_lowercase().cmp(&b.0.to_lowercase()))
|
||||||
|
});
|
||||||
|
v.truncate(6);
|
||||||
|
let items: String = v
|
||||||
|
.iter()
|
||||||
|
.map(|(name, bumps, net)| {
|
||||||
|
format!(
|
||||||
|
"<li class=\"row\"><div class=\"who\"><div class=\"nick\">{}</div>\
|
||||||
|
<span class=\"why\">{bumps} {}</span></div><span class=\"score {}\">{}</span></li>",
|
||||||
|
esc(name),
|
||||||
|
lx.bumps,
|
||||||
|
klass(*net),
|
||||||
|
signed(*net)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
|
||||||
|
lx.most_debated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn milestones(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||||
|
const LEVELS: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
||||||
|
let mut evs: Vec<&(&str, &Event)> = feed.iter().collect();
|
||||||
|
evs.sort_by_key(|(_, e)| e.t);
|
||||||
|
let mut cum: HashMap<String, i64> = HashMap::new();
|
||||||
|
let mut hits: Vec<(String, i64, u64)> = Vec::new();
|
||||||
|
for (_, e) in evs {
|
||||||
|
let c = cum.entry(e.thing.to_lowercase()).or_insert(0);
|
||||||
|
let before = *c;
|
||||||
|
*c += e.delta;
|
||||||
|
let after = *c;
|
||||||
|
for &lv in LEVELS {
|
||||||
|
if before < lv && after >= lv {
|
||||||
|
hits.push((e.thing.clone(), lv, e.t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hits.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
hits.sort_by_key(|(_, _, t)| std::cmp::Reverse(*t));
|
||||||
|
hits.truncate(8);
|
||||||
|
let items: String = hits
|
||||||
|
.iter()
|
||||||
|
.map(|(name, lv, t)| {
|
||||||
|
format!(
|
||||||
|
"<li class=\"hev\">🎉 <b>{}</b> {} <b>{lv}</b> \
|
||||||
|
<time class=\"ago\" data-t=\"{t}\"></time></li>",
|
||||||
|
esc(name),
|
||||||
|
lx.reached
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!(
|
||||||
|
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ol class=\"list\">{items}</ol></section>",
|
||||||
|
lx.milestones
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reasons_wall(feed: &[(&str, &Event)], lx: &Lex) -> String {
|
||||||
|
let items: String = feed
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, e)| e.why.is_some())
|
||||||
|
.take(6)
|
||||||
|
.map(|(_, e)| {
|
||||||
|
let by = e.by.clone().unwrap_or_else(|| lx.someone.to_string());
|
||||||
|
format!(
|
||||||
|
"<li class=\"quote\"><div class=\"q\">\u{201C}{}\u{201D}</div>\
|
||||||
|
<div class=\"qm\"><b>{}</b> \u{2192} <b>{}</b> \
|
||||||
|
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
|
||||||
|
esc(e.why.as_deref().unwrap_or("")),
|
||||||
|
esc(&by),
|
||||||
|
esc(&e.thing),
|
||||||
|
e.t
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if items.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"<section class=\"panel fade\"><div class=\"eyebrow\">{}</div><ul class=\"quotes\">{items}</ul></section>",
|
||||||
|
lx.reasons_recent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn footer(epoch: u64, lx: &Lex) -> String {
|
fn footer(epoch: u64, lx: &Lex) -> String {
|
||||||
format!(
|
format!(
|
||||||
"<footer class=\"fade\">{} · {} \
|
"<footer class=\"fade\">{} · {} \
|
||||||
|
|
@ -732,11 +1169,12 @@ fn time_cfg(l: Locale) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEAD: &str = r##"<!doctype html>
|
const HEAD: &str = r##"<!doctype html>
|
||||||
<html lang="en">
|
<html lang="__LANG__">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>karma · devtronic</title>
|
<title>karma · devtronic</title>
|
||||||
|
__OG__
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
|
|
@ -844,6 +1282,41 @@ 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}
|
||||||
|
.dot{width:9px;height:9px;border-radius:50%;flex:none}
|
||||||
|
.dot.on{background:var(--up);box-shadow:0 0 0 3px rgba(95,211,159,.16)}
|
||||||
|
.dot.off{background:var(--faint)}
|
||||||
|
.source .chan{font-family:var(--mono);color:var(--gold);font-weight:600}
|
||||||
|
.source .chan:hover{text-decoration:underline}
|
||||||
|
.source .users{font-family:var(--mono);color:var(--faint);font-size:11px}
|
||||||
|
.strip{margin:30px 2px 6px}
|
||||||
|
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
||||||
|
.tchip{font-family:var(--mono);font-size:13px;background:var(--panel);border:1px solid var(--line);border-radius:999px;padding:5px 12px}
|
||||||
|
.tchip.up{color:var(--up)}.tchip.down{color:var(--down)}
|
||||||
|
.tchip b{font-weight:700}
|
||||||
|
.chart{margin:24px 2px 6px}
|
||||||
|
.chart .eyebrow{margin:0 0 10px}
|
||||||
|
.chart .count{margin-left:auto;color:var(--faint);font-family:var(--mono);font-size:11px}
|
||||||
|
.bars{display:flex;align-items:flex-end;gap:3px;height:64px;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px}
|
||||||
|
.bar{flex:1;background:linear-gradient(180deg,var(--gold),rgba(245,182,66,.3));border-radius:2px;min-height:2px}
|
||||||
|
.band3{display:grid;grid-template-columns:1fr;gap:16px;margin-top:16px}
|
||||||
|
@media(min-width:720px){.band3{grid-template-columns:1fr 1fr 1fr}}
|
||||||
|
.quotes{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
|
||||||
|
.quote .q{color:var(--fg);font-size:13.5px;line-height:1.5}
|
||||||
|
.quote .qm{color:var(--muted);font-size:12px;margin-top:2px}
|
||||||
|
.quote .qm b{font-family:var(--mono);font-weight:600}
|
||||||
|
.controls{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 2px 12px}
|
||||||
|
.filter{flex:1;min-width:150px;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font-size:14px}
|
||||||
|
.filter:focus{outline:none;border-color:var(--gold)}
|
||||||
|
.sorts{display:flex;gap:6px}
|
||||||
|
.sorts button{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:7px 11px;cursor:pointer}
|
||||||
|
.sorts button.on{color:var(--bg);background:var(--gold);border-color:var(--gold);font-weight:600}
|
||||||
|
.row.thing,.pillar.expand{cursor:pointer}
|
||||||
|
.row.thing{flex-wrap:wrap}
|
||||||
|
.hist{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px;flex-basis:100%;width:100%}
|
||||||
|
.pillar .hist{text-align:left}
|
||||||
|
.hev{font-size:12.5px;color:var(--muted);word-break:break-word}
|
||||||
|
.hev b{font-family:var(--mono);color:var(--fg);font-weight:600}
|
||||||
|
.hev .chip{font-size:11px;padding:0 5px}
|
||||||
.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){
|
||||||
|
|
@ -878,6 +1351,38 @@ 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);
|
||||||
});
|
});
|
||||||
|
// click a row/pillar to toggle its history
|
||||||
|
document.querySelectorAll(".row.thing,.pillar.expand").forEach(function(el){
|
||||||
|
el.addEventListener("click",function(e){
|
||||||
|
if(e.target.closest("a"))return;
|
||||||
|
var h=el.querySelector(".hist");if(h)h.hidden=!h.hidden;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// filter the ledger
|
||||||
|
var q=document.getElementById("q");
|
||||||
|
if(q){q.addEventListener("input",function(){
|
||||||
|
var t=q.value.toLowerCase();
|
||||||
|
document.querySelectorAll("ol.ledger>li.row").forEach(function(li){
|
||||||
|
li.style.display=li.textContent.toLowerCase().indexOf(t)>=0?"":"none";
|
||||||
|
});
|
||||||
|
});}
|
||||||
|
// sort the ledger
|
||||||
|
document.querySelectorAll(".sorts button").forEach(function(btn){
|
||||||
|
btn.addEventListener("click",function(){
|
||||||
|
var key=btn.getAttribute("data-sort");
|
||||||
|
var list=document.querySelector("ol.ledger");if(!list)return;
|
||||||
|
document.querySelectorAll(".sorts button").forEach(function(b){b.classList.remove("on");});
|
||||||
|
btn.classList.add("on");
|
||||||
|
var rows=Array.prototype.slice.call(list.querySelectorAll("li.row"));
|
||||||
|
rows.sort(function(a,b){
|
||||||
|
if(key=="name")return a.getAttribute("data-name").localeCompare(b.getAttribute("data-name"));
|
||||||
|
var an=parseInt(a.getAttribute(key=="active"?"data-active":"data-score"),10);
|
||||||
|
var bn=parseInt(b.getAttribute(key=="active"?"data-active":"data-score"),10);
|
||||||
|
return bn-an;
|
||||||
|
});
|
||||||
|
rows.forEach(function(r){list.appendChild(r);});
|
||||||
|
});
|
||||||
|
});
|
||||||
setTimeout(function(){location.reload();},60000);
|
setTimeout(function(){location.reload();},60000);
|
||||||
})();"##;
|
})();"##;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use rustbot::i18n::Locale;
|
use rustbot::i18n::Locale;
|
||||||
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Channel};
|
use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, render_json, Channel};
|
||||||
|
|
||||||
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 {
|
||||||
let mut b = parse_board(net, store);
|
let mut b = parse_board(net, store);
|
||||||
|
|
@ -102,51 +102,64 @@ fn renders_spanish_chrome() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_meta_channels_with_and_without_topic() {
|
fn parses_meta_channels_topic_and_users() {
|
||||||
// new format: objects with an optional topic
|
let m = parse_meta(
|
||||||
let (server, channels) = parse_meta(
|
r##"{"server":"irc.libera.chat","webchat":"https://web.libera.chat/","seen":123,"channels":[{"name":"#argentina","topic":"Bienvenidos","users":42},{"name":"#ar"}]}"##,
|
||||||
r##"{"server":"irc.libera.chat","channels":[{"name":"#argentina","topic":"Bienvenidos"},{"name":"#ar"}]}"##,
|
|
||||||
);
|
);
|
||||||
assert_eq!(server, "irc.libera.chat");
|
assert_eq!(m.server, "irc.libera.chat");
|
||||||
assert_eq!(channels.len(), 2);
|
assert_eq!(m.webchat, "https://web.libera.chat/");
|
||||||
assert_eq!(channels[0].name, "#argentina");
|
assert_eq!(m.seen, 123);
|
||||||
assert_eq!(channels[0].topic.as_deref(), Some("Bienvenidos"));
|
assert_eq!(m.channels.len(), 2);
|
||||||
assert!(channels[1].topic.is_none());
|
assert_eq!(m.channels[0].name, "#argentina");
|
||||||
// old format (bare strings) still parses
|
assert_eq!(m.channels[0].topic.as_deref(), Some("Bienvenidos"));
|
||||||
let (_, old) = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
|
assert_eq!(m.channels[0].users, Some(42));
|
||||||
assert_eq!(old[0].name, "#c");
|
assert!(m.channels[1].topic.is_none() && m.channels[1].users.is_none());
|
||||||
assert!(old[0].topic.is_none());
|
// old bare-string format still parses
|
||||||
|
let old = parse_meta(r##"{"server":"x","channels":["#c"]}"##);
|
||||||
|
assert_eq!(old.channels[0].name, "#c");
|
||||||
// garbage degrades to empty
|
// garbage degrades to empty
|
||||||
let (s, c) = parse_meta("not json");
|
let g = parse_meta("not json");
|
||||||
assert!(s.is_empty() && c.is_empty());
|
assert!(g.server.is_empty() && g.channels.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn renders_source_channel_server_and_topic() {
|
fn renders_source_topic_users_join_and_online() {
|
||||||
let mut b = parse_board("libera", r#"{"romaka":2}"#);
|
let mut b = parse_board("libera", r#"{"romaka":2}"#);
|
||||||
b.server = "irc.libera.chat".to_string();
|
b.server = "irc.libera.chat".to_string();
|
||||||
|
b.webchat = "https://web.libera.chat/".to_string();
|
||||||
|
b.seen = 1000;
|
||||||
b.channels = vec![Channel {
|
b.channels = vec![Channel {
|
||||||
name: "#argentina".to_string(),
|
name: "#argentina".to_string(),
|
||||||
topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()),
|
topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()),
|
||||||
|
users: Some(42),
|
||||||
}];
|
}];
|
||||||
let en = render(&[b], 0, Locale::En);
|
let en = render(&[b], 1100, Locale::En); // now close to seen -> online
|
||||||
assert!(en.contains("#argentina"), "channel missing");
|
assert!(en.contains("#argentina"), "channel missing");
|
||||||
assert!(en.contains("irc.libera.chat"), "server missing");
|
assert!(en.contains("irc.libera.chat"), "server missing");
|
||||||
assert!(en.contains(">from<"), "from label missing");
|
assert!(en.contains("Argentina en Libera"), "topic missing");
|
||||||
// the topic text shows, and its URL becomes a safe link
|
|
||||||
assert!(en.contains("Argentina en Libera"), "topic text missing");
|
|
||||||
assert!(
|
assert!(
|
||||||
en.contains("<a href=\"https://qadlo.example/\""),
|
en.contains("<a href=\"https://qadlo.example/\""),
|
||||||
"topic url not linkified: {en}"
|
"topic url not linkified"
|
||||||
);
|
);
|
||||||
// spanish label still works with no topic
|
assert!(en.contains("42 online"), "user count missing");
|
||||||
let mut b2 = parse_board("libera", r#"{"romaka":2}"#);
|
assert!(
|
||||||
b2.channels = vec![Channel {
|
en.contains("https://web.libera.chat/#argentina"),
|
||||||
name: "#argentina".to_string(),
|
"join link missing"
|
||||||
topic: None,
|
);
|
||||||
}];
|
assert!(en.contains("class=\"dot on\""), "online dot missing");
|
||||||
let es = render(&[b2], 0, Locale::Es);
|
// og description present
|
||||||
assert!(es.contains(">desde<") && es.contains("#argentina"), "{es}");
|
assert!(
|
||||||
|
en.contains("property=\"og:description\""),
|
||||||
|
"og tags missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn online_dot_goes_offline_when_stale() {
|
||||||
|
let mut b = parse_board("t", r#"{"x":1}"#);
|
||||||
|
b.seen = 1000;
|
||||||
|
let html = render(&[b], 9_999_999, Locale::En);
|
||||||
|
assert!(html.contains("class=\"dot off\""), "should be offline");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -155,6 +168,7 @@ fn topic_cannot_inject_markup() {
|
||||||
b.channels = vec![Channel {
|
b.channels = vec![Channel {
|
||||||
name: "#c".to_string(),
|
name: "#c".to_string(),
|
||||||
topic: Some("<script>evil</script> javascript:alert(1)".to_string()),
|
topic: Some("<script>evil</script> javascript:alert(1)".to_string()),
|
||||||
|
users: None,
|
||||||
}];
|
}];
|
||||||
let html = render(&[b], 0, Locale::En);
|
let html = render(&[b], 0, Locale::En);
|
||||||
assert!(!html.contains("<script>evil"), "topic markup leaked");
|
assert!(!html.contains("<script>evil"), "topic markup leaked");
|
||||||
|
|
@ -162,6 +176,51 @@ fn topic_cannot_inject_markup() {
|
||||||
assert!(html.contains("<script>evil"), "topic not escaped");
|
assert!(html.contains("<script>evil"), "topic not escaped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_analytics_sections() {
|
||||||
|
let now = 2_000_000u64;
|
||||||
|
let mut log = String::new();
|
||||||
|
for i in 0..12u64 {
|
||||||
|
let t = now - (12 - i) * 3600;
|
||||||
|
let why = if i == 0 {
|
||||||
|
",\"why\":\"clean code\""
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
log.push_str(&format!(
|
||||||
|
"{{\"t\":{t},\"o\":\"rust\",\"d\":1,\"by\":\"nadia\"{why}}}\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
log.push_str(&format!(
|
||||||
|
"{{\"t\":{},\"o\":\"php\",\"d\":-1,\"by\":\"sam\",\"why\":\"footgun\"}}\n",
|
||||||
|
now - 1000
|
||||||
|
));
|
||||||
|
let b = board_with_events("libera", r#"{"rust":12,"php":-1}"#, &log);
|
||||||
|
let html = render(&[b], now, Locale::En);
|
||||||
|
assert!(html.contains("rising this week"), "no rising strip");
|
||||||
|
assert!(html.contains("karma over time"), "no chart");
|
||||||
|
assert!(html.contains("most debated"), "no most-debated");
|
||||||
|
assert!(
|
||||||
|
html.contains("milestones") && html.contains("reached"),
|
||||||
|
"no milestones"
|
||||||
|
);
|
||||||
|
assert!(html.contains("recent reasons"), "no reasons wall");
|
||||||
|
assert!(
|
||||||
|
html.contains("clean code") && html.contains("footgun"),
|
||||||
|
"reasons missing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_json_is_machine_readable() {
|
||||||
|
let b = parse_board("libera", r#"{"rust":{"n":5,"why":"clean","by":"a"}}"#);
|
||||||
|
let j = render_json(&[b], 42);
|
||||||
|
assert!(j.contains("\"generated\":42"), "{j}");
|
||||||
|
assert!(j.contains("\"network\":\"libera\""), "{j}");
|
||||||
|
assert!(j.contains("\"thing\":\"rust\",\"karma\":5"), "{j}");
|
||||||
|
assert!(j.contains("\"reason\":\"clean\""), "{j}");
|
||||||
|
}
|
||||||
|
|
||||||
#[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";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue