From 9e64ba0eed67c9098fdf4d5d7dbd76fe49fae2eb Mon Sep 17 00:00:00 2001 From: Andrei Stoica Date: Tue, 14 Apr 2026 22:05:31 -0400 Subject: [PATCH] moved to comrak 0.52 --- ' | 543 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- src/file/mod.rs | 62 +++--- src/main.rs | 19 +- src/todo/tasks.rs | 15 +- 5 files changed, 596 insertions(+), 45 deletions(-) create mode 100644 ' diff --git a/' b/' new file mode 100644 index 0000000..cf74de7 --- /dev/null +++ b/' @@ -0,0 +1,543 @@ +use crate::todo::{File as TodoFile, Status as TaskStatus}; +use crate::NaiveDate; +use crate::TaskGroup; +use chrono::Datelike; +use comrak::nodes::{Ast, AstNode, LineColumn, NodeHeading, NodeTaskItem, NodeValue}; +use comrak::options::{Extension, Parse}; +use comrak::{parse_document, Arena, Options}; +use indexmap::IndexMap; +use regex::Regex; +use std::collections::HashMap; +use std::fs::{read, File}; +use std::io::Write; +use std::path::PathBuf; +use std::str; + +#[derive(Debug)] +pub enum FileNameParseError { + TypeConversionError(&'static str), + ParseError(chrono::ParseError), +} + +pub fn get_filepath(data_dir: &PathBuf, date: &NaiveDate) -> PathBuf { + let file_name = format!("{}-{:02}-{:02}.md", date.year(), date.month(), date.day()); + let mut file_path = data_dir.clone(); + file_path.push(file_name); + file_path +} + +/// generate strings from TaskGroups and date +pub fn generate_file_content(data: &Vec, date: &NaiveDate) -> String { + // TODO: This should be a type and then I can implement it with From<> + let mut content = format!( + "# Today's tasks {}-{:02}-{:02}\n", + date.year(), + date.month(), + date.day() + ); + data.iter() + .for_each(|task_group| content.push_str(format!("\n{}", task_group.to_string()).as_str())); + + content +} + +pub fn write_file(path: &PathBuf, content: &String) { + let mut new_file = File::create(&path).expect("Could not open today's file: {today_file_path}"); + write!(new_file, "{}", content).expect("Could not write to file: {today_file_path}"); +} + +/// Load in text file as String +pub fn load_file(file: &TodoFile) -> String { + // TODO: This could be a TryFrom<> + let contents_utf8 = read(file.file.clone()) + .expect(format!("Could not read file {}", file.file.to_string_lossy()).as_str()); + str::from_utf8(&contents_utf8) + .expect( + format!( + "failed to convert contents of file to string: {}", + file.file.to_string_lossy() + ) + .as_str(), + ) + .to_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 = Extension::default(); + extension_options.tasklist = true; + + let mut parse_options = Parse::default(); + parse_options.relaxed_tasklist_matching = true; + + let options = &Options { + extension: extension_options, + parse: parse_options, + ..Options::default() + }; + parse_document(arena, contents, options) +} + +pub fn extract_secitons<'a>( + root: &'a AstNode<'a>, + sections: &Vec, +) -> HashMap { + let mut groups: HashMap = HashMap::new(); + for node in root.reverse_children() { + let node_ref = &node.data.borrow(); + if let NodeValue::Heading(heading) = node_ref.value { + if heading.level < 2 { + continue; + } + + let first_child_ref = &node.first_child(); + let first_child = if let Some(child) = first_child_ref { + child + } else { + continue; + }; + + let data_ref = &first_child.data.borrow(); + let title = if let NodeValue::Text(value) = &data_ref.value { + value + } else { + continue; + }; + + if sections.iter().any(|section| section.eq(title)) { + if let Ok(mut group) = TaskGroup::try_from(node) { + group.tasks = group + .tasks + .into_iter() + .filter(|task| !matches!(task.status, TaskStatus::Done(_))) + .collect(); + groups.insert(title.to_string(), group); + } + } + }; + } + 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(NodeTaskItem { + symbol: Some(status), + symbol_sourcepos: _, + }) 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, + closed: false, + }), + LineColumn { line: 0, column: 0 }, + ) + .into(), + )); + + let text_node = arena.alloc(AstNode::new( + Ast::new( + NodeValue::Text(text.to_string().into()), + 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.to_string()) { + 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(_)) { + let text_mut = text.to_mut(); + text_mut.clear(); + text_mut.push_str("Today's tasks "); + text_mut.push_str(new_date); + } else if !sections.contains(&text.to_string()) { + 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; + use crate::todo::Task; + use comrak::format_commonmark; + + #[test] + fn test_extract_sections() { + let test_md = "\ +# Test +## Content + - [ ] something + - [x] done + - [!] other +## Unused +### Sub section + - [ ] task +## Unrealated Stuff + - [ ] something else + + [ ] subtask"; + + let arena = Arena::new(); + let root = parse_todo_file(&test_md.to_string(), &arena); + + let result = extract_secitons(root, &vec![]); + assert_eq!(result.keys().count(), 0); + + let result = extract_secitons(root, &vec!["Not There".to_string()]); + assert_eq!(result.keys().count(), 0); + + let sections = vec!["Unused".to_string()]; + let result = extract_secitons(root, §ions); + assert_eq!(result.keys().count(), 0); + + let sections = vec!["Sub section".to_string()]; + let result = extract_secitons(root, §ions); + assert_eq!(result.keys().count(), 1); + assert!(result.get(sections.first().unwrap()).is_some()); + assert_eq!(result.get(sections.first().unwrap()).unwrap().level, 3); + + let sections = vec!["Content".to_string()]; + let result = extract_secitons(root, §ions); + assert_eq!(result.keys().count(), 1); + assert!(result.get(sections.first().unwrap()).is_some()); + assert_eq!( + result + .get(sections.first().unwrap()) + .expect("No Value for \"Content\""), + &TaskGroup { + name: sections.first().unwrap().clone(), + tasks: vec![ + Task { + status: TaskStatus::Empty, + text: "something".to_string(), + subtasks: None + }, + Task { + status: TaskStatus::Todo('!'), + text: "other".to_string(), + subtasks: None + }, + ], + level: 2 + } + ); + + let sections = vec!["Unrealated Stuff".to_string()]; + let result = extract_secitons(root, §ions); + assert_eq!(result.keys().count(), 1); + assert!(result.get(sections.first().unwrap()).is_some()); + assert_eq!( + result + .get(sections.first().unwrap()) + .expect("No Value for \"Content\""), + &TaskGroup { + name: sections.first().unwrap().clone(), + tasks: vec![Task { + status: TaskStatus::Empty, + text: "something else".to_string(), + subtasks: Some(vec![Task { + status: TaskStatus::Empty, + text: "subtask".to_string(), + subtasks: None + }]), + }], + level: 2 + } + ); + + let result = extract_secitons( + root, + &vec!["Content".to_string(), "Sub section".to_string()], + ); + assert_eq!(result.keys().count(), 2); + } + + #[test] + fn test_generate_file_content() { + let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); + let mut content: Vec = vec![]; + + let result = generate_file_content(&content, &date); + let expected = "# Today's tasks 2024-01-01\n"; + assert_eq!(result, expected); + + content.push(TaskGroup { + name: "Empty".into(), + tasks: vec![], + level: 2, + }); + + let result = generate_file_content(&content, &date); + let expected = "# Today's tasks 2024-01-01\n\n## Empty\n"; + assert_eq!(result, expected); + + content.push(TaskGroup { + name: "Subgroup".into(), + tasks: vec![], + level: 3, + }); + + let result = generate_file_content(&content, &date); + let expected = "# Today's tasks 2024-01-01\n\n## Empty\n\n### Subgroup\n"; + assert_eq!(result, expected); + + content.push(TaskGroup { + name: "Tasks".into(), + tasks: vec![ + Task { + status: Status::Empty, + text: "task 1".into(), + subtasks: None, + }, + Task { + status: Status::Done('x'), + text: "task 2".into(), + subtasks: None, + }, + Task { + status: Status::Todo('>'), + text: "task 3".into(), + subtasks: None, + }, + ], + level: 2, + }); + + let result = generate_file_content(&content, &date); + let expected = "\ +# Today's tasks 2024-01-01 + +## Empty + +### Subgroup + +## Tasks +- [ ] task 1 +- [x] task 2 +- [>] task 3 +"; + 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 = Extension::default(); + extension_options.tasklist = true; + + let mut parse_options = Parse::default(); + parse_options.relaxed_tasklist_matching = true; + + let options = &Options { + extension: extension_options, + parse: parse_options, + ..Options::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 = String::new(); //BufWriter::new(Vec::new()); + + assert!(format_commonmark(new_doc, options, &mut output).is_ok()); + + 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 +", + output + ); + } +} diff --git a/Cargo.toml b/Cargo.toml index 56d4eb2..4420d8a 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.24.1" +comrak = "~0.52.0" figment = { version = "0.10.10", features = ["env", "serde_json", "json"] } regex = "1.8.4" serde = { version = "1.0.164", features = ["serde_derive"] } diff --git a/src/file/mod.rs b/src/file/mod.rs index b32228a..a0090b7 100644 --- a/src/file/mod.rs +++ b/src/file/mod.rs @@ -2,10 +2,9 @@ use crate::todo::{File as TodoFile, Status as TaskStatus}; use crate::NaiveDate; use crate::TaskGroup; use chrono::Datelike; -use comrak::nodes::{Ast, AstNode, LineColumn, NodeHeading, NodeValue}; -use comrak::{ - format_commonmark, parse_document, Arena, ComrakOptions, ExtensionOptions, ParseOptions, -}; +use comrak::nodes::{Ast, AstNode, LineColumn, NodeHeading, NodeTaskItem, NodeValue}; +use comrak::options::{Extension, Parse}; +use comrak::{parse_document, Arena, Options}; use indexmap::IndexMap; use regex::Regex; use std::collections::HashMap; @@ -64,17 +63,17 @@ 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(); +pub fn parse_todo_file<'a>(contents: &String, arena: &'a Arena<'a>) -> &'a AstNode<'a> { + let mut extension_options = Extension::default(); extension_options.tasklist = true; - let mut parse_options = ParseOptions::default(); + let mut parse_options = Parse::default(); parse_options.relaxed_tasklist_matching = true; - let options = &ComrakOptions { + let options = &Options { extension: extension_options, parse: parse_options, - ..ComrakOptions::default() + ..Options::default() }; parse_document(arena, contents, options) } @@ -143,13 +142,16 @@ fn remove_task_nodes<'a>(root: &'a AstNode<'a>) { remove_task_nodes(child_node) } match node.data.borrow().value { - NodeValue::TaskItem(Some(status)) if status == 'x' || status == 'X' => node.detach(), + NodeValue::TaskItem(NodeTaskItem { + symbol: Some(status), + symbol_sourcepos: _, + }) if status == 'x' || status == 'X' => node.detach(), _ => continue, } } } -fn create_title<'a>(arena: &'a Arena>, date: &str) -> &'a AstNode<'a> { +fn create_title<'a>(arena: &'a Arena<'a>, date: &str) -> &'a AstNode<'a> { let mut text = String::new(); text.push_str("Today's tasks "); text.push_str(date); @@ -157,20 +159,22 @@ fn create_title<'a>(arena: &'a Arena>, date: &str) -> &'a AstNode<'a create_heading(arena, 1, &text) } -fn create_heading<'a>(arena: &'a Arena>, level: u8, text: &str) -> &'a AstNode<'a> { +fn create_heading<'a>(arena: &'a Arena<'a>, level: u8, text: &str) -> &'a AstNode<'a> { let heading_node = arena.alloc(AstNode::new( Ast::new( NodeValue::Heading(NodeHeading { level, setext: false, + closed: false, }), LineColumn { line: 0, column: 0 }, ) .into(), )); + let text_node = arena.alloc(AstNode::new( Ast::new( - NodeValue::Text(text.to_string()), + NodeValue::Text(text.to_string().into()), LineColumn { line: 0, column: 2 }, ) .into(), @@ -182,7 +186,7 @@ fn create_heading<'a>(arena: &'a Arena>, level: u8, text: &str) -> & } pub fn create_new_doc<'a>( - arena: &'a Arena>, + arena: &'a Arena<'a>, new_date: &str, sections: IndexMap>>>, ) -> &'a AstNode<'a> { @@ -228,7 +232,7 @@ pub fn extract_sections<'a>( 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) { + if sections.contains(&text.to_string()) { let mut content = Vec::new(); let mut following = node.following_siblings(); let _ = following.next().unwrap(); @@ -272,10 +276,11 @@ pub fn process_doc_tree<'a>(root: &'a AstNode<'a>, new_date: &str, sections: &Ve 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) { + let text_mut = text.to_mut(); + text_mut.clear(); + text_mut.push_str("Today's tasks "); + text_mut.push_str(new_date); + } else if !sections.contains(&text.to_string()) { remove_heading(node, heading.level); }; } @@ -290,8 +295,9 @@ pub fn process_doc_tree<'a>(root: &'a AstNode<'a>, new_date: &str, sections: &Ve #[cfg(test)] mod test { use super::*; - use crate::todo::{Status, Task}; - use std::io::BufWriter; + use crate::todo::Status; + use crate::todo::Task; + use comrak::format_commonmark; #[test] fn test_extract_sections() { @@ -487,16 +493,16 @@ mod test { "Last".to_string(), ]; let arena = Arena::new(); - let mut extension_options = ExtensionOptions::default(); + let mut extension_options = Extension::default(); extension_options.tasklist = true; - let mut parse_options = ParseOptions::default(); + let mut parse_options = Parse::default(); parse_options.relaxed_tasklist_matching = true; - let options = &ComrakOptions { + let options = &Options { extension: extension_options, parse: parse_options, - ..ComrakOptions::default() + ..Options::default() }; let ast = parse_document(&arena, md, options); @@ -507,12 +513,10 @@ mod test { process_doc_tree(ast, new_date, &groups); - let mut output = BufWriter::new(Vec::new()); + let mut output = String::new(); //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 @@ -533,7 +537,7 @@ mod test { ## Last ", - text + output ); } } diff --git a/src/main.rs b/src/main.rs index ef48118..e618b03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,19 +8,19 @@ use chrono::naive::NaiveDate; use chrono::{Datelike, Local, TimeDelta}; use clap::Parser; use cli::Args; -use comrak::{format_commonmark, Arena, ComrakOptions, ExtensionOptions, ParseOptions}; +use comrak::options::{Extension, Parse}; +use comrak::{format_commonmark, Arena, Options}; 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}; +use crate::file::{extract_sections, process_doc_tree}; fn main() { // setup @@ -124,16 +124,16 @@ 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(); + let mut extension_options = Extension::default(); extension_options.tasklist = true; - let mut parse_options = ParseOptions::default(); + let mut parse_options = Parse::default(); parse_options.relaxed_tasklist_matching = true; - let options = &ComrakOptions { + let options = &Options { extension: extension_options, parse: parse_options, - ..ComrakOptions::default() + ..Options::default() }; let sections = &cfg.sections; log::info!("looking for sections: {:?}", sections); @@ -160,13 +160,12 @@ fn main() { process_doc_tree(root, &date, §ions); - let mut new_content = BufWriter::new(Vec::new()); + let mut new_content = String::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, &text.expect("")); + file::write_file(&file_path, &new_content); // return file name file_path } diff --git a/src/todo/tasks.rs b/src/todo/tasks.rs index 90d1053..8930a24 100644 --- a/src/todo/tasks.rs +++ b/src/todo/tasks.rs @@ -1,7 +1,6 @@ use std::borrow::Borrow; -use comrak::nodes::AstNode; -use comrak::nodes::NodeValue; +use comrak::nodes::{AstNode, NodeTaskItem, NodeValue}; #[derive(Debug, Clone, PartialEq)] pub struct TaskGroup { @@ -46,7 +45,7 @@ impl Task { for child in node.children() { let child_data_ref = child.data.borrow(); let t = match &child_data_ref.borrow().value { - NodeValue::Text(contents) => contents.clone(), + NodeValue::Text(contents) => contents.to_string(), NodeValue::Emph if child.first_child().is_some() => { format!("*{}*", Self::extract_text(child.first_child().unwrap())?) } @@ -101,8 +100,14 @@ impl<'a> TryFrom<&'a AstNode<'a>> for Task { .ok_or(TaskError::ParsingError("No childern of node found"))?, )?; let status = match ch { - Some(c) if c == 'x' || c == 'X' => Status::Done(c), - Some(c) => Status::Todo(c), + NodeTaskItem { + symbol: Some(c), + symbol_sourcepos: _, + } if c == 'x' || c == 'X' => Status::Done(c), + NodeTaskItem { + symbol: Some(c), + symbol_sourcepos: _, + } => Status::Todo(c), _ => Status::Empty, }; let subtasks = node