diff --git a/Cargo.toml b/Cargo.toml index ea8effa..56d4eb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" [dependencies] chrono = "0.4.26" clap = { version = "4.5.1", features = ["derive"] } -comrak = "0.18.0" +comrak = "0.24.1" figment = { version = "0.10.10", features = ["env", "serde_json", "json"] } regex = "1.8.4" serde = { version = "1.0.164", features = ["serde_derive"] } @@ -16,3 +16,4 @@ serde_json = "1.0.97" resolve-path = "0.1.0" simple_logger = "4.3.3" log = "0.4.21" +indexmap = "2.2.6" diff --git a/src/file/mod.rs b/src/file/mod.rs index 868e23d..b32228a 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -2,9 +2,12 @@ use crate::todo::{File as TodoFile, Status as TaskStatus}; use crate::NaiveDate; use crate::TaskGroup; use chrono::Datelike; -use comrak::nodes::{AstNode, NodeValue}; -use comrak::parse_document; -use comrak::{Arena, ComrakExtensionOptions, ComrakOptions, ComrakParseOptions}; +use comrak::nodes::{Ast, AstNode, LineColumn, NodeHeading, NodeValue}; +use comrak::{ + format_commonmark, parse_document, Arena, ComrakOptions, ExtensionOptions, ParseOptions, +}; +use indexmap::IndexMap; +use regex::Regex; use std::collections::HashMap; use std::fs::{read, File}; use std::io::Write; @@ -62,15 +65,15 @@ pub fn load_file(file: &TodoFile) -> String { /// Parse contents of markdown file with Comrak ( relaxed tasklist matching is enabled) pub fn parse_todo_file<'a>(contents: &String, arena: &'a Arena>) -> &'a AstNode<'a> { + let mut extension_options = ExtensionOptions::default(); + extension_options.tasklist = true; + + let mut parse_options = ParseOptions::default(); + parse_options.relaxed_tasklist_matching = true; + let options = &ComrakOptions { - extension: ComrakExtensionOptions { - tasklist: true, - ..ComrakExtensionOptions::default() - }, - parse: ComrakParseOptions { - relaxed_tasklist_matching: true, - ..ComrakParseOptions::default() - }, + extension: extension_options, + parse: parse_options, ..ComrakOptions::default() }; parse_document(arena, contents, options) @@ -117,10 +120,178 @@ pub fn extract_secitons<'a>( groups } +fn remove_heading<'a>(node: &'a AstNode<'a>, level: u8) { + let mut following = node.following_siblings(); + let _ = following.next().unwrap(); + for sib in following { + let node_ref = sib.data.borrow(); + if let NodeValue::Heading(heading) = node_ref.value { + if heading.level == level { + break; + } + } else { + sib.detach(); + } + } + node.detach(); +} + +/// recursively removes nodes from List +fn remove_task_nodes<'a>(root: &'a AstNode<'a>) { + for node in root.children() { + for child_node in node.children() { + remove_task_nodes(child_node) + } + match node.data.borrow().value { + NodeValue::TaskItem(Some(status)) if status == 'x' || status == 'X' => node.detach(), + _ => continue, + } + } +} + +fn create_title<'a>(arena: &'a Arena>, date: &str) -> &'a AstNode<'a> { + let mut text = String::new(); + text.push_str("Today's tasks "); + text.push_str(date); + + create_heading(arena, 1, &text) +} + +fn create_heading<'a>(arena: &'a Arena>, level: u8, text: &str) -> &'a AstNode<'a> { + let heading_node = arena.alloc(AstNode::new( + Ast::new( + NodeValue::Heading(NodeHeading { + level, + setext: false, + }), + LineColumn { line: 0, column: 0 }, + ) + .into(), + )); + let text_node = arena.alloc(AstNode::new( + Ast::new( + NodeValue::Text(text.to_string()), + LineColumn { line: 0, column: 2 }, + ) + .into(), + )); + + heading_node.append(text_node); + + heading_node +} + +pub fn create_new_doc<'a>( + arena: &'a Arena>, + new_date: &str, + sections: IndexMap>>>, +) -> &'a AstNode<'a> { + let doc = arena.alloc(AstNode::new( + Ast::new(NodeValue::Document, LineColumn { line: 0, column: 0 }).into(), + )); + let title = create_title(&arena, new_date); + doc.append(title); + + for (section, value) in sections.iter() { + let heading = create_heading(arena, 2, §ion); + doc.append(heading); + match value { + Some(nodes) => { + for node in nodes.iter() { + doc.append(node); + } + } + _ => (), + } + } + doc +} + +pub fn extract_sections<'a>( + root: &'a AstNode<'a>, + sections: &Vec, +) -> IndexMap>>> { + let mut section_map: IndexMap>>> = IndexMap::new(); + sections.iter().for_each(|section| { + section_map.insert(section.to_string(), None); + }); + + for node in root.reverse_children() { + let node_ref = node.data.borrow(); + match node_ref.value { + NodeValue::Heading(heading) => { + let heading_content_node = if let Some(child) = node.first_child() { + child + } else { + continue; + }; + + let mut heading_content_ref = heading_content_node.data.borrow_mut(); + if let NodeValue::Text(text) = &mut heading_content_ref.value { + if sections.contains(text) { + let mut content = Vec::new(); + let mut following = node.following_siblings(); + let _ = following.next().unwrap(); + + for sib in following { + remove_task_nodes(sib); + let node_ref = sib.data.borrow(); + if let NodeValue::Heading(inner_heading) = node_ref.value { + if heading.level == inner_heading.level { + break; + } + } else { + content.push(sib); + } + } + section_map.insert(text.to_string(), Some(content)); + remove_heading(node, heading.level); + }; + } + } + _ => continue, + } + } + + section_map +} + +pub fn process_doc_tree<'a>(root: &'a AstNode<'a>, new_date: &str, sections: &Vec) { + for node in root.reverse_children() { + let node_ref = node.data.borrow(); + match node_ref.value { + NodeValue::Heading(heading) => { + let heading_content_node = if let Some(child) = node.first_child() { + child + } else { + continue; + }; + + let mut heading_content_ref = heading_content_node.data.borrow_mut(); + if let NodeValue::Text(text) = &mut heading_content_ref.value { + let re = Regex::new(r"Today's tasks \d+-\d+-\d+") + .expect("title regex is not parsable"); + if matches!(re.find(text), Some(_)) { + text.clear(); + text.push_str("Today's tasks "); + text.push_str(new_date); + } else if !sections.contains(text) { + remove_heading(node, heading.level); + }; + } + } + NodeValue::List(_list) => remove_task_nodes(node), + _ => continue, + } + } + eprintln!("{:#?}", root); +} + #[cfg(test)] mod test { use super::*; use crate::todo::{Status, Task}; + use std::io::BufWriter; #[test] fn test_extract_sections() { @@ -278,4 +449,91 @@ mod test { "; assert_eq!(result, expected); } + + #[test] + fn test_node_removal() { + let md = " +# Today's tasks 2024-01-01 + +## Tasks + +- [ ] task 1 +- [X] task 2 +- [x] task 2 +- [>] task 3 +- [!] task 3 + +## Long Term + +- [ ] task 1 +- [X] task 2 + - [ ] all of these subtasks should be removed + - [x] subtasks + - [x] sub task to remove +- [!] task 3 + - [ ] sub task to keep + - [x] sub task to remove + +## Todays Notes + +- some notes here +- these can go +"; + let new_date = "2024-01-02"; + let groups = vec![ + "Tasks".to_string(), + "Other".to_string(), + "Long Term".to_string(), + "Last".to_string(), + ]; + let arena = Arena::new(); + let mut extension_options = ExtensionOptions::default(); + extension_options.tasklist = true; + + let mut parse_options = ParseOptions::default(); + parse_options.relaxed_tasklist_matching = true; + + let options = &ComrakOptions { + extension: extension_options, + parse: parse_options, + ..ComrakOptions::default() + }; + + let ast = parse_document(&arena, md, options); + + let sections = extract_sections(ast, &groups); + + let new_doc = create_new_doc(&arena, new_date, sections); + + process_doc_tree(ast, new_date, &groups); + + let mut output = BufWriter::new(Vec::new()); + + assert!(format_commonmark(new_doc, options, &mut output).is_ok()); + + let bytes = output.into_inner().expect("should be a vec"); + let text = String::from_utf8(bytes).expect("should be convertable to string"); + assert_eq!( + "\ +# Today's tasks 2024-01-02 + +## Tasks + +- [ ] task 1 +- [>] task 3 +- [!] task 3 + +## Other + +## Long Term + +- [ ] task 1 +- [!] task 3 + - [ ] sub task to keep + +## Last +", + text + ); + } } diff --git a/src/main.rs b/src/main.rs index 87f57d8..ef48118 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,20 +5,23 @@ mod logging; mod todo; use chrono::naive::NaiveDate; -use chrono::{Local, TimeDelta}; +use chrono::{Datelike, Local, TimeDelta}; use clap::Parser; use cli::Args; -use comrak::Arena; +use comrak::{format_commonmark, Arena, ComrakOptions, ExtensionOptions, ParseOptions}; use config::Config; use log; use logging::get_logging_level; use resolve_path::PathResolveExt; use simple_logger::init_with_level; use std::fs; +use std::io::BufWriter; use std::path::Path; use std::process::Command; use todo::{File as TodoFile, TaskGroup}; +use crate::file::{create_new_doc, extract_sections, process_doc_tree}; + fn main() { // setup let args = Args::parse(); @@ -121,6 +124,17 @@ fn main() { let current_file = match latest_file { // copy old file if the user specifies today's notes but it does not exist Some(todo_file) if todo_file.date < today && args.previous == 0 => { + let mut extension_options = ExtensionOptions::default(); + extension_options.tasklist = true; + + let mut parse_options = ParseOptions::default(); + parse_options.relaxed_tasklist_matching = true; + + let options = &ComrakOptions { + extension: extension_options, + parse: parse_options, + ..ComrakOptions::default() + }; let sections = &cfg.sections; log::info!("looking for sections: {:?}", sections); let arena = Arena::new(); @@ -131,29 +145,28 @@ fn main() { "loading and parsing file: {}", todo_file.file.to_string_lossy() ); + let contents = file::load_file(&todo_file); - let root = file::parse_todo_file(&contents, &arena); + let root = comrak::parse_document(&arena, &contents, options); root }; log::trace!("file loaded"); - // extract sections specified in config - let groups = file::extract_secitons(root, sections); - log::trace!("sections extracted"); - // create new sections and generate empty sections for any that are missing - let level = groups.values().map(|group| group.level).min().unwrap_or(2); - let data = sections - .iter() - .map(|section| match groups.get(section) { - Some(group) => group.clone(), - None => TaskGroup::empty(section.to_string(), level), - }) - .collect(); + + let sect = extract_sections(root, §ions); + let date = format!("{}-{:02}-{:02}", today.year(), today.month(), today.day()); // generate string for new file and write to filesystem - let content = file::generate_file_content(&data, &today); + let new_doc = file::create_new_doc(&arena, &date, sect); + + process_doc_tree(root, &date, §ions); + + let mut new_content = BufWriter::new(Vec::new()); + format_commonmark(new_doc, options, &mut new_content); + let text = String::from_utf8(new_content.into_inner().expect("")); + let file_path = file::get_filepath(&data_dir, &today); log::info!("writing to file: {}", file_path.to_string_lossy()); - file::write_file(&file_path, &content); + file::write_file(&file_path, &text.expect("")); // return file name file_path }