//! open graph cards for a knot's pages: a repo's, its tickets', the commits svfe renders and
//! its owners'. each is drawn once for what it shows and kept by that in the output root. a
//! repo's cards are linked into a set named by what they're drawn from, a generation links each
//! repo's set under `www/og`, and nginx serves the repo's card, then the knot's, for a page
//! without its own
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use anyhow::Context as _;
use knot_types::Oid;
use rayon::prelude::*;
use serde::Serialize;
use crate::cas::{atomic_write, link_or_reflink};
pub(crate) mod canvas;
mod draw;
mod plan;
#[cfg(test)]
mod tests;
use canvas::{Rgb, mix};
pub(crate) use plan::{GitRepo, SocialPull, SocialRepo, SocialTicket, SocialUser, plan};
/// the drawn cards, by the hash of what they show
pub(crate) const CARDS_DIR: &str = "og-cards";
/// the repos' sets, by the hash of what their cards are drawn from
const SETS_DIR: &str = "sets";
/// a set's repo card
const REPO_CARD: &str = "repo.png";
/// how long a set or card nothing names is kept, for a generation still being built
const GRACE: Duration = Duration::from_secs(3600);
/// where a generation serves them
pub(crate) const WWW_DIR: &str = "og";
/// the commit pages svfe-pages keeps of a repo, newest first in the log's order
pub(crate) const COMMITS: usize = 60;
/// bump when a card's look changes, so every card is drawn again
const VERSION: u32 = 2;
/// the cards' palette: an unfurl can't follow its reader's theme, so the knot picks one
const DARK: bool = true;
#[derive(Serialize, Clone)]
pub(crate) struct Ribbon {
pub(crate) host: String,
/// the owner, or `owner / repo`
pub(crate) path: String,
}
#[derive(Serialize, Clone)]
pub(crate) struct Language {
pub(crate) name: String,
pub(crate) size: u64,
}
#[derive(Serialize, Clone)]
pub(crate) struct Diff {
pub(crate) insertions: u64,
pub(crate) deletions: u64,
pub(crate) files: u64,
}
#[derive(Serialize)]
pub(crate) enum Card {
Repo {
ribbon: Ribbon,
name: String,
description: String,
topics: Vec<String>,
open_tickets: usize,
open_pulls: usize,
branch: String,
languages: Vec<Language>,
},
Ticket {
ribbon: Ribbon,
number: String,
title: String,
state: String,
author: String,
date: String,
body: String,
},
Pull {
ribbon: Ribbon,
number: String,
title: String,
state: String,
author: String,
/// the author's colour, svfe's `authorHue`
hue: usize,
/// what places the punch holes' notches
seed: String,
date: String,
from: String,
into: String,
body: String,
diff: Option<Diff>,
},
Commit {
ribbon: Ribbon,
subject: String,
short: String,
author: String,
date: String,
branch: String,
body: String,
diff: Option<Diff>,
},
User {
host: String,
handle: String,
about: String,
repos: Vec<String>,
repo_count: usize,
/// the avatar's pixels at the tile's size, keyed by its cid
#[serde(skip)]
avatar: Option<Vec<u8>>,
},
}
/// what a card shows that's read only when it has to be drawn
pub(crate) enum Lazy {
Ready(Card),
/// the repo's languages, as its git reads answer them for `commit`
Languages {
card: Card,
served: crate::store::RepoStore,
seal: String,
commit: String,
},
Commit {
export: PathBuf,
oid: Oid,
ribbon: Ribbon,
branch: String,
},
PullDiff {
card: Card,
export: PathBuf,
base: Oid,
head: Oid,
},
Avatar {
card: Card,
file: Option<PathBuf>,
},
}
/// the cards a generation serves: each repo's set, and its owners'
pub(crate) struct Plan {
pub(crate) sets: Vec<Set>,
pub(crate) users: Vec<Planned>,
}
/// a repo's cards, under a name that settles every byte of them, so a set is never written
/// again: other inputs make another set
pub(crate) struct Set {
pub(crate) did: String,
pub(crate) name: String,
/// what to draw, at paths in the set. none when the set is there already
pub(crate) cards: Vec<Planned>,
}
/// a card at `path` in its set, or an owner's at `www/og/<path>`
pub(crate) struct Planned {
pub(crate) path: String,
pub(crate) key: String,
pub(crate) card: Lazy,
}
/// what keys a card: everything it shows, or what that is read from
pub(crate) fn key(shown: &impl Serialize) -> anyhow::Result<String> {
Ok(crate::atproto::short_hash(&serde_json::to_vec(&(
VERSION,
DARK,
canvas::fonts_digest(),
shown,
))?))
}
pub(crate) struct Theme {
pub(crate) bg: Rgb,
pub(crate) card: Rgb,
pub(crate) text: Rgb,
pub(crate) muted: Rgb,
pub(crate) accent: Rgb,
pub(crate) soft: Rgb,
pub(crate) border: Rgb,
pub(crate) teal: Rgb,
pub(crate) lav: Rgb,
pub(crate) add: Rgb,
pub(crate) del: Rgb,
pub(crate) lane: Rgb,
pub(crate) patch: Rgb,
pub(crate) pedge: Rgb,
pub(crate) rim: Rgb,
/// svfe's author colours: accent, lav, teal, amber, sky
pub(crate) hues: [Rgb; 5],
/// a language bar's colours, biggest first
pub(crate) languages: [Rgb; 5],
}
/// svfe.css's palettes, with its color-mix shades worked out
pub(crate) fn theme() -> Theme {
let rgb = |hex: u32| [(hex >> 16) as u8, (hex >> 8) as u8, hex as u8];
match DARK {
false => {
let (bg, card, text, muted, accent) = (
rgb(0xfdf7fa),
rgb(0xffffff),
rgb(0x3b2f36),
rgb(0x9a8691),
rgb(0xd9628f),
);
let (soft, lav, teal, amber, sky) = (
rgb(0xfbe3ee),
rgb(0x8a6fd0),
rgb(0x3f9c8c),
rgb(0xc08a2c),
rgb(0x4f8fc4),
);
let patch = mix(accent, card, 0.07);
Theme {
bg,
card,
text,
muted,
accent,
soft,
border: rgb(0xf1d5e2),
teal,
lav,
add: rgb(0x3f8f5f),
del: rgb(0xc4475f),
lane: mix(soft, bg, 0.45),
patch,
pedge: mix(accent, card, 0.38),
rim: mix(accent, patch, 0.42),
hues: [accent, lav, teal, amber, sky],
languages: [accent, sky, lav, teal, muted],
}
}
true => {
let (bg, card, text, muted, accent) = (
rgb(0x1b1519),
rgb(0x241c21),
rgb(0xf1e4ea),
rgb(0xa8919d),
rgb(0xf28bb5),
);
let (soft, lav, teal, amber, sky) = (
rgb(0x3a2531),
rgb(0xb6a4f0),
rgb(0x5fbfae),
rgb(0xe3b45f),
rgb(0x7fb3e0),
);
let patch = mix(accent, mix(text, card, 0.07), 0.09);
Theme {
bg,
card,
text,
muted,
accent,
soft,
border: rgb(0x3d2c36),
teal,
lav,
add: rgb(0x8fd5a6),
del: rgb(0xf29aa9),
lane: card,
patch,
pedge: mix(text, patch, 0.16),
rim: mix(text, patch, 0.24),
hues: [accent, lav, teal, amber, sky],
languages: [accent, sky, lav, teal, muted],
}
}
}
}
impl Lazy {
fn resolve(self) -> anyhow::Result<Card> {
match self {
Self::Ready(card) => Ok(card),
Self::Languages {
mut card,
served,
seal,
commit,
} => {
if let Card::Repo { languages, .. } = &mut card {
*languages = crate::git_xrpc::sealed_languages(&served, &seal, &commit)
.unwrap_or_default()
.into_iter()
.map(|(name, size)| Language { name, size })
.collect();
}
Ok(card)
}
Self::Commit {
export,
oid,
ribbon,
branch,
} => {
let repo = knot_git::Repo::open(&export)?;
let commit = repo.find_commit(oid)?;
let (subject, body) = split_message(&commit.message);
Ok(Card::Commit {
ribbon,
subject,
short: oid.to_hex()[..7].to_string(),
author: commit.author.name.as_str().to_string(),
date: date_of(commit.author.time.get(), commit.author.offset_seconds),
branch,
body,
diff: diff(&repo, commit.parents.first().copied(), oid),
})
}
Self::PullDiff {
mut card,
export,
base,
head,
} => {
if let Card::Pull { diff: shown, .. } = &mut card {
*shown = knot_git::Repo::open(&export)
.ok()
.and_then(|repo| diff(&repo, Some(base), head));
}
Ok(card)
}
Self::Avatar { mut card, file } => {
if let Card::User { avatar, .. } = &mut card {
*avatar = file.and_then(|file| tile(&file).ok());
}
Ok(card)
}
}
}
}
/// the pixels of the user card's avatar tile
const TILE: u32 = 150;
fn tile(file: &Path) -> anyhow::Result<Vec<u8>> {
let image = image::ImageReader::open(file)?
.with_guessed_format()?
.decode()?;
let image = image.resize_to_fill(TILE, TILE, image::imageops::FilterType::Triangle);
Ok(image.to_rgb8().into_raw())
}
fn diff(repo: &knot_git::Repo, base: Option<Oid>, head: Oid) -> Option<Diff> {
let patches = repo
.commit_patches(
knot_git::PatchRange { base, head },
&mut knot_git::BinaryBudget::Omit,
)
.ok()?;
let (insertions, deletions, files) = crate::git_xrpc::diff_stat(&patches);
Some(Diff {
insertions: insertions.max(0) as u64,
deletions: deletions.max(0) as u64,
files: files.max(0) as u64,
})
}
/// a message's first line, and its next lines as one
pub(crate) fn split_message(message: &str) -> (String, String) {
let mut lines = message.trim().lines();
let subject = lines.next().unwrap_or_default().trim().to_string();
(subject, prose(&lines.collect::<Vec<_>>().join("\n")))
}
/// markdown read as one line of words: no fences, quote marks, list bullets or emphasis
pub(crate) fn prose(text: &str) -> String {
let mut fenced = false;
let mut words = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.starts_with("```") {
fenced = !fenced;
continue;
}
if fenced {
continue;
}
let line = line.trim_start_matches(['#', '>', '-', '*', '+', ' ']);
words.extend(
line.split_whitespace()
.map(|word| word.trim_matches(['*', '_', '`'])),
);
}
words.retain(|word| !word.is_empty());
words.join(" ")
}
/// the day in the author's own offset
pub(crate) fn date_of(seconds: i64, offset: i32) -> String {
let offset = chrono::FixedOffset::east_opt(offset)
.unwrap_or(chrono::FixedOffset::east_opt(0).expect("zero offset"));
chrono::DateTime::from_timestamp(seconds, 0)
.map(|at| at.with_timezone(&offset).format("%Y-%m-%d").to_string())
.unwrap_or_default()
}
/// the date of an rfc3339 timestamp as written
pub(crate) fn day(at: &str) -> String {
chrono::DateTime::parse_from_rfc3339(at)
.map(|at| at.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}
/// draws what's missing, links each repo's set and the owners' cards into `build_dir`, and
/// sweeps what neither this generation nor a kept one names
pub(crate) fn emit(output_root: &Path, build_dir: &Path, plan: Plan) -> anyhow::Result<()> {
let cards = output_root.join(CARDS_DIR);
let sets = cards.join(SETS_DIR);
std::fs::create_dir_all(&sets).with_context(|| format!("create {}", sets.display()))?;
// a generation's links outlive the path it was built at
let sets = std::fs::canonicalize(&sets)?;
let www = build_dir.join("www").join(WWW_DIR);
std::fs::create_dir_all(&www).with_context(|| format!("create {}", www.display()))?;
let theme = theme();
let started = std::time::Instant::now();
let drawn = AtomicUsize::new(0);
let card = |plan: Planned| -> anyhow::Result<Option<(String, PathBuf)>> {
let file = cards.join(format!("{}.png", plan.key));
if !file.is_file() {
drawn.fetch_add(1, Ordering::Relaxed);
match plan
.card
.resolve()
.and_then(|card| draw::draw(&card, &theme))
{
Ok(png) => atomic_write(&file, &png)
.with_context(|| format!("write {}", file.display()))?,
Err(error) => {
tracing::warn!(path = %plan.path, "no og card: {error:#}");
return Ok(None);
}
}
}
Ok(Some((plan.path, file)))
};
let users: Vec<_> = plan
.users
.into_par_iter()
.map(card)
.collect::<anyhow::Result<_>>()?;
let mut owners = BTreeSet::new();
for (path, file) in users.into_iter().flatten() {
let target = www.join(&path);
link_or_reflink(&file, &target).with_context(|| format!("link {}", target.display()))?;
owners.insert(file);
}
let mut wanted = Vec::new();
let mut work = Vec::new();
for (index, set) in plan.sets.into_iter().enumerate() {
wanted.push((set.did, set.name, !set.cards.is_empty()));
work.extend(set.cards.into_iter().map(|planned| (index, planned)));
}
let done: Vec<(usize, Option<(String, PathBuf)>)> = work
.into_par_iter()
.map(|(index, planned)| Ok((index, card(planned)?)))
.collect::<anyhow::Result<_>>()?;
let mut contents = vec![Vec::new(); wanted.len()];
for (index, done) in done {
contents[index].push(done);
}
let mut built = 0;
let mut named = BTreeSet::new();
for ((did, name, fresh), contents) in wanted.iter().zip(contents) {
let dir = sets.join(name);
if *fresh && !dir.is_dir() {
match contents.into_iter().collect::<Option<Vec<_>>>() {
Some(contents) => build_set(&sets, name, &contents)?,
// a card it can't draw now leaves the repo to the knot's card until a later write
None => continue,
}
built += 1;
}
let target = www.join(did);
std::os::unix::fs::symlink(&dir, &target)
.with_context(|| format!("link {}", target.display()))?;
std::os::unix::fs::symlink(dir.join(REPO_CARD), www.join(format!("{did}.png")))?;
named.insert(name.clone());
}
let drawn = drawn.into_inner();
tracing::info!(
sets = wanted.len(),
built,
drawn,
seconds = started.elapsed().as_secs_f64(),
"og cards"
);
// what a sweep leaves only costs disk, so it doesn't fail the write
if (drawn > 0 || built > 0)
&& let Err(error) = sweep(output_root, &named, &owners, GRACE)
{
tracing::warn!("og card sweep: {error:#}");
}
Ok(())
}
/// links a set's cards into a staging directory and renames it into place whole
fn build_set(sets: &Path, name: &str, contents: &[(String, PathBuf)]) -> anyhow::Result<()> {
let staging = tempfile::Builder::new()
.prefix(".staging-")
.tempdir_in(sets)?;
for (path, file) in contents {
let target = staging.path().join(path);
link_or_reflink(file, &target).with_context(|| format!("link {}", target.display()))?;
}
crate::cas::seal_read_only(staging.path())?;
let dir = sets.join(name);
match std::fs::rename(staging.path(), &dir) {
Ok(()) => {
// it's the set now, not staging to clean up
let _ = staging.keep();
Ok(())
}
// a write alongside built the same set
Err(_) if dir.is_dir() => Ok(()),
Err(error) => Err(error).with_context(|| format!("publish {}", dir.display())),
}
}
/// drops the sets that neither `named` nor a kept generation names, and the drawn cards no kept
/// set or owner links, once they're older than `grace`
pub(super) fn sweep(
output_root: &Path,
named: &BTreeSet<String>,
owners: &BTreeSet<PathBuf>,
grace: Duration,
) -> anyhow::Result<()> {
use std::os::unix::fs::MetadataExt as _;
let cards = output_root.join(CARDS_DIR);
let sets = cards.join(SETS_DIR);
let mut kept = named.clone();
let generations = output_root.join("generations");
for generation in std::fs::read_dir(&generations).into_iter().flatten() {
let www = generation?.path().join("www").join(WWW_DIR);
for entry in std::fs::read_dir(&www).into_iter().flatten() {
if let Ok(target) = std::fs::read_link(entry?.path())
&& let Some(name) = target.file_name()
&& target.parent().and_then(Path::file_name) == Some(SETS_DIR.as_ref())
{
kept.insert(name.to_string_lossy().into_owned());
}
}
}
let old = |path: &Path| {
std::fs::symlink_metadata(path)
.and_then(|meta| meta.modified())
.is_ok_and(|at| at.elapsed().is_ok_and(|age| age >= grace))
};
let mut linked = BTreeSet::new();
for entry in std::fs::read_dir(&sets)? {
let path = entry?.path();
let name = path.file_name().unwrap_or_default().to_string_lossy();
if kept.contains(name.as_ref()) {
let mut dirs = vec![path];
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let meta = entry.metadata()?;
match meta.is_dir() {
true => dirs.push(entry.path()),
false => {
linked.insert((meta.dev(), meta.ino()));
}
}
}
}
} else if old(&path) {
std::fs::remove_dir_all(&path).with_context(|| format!("remove {}", path.display()))?;
}
}
for owner in owners {
let meta = std::fs::metadata(owner)?;
linked.insert((meta.dev(), meta.ino()));
}
for entry in std::fs::read_dir(&cards)? {
let entry = entry?;
let meta = entry.metadata()?;
if meta.is_file() && !linked.contains(&(meta.dev(), meta.ino())) && old(&entry.path()) {
std::fs::remove_file(entry.path())
.with_context(|| format!("remove {}", entry.path().display()))?;
}
}
Ok(())
}