Miniban - Markdown docs as kanban boards

//! A simple markdown powered kanban board
//!
//! ```cargo
//! [dependencies]
//! anyhow = "1.0"
//! pulldown-cmark = "0.8"
//! sha1 = {version = "0.6", features = ["std"] }
//! toml = "0.5"
//! serde = { version = "1.0", features = ["derive"] }
//! ```
use std::{
    collections::HashMap,
    env,
    ffi::OsString,
    net::{TcpListener, TcpStream, ToSocketAddrs},
    path::PathBuf,
};

use anyhow::{ensure, Result};
use serde::Deserialize;

pub use base::Item;
pub use parser::parse_file;
pub use render::{render_board, AssetConfig};

#[allow(dead_code)]
fn main() -> Result<()> {
    let args = env::args_os().skip(1).collect::<Vec<_>>();

    let index_fn = move || {
        let asset_config = AssetConfig {
            style_sheet_path: String::from("/app.css"),
            script_path: String::from("/app.js"),
        };
        let config = parse_args(&args)?;

        let mut items = HashMap::<String, Vec<Item>>::new();

        for path in &config.paths {
            for (section, section_items) in parse_file(path, &config.sections)? {
                items.entry(section).or_default().extend(section_items);
            }
        }

        let content = render_board(&items, &config.sections, &asset_config);
        Ok(content)
    };

    serve_board("127.0.0.1:5000", index_fn)?;

    Ok(())
}

fn parse_args(args: &[OsString]) -> Result<Config> {
    let mut paths = Vec::new();
    let mut config = None;

    for arg in args {
        let path = PathBuf::from(arg);
        if path.extension().and_then(|s| s.to_str()) == Some("toml") {
            ensure!(config.is_none(), "Cannot handle multiple config files");
            let new_config = std::fs::read(&path)?;
            let new_config: Config = toml::from_slice(&new_config)?;
            config = Some(new_config);
        } else {
            paths.push(path);
        }
    }

    let mut config = config.unwrap_or_else(Config::default);
    config.paths.extend(paths.into_iter());

    if config.sections.is_empty() {
        config.sections.extend(
            (&["Backlog", "Todo", "Doing", "Done"])
                .iter()
                .map(|s| String::from(*s)),
        );
    }

    Ok(config)
}

#[derive(Default, Debug, Deserialize, Clone)]
struct Config {
    paths: Vec<PathBuf>,
    sections: Vec<String>,
}

/// Run a simple HTTP Server
///
/// Adapted from the [rust book](https://doc.rust-lang.org/book/ch20-01-single-threaded.html)
///
fn serve_board<A, F>(address: A, index_fn: F) -> Result<()>
where
    A: ToSocketAddrs + std::fmt::Display,
    F: Fn() -> Result<String>,
{
    println!("Serving at {}", address);
    let listener = TcpListener::bind(address)?;
    for stream in listener.incoming() {
        let res = serve_request(stream, &index_fn);
        if let Err(err) = res {
            println!("Error during request handling: {}", err);
        }
    }

    Ok(())
}

fn serve_request<F>(stream: std::io::Result<TcpStream>, index_fn: &F) -> Result<()>
where
    F: Fn() -> Result<String>,
{
    let stream = stream?;
    let (stream, buffer) = reader_request_line(stream)?;

    println!(
        "{:?}",
        std::str::from_utf8(buffer.as_slice()).unwrap_or("<invalid utf8>")
    );
    if buffer.starts_with(b"GET / HTTP") || buffer.starts_with(b"GET /?") {
        let content = index_fn()?;
        write_reply(stream, "text/html", &content)?;
    } else if buffer.starts_with(b"GET /app.css HTTP") {
        write_reply(stream, "text/css", assets::STYLE_SHEET)?;
    } else if buffer.starts_with(b"GET /app.js HTTP") {
        write_reply(stream, "application/javascript", assets::SCRIPT)?;
    } else {
        use std::io::Write;
        let mut stream = stream;
        stream.write_all(b"HTTP/1.1 404 NOT FOUND\r\n\r\n")?;
        stream.flush()?;
    }
    Ok(())
}

fn reader_request_line(stream: TcpStream) -> Result<(TcpStream, Vec<u8>)> {
    use std::io::{BufRead, BufReader};

    let mut stream = BufReader::new(stream);

    let mut buffer = Vec::new();
    stream.read_until(b'\n', &mut buffer)?;

    let stream = stream.into_inner();

    Ok((stream, buffer))
}

pub fn write_reply(mut stream: TcpStream, content_type: &str, content: &str) -> Result<()> {
    use std::io::Write;

    stream.write_all(b"HTTP/1.1 200 OK\r\n")?;
    stream.write_all(format!("Content-Type: {}\r\n", content_type).as_bytes())?;
    stream.write_all(format!("Content-Length: {}\r\n", content.len()).as_bytes())?;
    stream.write_all(b"\r\n")?;
    stream.write_all(content.as_bytes())?;
    stream.flush()?;

    Ok(())
}

mod base {
    use std::{ffi::OsStr, path::PathBuf};

    use sha1::Sha1;

    pub fn section_to_class(title: &str) -> String {
        format!("column-{}", title.trim().to_lowercase())
    }

    #[derive(Debug, Clone, PartialEq)]
    pub struct Item {
        pub title: String,
        pub content: String,
        pub source: PathBuf,
    }

    impl Item {
        pub fn get_source(&self) -> String {
            self.source
                .file_stem()
                .and_then(OsStr::to_str)
                .map(str::to_owned)
                .unwrap_or_else(|| String::from("unknown"))
        }

        pub fn get_tag_class(&self) -> String {
            let source = self.get_source();
            Item::get_tag_class_for_source(&source)
        }

        pub fn get_id(&self) -> String {
            let title = self.title.trim().replace(' ', "").to_lowercase();
            let mut h = Sha1::new();
            h.update(title.as_bytes());
            h.hexdigest()
        }

        pub fn get_tag_class_for_source(source: &str) -> String {
            let mut res = source.to_lowercase().replace('.', "-");
            res.insert_str(0, "source-");
            res
        }
    }
}

mod parser {
    use std::{
        collections::HashMap,
        fs::File,
        io::{BufRead, BufReader},
        path::{Path, PathBuf},
    };

    use anyhow::Result;

    use super::base::Item;

    pub fn parse_file(
        path: impl AsRef<Path>,
        sections: &[String],
    ) -> Result<HashMap<String, Vec<Item>>> {
        let path = path.as_ref();
        let content = File::open(path)?;
        let content = BufReader::new(content);

        let mut items = HashMap::new();
        let mut current_section = None;
        let mut current_title = None;
        let mut current_content = String::new();

        for line in content.lines() {
            let line = line.unwrap();
            let line = line.trim_end();

            if let Some(section) = parse_section_start(line, sections) {
                push_current(
                    &mut items,
                    path,
                    &current_section,
                    &mut current_title,
                    &mut current_content,
                );
                current_section = Some(section);
                current_title = None;
            } else if let Some(title) = parse_title(line) {
                push_current(
                    &mut items,
                    path,
                    &current_section,
                    &mut current_title,
                    &mut current_content,
                );
                current_title = Some(title);
            } else if line.starts_with('#') {
                push_current(
                    &mut items,
                    path,
                    &current_section,
                    &mut current_title,
                    &mut current_content,
                );
                current_section = None;
                current_title = None;
            } else if current_title.is_some() {
                current_content.push_str(line);
                current_content.push('\n');
            }
        }

        push_current(
            &mut items,
            path,
            &current_section,
            &mut current_title,
            &mut current_content,
        );

        Ok(items)
    }

    fn push_current(
        items: &mut HashMap<String, Vec<Item>>,
        path: impl Into<PathBuf>,
        current_section: &Option<String>,
        current_title: &mut Option<String>,
        current_content: &mut String,
    ) {
        let content = take(current_content);
        let current_title = take(current_title);

        if let (Some(title), Some(section)) = (current_title, current_section.as_ref()) {
            let source = path.into();
            items.entry(section.to_owned()).or_default().push(Item {
                title,
                content,
                source,
            });
        }
    }

    fn parse_section_start(line: &str, sections: &[String]) -> Option<String> {
        let line = line.trim_end().strip_prefix("# ")?;

        for sec in sections {
            if sec == line {
                return Some(sec.to_owned());
            }
        }
        None
    }

    fn parse_title(line: &str) -> Option<String> {
        if let Some(title) = line.trim_end().strip_prefix("## ") {
            Some(title.trim().to_owned())
        } else {
            None
        }
    }

    fn take<T: Default>(value: &mut T) -> T {
        let mut result = T::default();
        std::mem::swap(value, &mut result);
        result
    }
}

mod render {
    use pulldown_cmark::{html, Options, Parser};
    use std::collections::HashMap;

    use super::base::{section_to_class, Item};

    pub struct AssetConfig {
        pub style_sheet_path: String,
        pub script_path: String,
    }

    pub fn render_board(
        items: &HashMap<String, Vec<Item>>,
        sections: &[String],
        config: &AssetConfig,
    ) -> String {
        let mut target = String::new();

        try_render_header(&mut target, items, config).unwrap();
        try_render_cards(&mut target, items, sections).unwrap();
        try_render_footer(&mut target, config).unwrap();

        target
    }

    fn try_render_header(
        target: &mut String,
        items: &HashMap<String, Vec<Item>>,
        config: &AssetConfig,
    ) -> Result<(), std::fmt::Error> {
        use std::fmt::Write;

        let sources = items.values().flatten().map(Item::get_source);
        let sources = unique(sources);

        let tag_colors = &["#f66", "#6f6", "#66f", "#6ff", "#f6f", "#ff6"];

        writeln!(target, r#"<html>"#)?;
        writeln!(target, r#"<head>"#)?;
        writeln!(target, r#"<meta charset="UTF-8">"#)?;
        writeln!(
            target,
            r#"<link rel="stylesheet" type="text/css" href="{}" media="screen"/>"#,
            config.style_sheet_path,
        )?;

        writeln!(target, r#"<style type="text/css">"#)?;
        for (source, &color) in sources.iter().zip(tag_colors.iter().cycle()) {
            let tag_class = Item::get_tag_class_for_source(source);

            writeln!(target, r#"div.card.{} {{"#, tag_class)?;
            writeln!(target, r#"  border-left-color: {};"#, color)?;
            writeln!(target, r#"}}"#)?;
            writeln!(target)?;
            writeln!(target, r#"div#source-filter button.{} {{"#, tag_class)?;
            writeln!(target, r#"  border: 1px solid {};"#, color)?;
            writeln!(target, r#"}}"#)?;
        }
        writeln!(target, r#"</style>"#)?;

        writeln!(target, r#"<title>Miniban</title>"#)?;
        writeln!(target, r#"</head>"#)?;
        writeln!(target, r#"<body id="miniban">"#)?;
        writeln!(target, r#"<h1>Miniban board</h1>"#)?;

        writeln!(target, r#"<div id="source-filter">"#)?;
        writeln!(target, r#"<button data-class="">clear</button>"#)?;

        for source in sources {
            let tag_class = Item::get_tag_class_for_source(&source);

            writeln!(
                target,
                r#"<button class="{tag}" data-class="{tag}">{source}</button>"#,
                source = source,
                tag = tag_class,
            )?;
        }
        writeln!(target, r#"</div>"#)?;

        writeln!(target, r#"<div id="board">"#)?;

        Ok(())
    }

    fn try_render_cards(
        target: &mut String,
        items: &HashMap<String, Vec<Item>>,
        sections: &[String],
    ) -> Result<(), std::fmt::Error> {
        use std::fmt::Write;

        for section in sections {
            writeln!(target, r#"<section class="{}">"#, section_to_class(section))?;
            writeln!(target, r#"  <h2>{}</h2>"#, section)?;
            writeln!(target, r#"  <div class="cards">"#)?;

            for item in items.get(section).into_iter().flatten() {
                let source = item.get_source();
                let tag_class = item.get_tag_class();
                let id = item.get_id();

                writeln!(
                    target,
                    r#"    <div id="{id}" class="card {tag_class}">"#,
                    id = id,
                    tag_class = tag_class,
                )?;
                writeln!(target, r#"    <header>"#)?;
                render_markdown(target, &format!("### {}", item.title));
                writeln!(target, r#"    <p>{}</p>"#, source)?;
                writeln!(target, r#"    </header>"#)?;
                writeln!(target, r#"<div class="content">"#)?;
                render_markdown(target, &item.content);
                writeln!(target, r#"</div>"#)?;

                writeln!(target, r#"    </div>"#)?;
            }

            writeln!(target, r#"  </div>"#)?;
            writeln!(target, r#"</section>"#)?;
        }

        Ok(())
    }

    fn unique<L, I>(items: L) -> Vec<I>
    where
        L: IntoIterator<Item = I>,
        I: Ord + Clone,
    {
        let mut res = items.into_iter().collect::<Vec<_>>();
        res.sort();
        res.dedup();

        res
    }

    fn try_render_footer(target: &mut String, config: &AssetConfig) -> Result<(), std::fmt::Error> {
        use std::fmt::Write;

        writeln!(target, r#"</div>"#)?;
        writeln!(target, r#"</body>"#)?;
        writeln!(
            target,
            r#"<script type="application/javascript" src="{}"></script>"#,
            config.script_path,
        )?;
        writeln!(target, r#"</html>"#)?;

        Ok(())
    }

    pub fn render_markdown(target: &mut String, content: &str) {
        let mut options = Options::empty();
        options.insert(Options::ENABLE_STRIKETHROUGH);
        options.insert(Options::ENABLE_TABLES);
        options.insert(Options::ENABLE_TASKLISTS);

        let parser = Parser::new_ext(content, options);
        html::push_html(target, parser);
    }
}

pub mod assets {
    pub const STYLE_SHEET: &str = r###"* {
    padding: 0px;
    margin: 0px;
    font-size: 100%;
}

body {
    font-family: Arial, Helvetica, sans-serif;
    margin: 0.5em 3em;
    color: #222;
}

body#miniban h1 {
    display: none;
}

div#source-filter button {
    padding: 0.2em;
}

div#source-filter button.active:not(:hover), div#source-filter button:not(.active):hover {
    background-color: #eee;
}

div#source-filter button.active:hover, div#source-filter button:not(.active):not(:hover) {
    background-color: #fff;
}

#board {
    display: flex;
}

#board section {
    width: 30em;
    flex-shrink: 0;
}

#board h2 {
    font-size: 1.3em;
    padding: 0.25em 0.5em;
}

div.card {
    width: calc(28em - 1.5em);
    border: 0.1em solid rgba(0,0,0,0.05);
    box-shadow: 0 0.25em 0.2em 0 rgba(0,0,0,0.15);
    margin: 0.5em 0.5em 1em 0.5em;
    padding: 0.25em;
    line-height: 130%;
}

div.card.hidden {
    display: none;
}

div.card h3 {
    width: 100%;
    font-size: 1.0em;
    font-weight: bold;
}

div.card div.content.hidden {
    display: none;
}

div.card div.content ul {
    padding-left: 1em;
    margin: 0.25em 0em;
}

div.card div.content p {
    margin: 0.2em 0em 0.5em 0em;
}

div.card div.content blockquote {
    margin-left: 0.5em;
    padding-left: 0.5em;
    border-left: 0.1em solid #777;
}

div.card header p {
    font-size: 0.8em;
    color: #777;
}

div.card pre {
    width: 100%;
    overflow-x: scroll;
    scrollbar-width: none; 
}
"###;

    pub const SCRIPT: &str = r###"(function () {
    "strict";
    init();

    function init() {
        bindEventListeners();

        const url = new URL(window.location);
        const source = url.searchParams.get("source");
        const card = url.searchParams.get("card");

        showSource(source);
        showCard(card);
    }

    function bindEventListeners() {
        document.querySelectorAll("div.card").forEach(card => {
            card.addEventListener("click", () => {
                const id = card.id;
                setState({ card: id });
                showCard(id);
            });
        });

        document.querySelectorAll("div#source-filter button").forEach(button => {
            const targetClass = button.dataset.class;
            const source = targetClass.replace("source-", "");

            if (source == "") {
                button.addEventListener("click", () => {
                    setState({ source: null, card: null });
                    showSource(null);
                    showCard(null);
                });
            }
            else {
                button.addEventListener("click", () => {
                    setState({ source });
                    showSource(source);
                });
            }
        });
    }

    function showCard(id) {
        document.querySelectorAll("div.card > div.content").forEach(content => {
            const card = content.parentElement;
            setClass(content, "hidden", (id == null) || (id == "") || card.id != id);
        });
    }

    function showSource(source) {
        document.querySelectorAll("div.card").forEach(card => {
            setClass(card, "hidden", (source != null) && (source != "") && !card.classList.contains("source-" + source));
        });
    }

    function setState(params) {
        const url = new URL(window.location);
        updateSearchParam(url, params, "source");
        updateSearchParam(url, params, "card");

        window.history.replaceState(null, "", url.toString());
    }

    function updateSearchParam(url, params, key) {
        if (key in params) {
            if ((params[key] != null) && (params[key] != "")) {
                url.searchParams.set(key, params[key]);
            }
            else {
                url.searchParams.delete(key);
            }
        }
    }

    function setClass(element, className, flag) {
        if (flag) {
            element.classList.add(className);
        }
        else {
            element.classList.remove(className);
        }
    }
})();
"###;
}