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"] }
//! ```
use std::{collections::HashMap, env, net::{TcpListener, TcpStream, ToSocketAddrs}, path::PathBuf};

use anyhow::Result;

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

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

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

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

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

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

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

    Ok(())
}

/// 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;

    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub enum Section {
        Backlog,
        Todo,
        Doing,
        Done,
    }

    impl Section {
        pub fn display(&self) -> &'static str {
            match self {
                Self::Backlog => "Backlog",
                Self::Todo => "Todo",
                Self::Doing => "Doing",
                Self::Done => "Done",
            }
        }

        pub fn class(&self) -> &'static str {
            match self {
                Self::Backlog => "column-backlog",
                Self::Todo => "column-todo",
                Self::Doing => "column-doing",
                Self::Done => "column-done",
            }
        }
    }

    #[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, Section};

    pub fn parse_file(path: impl AsRef<Path>) -> Result<HashMap<Section, 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) {
                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<Section, Vec<Item>>,
        path: impl Into<PathBuf>,
        current_section: &Option<Section>,
        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) {
            let source = path.into();
            items.entry(section).or_default().push(Item {
                title,
                content,
                source,
            });
        }
    }

    fn parse_section_start(line: &str) -> Option<Section> {
        match line.trim_end() {
            "# Todo" => Some(Section::Todo),
            "# Backlog" => Some(Section::Backlog),
            "# Doing" => Some(Section::Doing),
            "# Done" => Some(Section::Done),
            _ => 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::{Item, Section};

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

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

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

        target
    }

    fn try_render_header(
        target: &mut String,
        items: &HashMap<Section, 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<Section, Vec<Item>>,
    ) -> Result<(), std::fmt::Error> {
        use std::fmt::Write;

        for section in &[
            Section::Backlog,
            Section::Todo,
            Section::Doing,
            Section::Done,
        ] {
            writeln!(target, r#"<section class="{}">"#, section.class())?;
            writeln!(target, r#"  <h2>{}</h2>"#, section.display())?;
            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);
        }
    }
})();
"###;
}