karma-web: static HTML karma board served at karma.devtronic.pro
All checks were successful
ci / check (push) Successful in 43s

This commit is contained in:
Jean Chevronnet 2026-07-29 23:27:39 +00:00
parent e801b04d6e
commit 9a3393153a
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
6 changed files with 339 additions and 1 deletions

48
src/bin/karma-web.rs Normal file
View file

@ -0,0 +1,48 @@
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use rustbot::karma_web::{parse_board, render, Board};
fn main() {
let mut args = std::env::args().skip(1);
let dir = args
.next()
.or_else(|| std::env::var("RUSTBOT_DATA_DIR").ok())
.unwrap_or_else(|| ".".to_string());
let out = args.next();
let mut boards: Vec<Board> = Vec::new();
if let Ok(rd) = fs::read_dir(&dir) {
let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
paths.sort();
for p in paths {
let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
if let Some(net) = name
.strip_prefix("karma.")
.and_then(|s| s.strip_suffix(".json"))
{
if let Ok(body) = fs::read_to_string(&p) {
boards.push(parse_board(net, &body));
}
}
}
}
let epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let html = render(&boards, epoch);
match out {
Some(path) => {
let tmp = format!("{path}.tmp");
if let Err(e) = fs::write(&tmp, &html).and_then(|_| fs::rename(&tmp, &path)) {
eprintln!("karma-web: could not write {path}: {e}");
std::process::exit(1);
}
}
None => print!("{html}"),
}
}

210
src/karma_web.rs Normal file
View file

@ -0,0 +1,210 @@
use crate::json::Json;
pub struct Row {
pub thing: String,
pub count: i64,
pub reason: Option<String>,
pub by: Option<String>,
}
pub struct Board {
pub network: String,
pub rows: Vec<Row>,
}
pub fn parse_board(network: &str, body: &str) -> Board {
let mut rows = Vec::new();
if let Some(Json::Obj(entries)) = Json::parse(body) {
for (k, v) in entries {
let (count, reason, by) = fields(&v);
rows.push(Row {
thing: k,
count,
reason,
by,
});
}
}
rows.sort_by(|a, b| {
b.count
.cmp(&a.count)
.then_with(|| a.thing.to_lowercase().cmp(&b.thing.to_lowercase()))
});
Board {
network: network.to_string(),
rows,
}
}
fn fields(v: &Json) -> (i64, Option<String>, Option<String>) {
if let Some(n) = v.as_f64() {
return (n as i64, None, None);
}
let count = v.get("n").and_then(Json::as_f64).unwrap_or(0.0) as i64;
let reason = v
.get("why")
.and_then(Json::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let by = v
.get("by")
.and_then(Json::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
(count, reason, by)
}
fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&#39;"),
_ => o.push(c),
}
}
o
}
pub fn render(boards: &[Board], updated_epoch: u64) -> String {
let total: usize = boards.iter().map(|b| b.rows.len()).sum();
let mut s = String::from(HEAD);
if total == 0 {
s.push_str(EMPTY);
} else {
s.push_str(SEARCH);
for b in boards {
if b.rows.is_empty() {
continue;
}
s.push_str(&format!(
"<section class=\"netsec\"><div class=\"net\"><h2>{}</h2>\
<span class=\"count\">{} {}</span></div><ol class=\"board\">",
esc(&b.network),
b.rows.len(),
if b.rows.len() == 1 {
"entry"
} else {
"entries"
}
));
for (i, r) in b.rows.iter().enumerate() {
s.push_str(&row(i + 1, r));
}
s.push_str("</ol></section>");
}
}
s.push_str(&SCRIPT.replace("__EPOCH__", &updated_epoch.to_string()));
s
}
fn row(rank: usize, r: &Row) -> String {
let medal = match rank {
1 => "🥇".to_string(),
2 => "🥈".to_string(),
3 => "🥉".to_string(),
n => n.to_string(),
};
let top = if rank <= 3 { " top" } else { "" };
let (klass, sign) = match r.count {
n if n > 0 => ("pos", "+"),
n if n < 0 => ("neg", ""),
_ => ("zero", ""),
};
let mut who = format!("<div class=\"name\">{}</div>", esc(&r.thing));
if let Some(reason) = &r.reason {
match &r.by {
Some(by) => who.push_str(&format!(
"<div class=\"reason\">\u{201C}{}\u{201D} <span class=\"by\">\u{2014} {}</span></div>",
esc(reason),
esc(by)
)),
None => who.push_str(&format!(
"<div class=\"reason\">\u{201C}{}\u{201D}</div>",
esc(reason)
)),
}
}
format!(
"<li class=\"row\"><span class=\"rank{top}\">{medal}</span>\
<div class=\"who\">{who}</div>\
<span class=\"score {klass}\">{sign}{}</span></li>",
r.count
)
}
const HEAD: &str = r##"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>karma devtronic</title>
<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>
:root{--bg:#0f1216;--card:#171b21;--card2:#1b2028;--line:#252b34;--fg:#e6e9ee;--muted:#8b95a3;--pos:#4ade80;--neg:#f87171;--zero:#8b95a3;--accent:#7c9cff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
.wrap{max-width:720px;margin:0 auto;padding:32px 18px 64px}
header{text-align:center;margin-bottom:24px}
h1{margin:0;font-size:34px;letter-spacing:-.02em}
.sub{margin:6px 0 0;color:var(--muted);font-size:14px}
.search{width:100%;margin:0 0 22px;padding:11px 14px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--fg);font-size:15px}
.search:focus{outline:none;border-color:var(--accent)}
.netsec{margin-top:26px}
.net{display:flex;align-items:baseline;gap:10px;margin:0 2px 10px}
.net h2{margin:0;font-size:15px;font-weight:600;text-transform:lowercase}
.net .count{margin-left:auto;color:var(--muted);font-size:12px}
ol.board{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
li.row{display:flex;align-items:center;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
li.row:hover{background:var(--card2)}
.rank{flex:none;width:30px;text-align:center;color:var(--muted);font-variant-numeric:tabular-nums}
.rank.top{font-size:18px}
.who{flex:1;min-width:0}
.name{font-weight:600;word-break:break-word}
.reason{color:var(--muted);font-size:13px;margin-top:2px;word-break:break-word}
.reason .by{color:var(--fg);opacity:.8}
.score{flex:none;font-variant-numeric:tabular-nums;font-weight:700;font-size:17px;min-width:46px;text-align:right}
.score.pos{color:var(--pos)}.score.neg{color:var(--neg)}.score.zero{color:var(--zero)}
.empty{text-align:center;color:var(--muted);padding:48px 6px}
.empty code,footer code{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;color:var(--fg)}
footer{text-align:center;color:var(--muted);font-size:12px;margin-top:44px}
</style>
</head>
<body>
<main class="wrap">
<header>
<h1> karma</h1>
<p class="sub">community karma, tracked by <a href="https://git.devtronic.pro/reverse/rubot">rubot</a></p>
</header>
"##;
const SEARCH: &str = r##"<input id="q" class="search" type="search" placeholder="filter…" autocomplete="off" aria-label="filter">"##;
const EMPTY: &str = r##"<div class="empty">no karma yet — say <code>nick++</code> in the channel to give some.</div>"##;
const SCRIPT: &str = r##"<footer>say <code>nick++</code> in the channel to give karma &middot; <a href="https://git.devtronic.pro/reverse/rubot">rubot</a> &middot; updated <span id="up"></span></footer>
</main>
<script>
(function(){
var q=document.getElementById('q');
if(q){q.addEventListener('input',function(){
var t=q.value.toLowerCase();
document.querySelectorAll('section.netsec').forEach(function(sec){
var any=false;
sec.querySelectorAll('li.row').forEach(function(li){
var show=li.textContent.toLowerCase().indexOf(t)>=0;
li.style.display=show?'':'none';if(show)any=true;});
sec.style.display=any?'':'none';});
});}
var up=document.getElementById('up');
if(up){try{up.textContent=new Date(__EPOCH__*1000).toLocaleString();}catch(e){}}
setTimeout(function(){if(!q||!q.value)location.reload();},60000);
})();
</script>
</body>
</html>
"##;

View file

@ -5,6 +5,7 @@ pub mod config;
pub mod http;
pub mod irc;
pub mod json;
pub mod karma_web;
pub fn log(msg: &str) {
eprintln!("[rustbot] {msg}");