//! 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, ¤t_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, ¤t_section, &mut current_title, &mut current_content, ); current_title = Some(title); } else if line.starts_with('#') { push_current( &mut items, path, ¤t_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, ¤t_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); } } })(); "###; }