use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::mpsc::channel; use std::time::{Duration, Instant}; use std::{fs, io}; use comrak::{self, ComrakOptions}; use notify::{DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use toml; type TagName = String; type TagId = String; type TagMap = HashMap; // Maps field to value. type NoteMap = HashMap; type NotesMap = HashMap; const TAG_TYPE_ID: &'static str = "5"; const TEMPLATE_HEADER: &'static str = r#" {{TITLE}}
"#; const TEMPLATE_FOOTER: &'static str = r#"
"#; #[derive(Serialize, Deserialize)] struct Configuration { path_in: String, path_out: String, } struct Context { config: Configuration, tags: Option, public_tag_id: Option, } impl Context { fn new(config: Configuration) -> Self { Self { config, tags: None, public_tag_id: None, } } fn update_tags(&mut self, notes: &NotesMap) { let tags = collect_tags(notes); self.public_tag_id = tags.get("public").map(|x| x.clone()); self.tags = Some(tags); } fn sanitize_title(title: &str) -> String { // TODO do a better sanitize of the title. title.replace(" ", "_") } fn publish_public(&self, notes: &NotesMap) -> Result<(), io::Error> { let public_tag_id = if let Some(id) = &self.public_tag_id { id } else { return Ok(()); }; let mut tagged_notes = HashSet::new(); for note in notes.values() { if let Some(tag_id) = note.get("tag_id") { if tag_id == public_tag_id { tagged_notes.insert(note.get("note_id").unwrap()); } } } let tagged_notes = tagged_notes.iter().map(|¬e_id| { notes .iter() .find(|(_, note)| ¬e["id"] == note_id) .map(|(path, _)| path) .unwrap() }); for note_id in tagged_notes { let note = ¬es[note_id]; if let Some(content) = note.get("markdown_content") { // Put the title as H1, if it's not the case yet. let md_content = if !content.starts_with("#") { "# ".to_string() + content } else { content.to_string() }; let title = content.split("\n").next().unwrap(); let mut options = ComrakOptions::default(); options.ext_autolink = true; options.ext_tasklist = true; options.ext_header_ids = Some("id-".into()); let html_content = TEMPLATE_HEADER.replace("{{TITLE}}", &title) + &comrak::markdown_to_html(&md_content, &options) + TEMPLATE_FOOTER; let filename = Context::sanitize_title(title); let path = self.config.path_out.clone() + &format!("/{}.html", filename); println!("Publishing {}", path); let mut file = fs::File::create(path)?; use std::io::Write; file.write_all(html_content.as_bytes())?; } } Ok(()) } fn remove_public(&mut self, note: &NoteMap) { // TODO this will only remove the file if the note has been removed, not if the public tag // has been removed. Handle this too. let title = if let Some(content) = note.get("markdown_content") { content.split("\n").next().unwrap() } else { return; }; let filename = Context::sanitize_title(&title); let path = self.config.path_out.clone() + &format!("/{}.html", filename); // Missing files or errors on deletion are fine. let _ = fs::remove_file(&path); } } fn collect_tags(notes: &NotesMap) -> TagMap { let mut tags = TagMap::new(); for note in notes.values() { if let Some(type_id) = note.get("type_") { if type_id == TAG_TYPE_ID { tags.insert(note["markdown_content"].clone(), note["id"].clone()); } } } tags } fn run_hooks(context: &mut Context, notes: &NotesMap) -> Result<(), io::Error> { println!("Running hooks..."); let start = Instant::now(); context.update_tags(notes); let ret = context.publish_public(notes); let duration = Instant::now() - start; println!("Took {}ms", duration.as_millis()); ret } fn read_file(path: &Path) -> Result { let content = fs::read_to_string(path)?; let content = content.trim().split("\n").collect::>(); let mut fields = NoteMap::new(); for j in 0..content.len() { let i = content.len() - 1 - j; let line = content[i]; if let Some(index) = line.find(":") { let (key, value) = line.split_at(index); // Remove the ":" character. let value = value[1..].trim(); if key.len() > 0 && value.len() > 0 { fields.insert(key.to_string(), value.to_string()); } } else { // First line without a metadata field! Reverse content, it's the markdown block. let markdown = content[0..i] .iter() .map(|s| s.to_string()) .collect::>() .join("\n"); let markdown = markdown.trim(); fields.insert("markdown_content".to_string(), markdown.to_string()); break; } } println!( "Extracted content of {} ({} fields)", fields .get("id") .expect(&format!("Missing id for note {}", path.to_string_lossy())), fields.len() ); Ok(fields) } fn main() -> Result<(), io::Error> { let config_content = fs::read_to_string("./config.toml")?; let config: Configuration = toml::from_str(&config_content).unwrap(); match fs::metadata(&config.path_out) { Ok(_) => {} Err(_) => { fs::create_dir(&config.path_out)?; } } let mut num_files = 0; let mut files = NotesMap::new(); for entry in fs::read_dir(&config.path_in)? { let dir_entry = entry?; if !dir_entry.file_type()?.is_file() { continue; } let path = dir_entry.path(); let note = read_file(&path)?; files.insert(path, note); num_files += 1; } println!("Found {} files.", num_files); let mut context = Context::new(config); run_hooks(&mut context, &files)?; println!("Now listening to disk events..."); let (tx, rx) = channel(); let mut watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(2)).unwrap(); watcher .watch(&context.config.path_in, RecursiveMode::Recursive) .unwrap(); loop { match rx.recv() { Ok(evt) => { use DebouncedEvent::*; match &evt { Create(path) | Write(path) => { files.insert(path.clone(), read_file(path)?); run_hooks(&mut context, &files)?; } Remove(path) => { if let Some(note) = files.get(path) { context.remove_public(note); } files.remove(path); } _ => {} }; println!("{:?}", evt); } Err(e) => println!("watch error: {:?}", e), } } }