karma-web: static HTML karma board served at karma.devtronic.pro
All checks were successful
ci / check (push) Successful in 43s
All checks were successful
ci / check (push) Successful in 43s
This commit is contained in:
parent
e801b04d6e
commit
9a3393153a
6 changed files with 339 additions and 1 deletions
48
src/bin/karma-web.rs
Normal file
48
src/bin/karma-web.rs
Normal 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
210
src/karma_web.rs
Normal 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("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\'' => o.push_str("'"),
|
||||
_ => 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 · <a href="https://git.devtronic.pro/reverse/rubot">rubot</a> · 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>
|
||||
"##;
|
||||
|
|
@ -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}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue