use chrono::{self, Datelike, Duration, Local, Month, NaiveDate, Weekday}; use clap::Parser; use ron; use serde::{Deserialize, Serialize}; use std::{ collections::HashSet, fmt::Write as _, fs::OpenOptions, io::{BufRead, BufReader, Read, Seek, Write as _}, path::PathBuf, }; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { notes: PathBuf, patterns: Option, } #[derive(Serialize, Deserialize, Debug)] enum Pattern { MonthlyOpt { dom: u32, months: Vec }, Monthly { dom: u32 }, Weekly { dow: Weekday }, Daily, } #[derive(Serialize, Deserialize, Debug)] struct Position { name: String, pattern: Pattern, } fn month_from_date(date: NaiveDate) -> Month { return Month::try_from( u8::try_from(date.month()) .expect("This should never happen: month into u8 should never fail"), ) .expect("This should never happen: month from u8 that is a month should never fail"); } fn is_todo(bytes: &[u8]) -> bool { return bytes == b"- [ ]" // done || bytes == b"- [r]" // tried unsucessfully, have to retry || bytes == b"- [p]"; // Made progress, but not done } fn is_done(bytes: &[u8]) -> bool { return bytes == b"- [x]" // done || bytes == b"- [n]" // no || bytes == b"- [i]" // into issue || bytes == b"- [d]" // someone else did it / took "assignment" (spiritual or actually in Gitea) || bytes == b"- [m]" // moved to a later, specified time || bytes == b"- [c]" // i will come back to this when i feel like it, but no need to track it now || bytes == b"- [q]"; // deprecated / morphed into another todo } fn update_notes(notes_path: &PathBuf, patterns_path: Option) { let mut notes_file = OpenOptions::new() .read(true) .write(true) .open(notes_path) .expect("file should exist"); let mut notes_bufreader = BufReader::new(¬es_file); let mut str_buf1 = String::new(); let mut str_buf2 = String::new(); let mut latest_date = NaiveDate::MIN; let mut lines_to_not_use: Vec = Vec::new(); // Find todo-type lines let mut n_lines = 0; while let Ok(n) = notes_bufreader.read_line(&mut str_buf1) { if n == 0 { break; } if str_buf1 == "\n" { str_buf1.clear(); continue; } let trimmed_str = str_buf1.trim(); let trimmed_bytes = trimmed_str.as_bytes(); let Some(prefix) = trimmed_bytes.get(0..5) else { continue; }; if is_todo(prefix) || is_done(prefix) { str_buf2.push_str(&str_buf1); n_lines += 1; } else if trimmed_bytes.get(0) == Some(&b'#') { if let Some(header_cont) = trimmed_str.get(1..) && let Ok(date) = NaiveDate::parse_from_str(header_cont, "%Y-%m-%d") { if date > latest_date { latest_date = date; } } } str_buf1.clear(); } let mut todos_hashset: HashSet<&str> = HashSet::new(); let split_lines = str_buf2.rsplit("\n"); for (rev_idx, line) in split_lines.enumerate() { let org_idx = n_lines - rev_idx; let trimmed_str = line.trim(); let trimmed_bytes = trimmed_str.as_bytes(); let Some(prefix) = trimmed_bytes.get(0..5) else { continue; }; let Some(todotext) = trimmed_str.get(6..) else { continue; }; if !todos_hashset.insert(todotext) || is_done(prefix) { lines_to_not_use.push(org_idx); } } // line break as needed notes_bufreader.seek(std::io::SeekFrom::End(-2)).unwrap_or_else(|e| panic!("file is shorter than two characters. you dont need a todo program for that amount of todos, enjoy your life. Error is: {e}")); let mut last_two_bytes = [0u8, 0u8]; notes_bufreader.read_exact(&mut last_two_bytes).unwrap_or_else(|e| panic!("file is shorter than two characters. you dont need a todo program for that amount of todos, enjoy your life. Error is: {e}")); if last_two_bytes != *b"\n\n" { write!(str_buf1, "\n").unwrap_or_else(|e| panic!("{e}")); } // Header line let today = Local::now().date_naive(); if latest_date < today { writeln!(str_buf1, "# {}\n", today).unwrap_or_else(|e| panic!("{e}")); } else { println!("Come back tomorrow!"); // TODO Handle this return; } // Add patternized todos if let Some(patterns) = patterns_path { let patterns_file = OpenOptions::new() .read(true) .open(patterns) .expect("file should exist"); let notes_bufreader = BufReader::new(&patterns_file); let positions: Vec = ron::de::from_reader(notes_bufreader).expect("File should be of correct ron syntax"); for p in &positions { match p.pattern { Pattern::Monthly { dom } => { let mut date_idx = latest_date.clone() + Duration::days(1); while date_idx <= today { if date_idx.day() == dom { writeln!(str_buf1, "- [ ] {}", &p.name) .unwrap_or_else(|e| panic!("{e}")); break; } date_idx += Duration::days(1); } } Pattern::MonthlyOpt { dom, ref months } => { let mut date_idx = latest_date.clone() + Duration::days(1); while date_idx <= today { if date_idx.day() == dom && months.contains(&month_from_date(date_idx)) { writeln!(str_buf1, "- [ ] {}", &p.name) .unwrap_or_else(|e| panic!("{e}")); break; } date_idx += Duration::days(1); } } Pattern::Weekly { dow } => { dbg!(dow, today); let mut date_idx = latest_date.clone() + Duration::days(1); while date_idx <= today { dbg!(date_idx); if date_idx.weekday() == dow { writeln!(str_buf1, "- [ ] {}", &p.name) .unwrap_or_else(|e| panic!("{e}")); break; } date_idx += Duration::days(1); } } Pattern::Daily => { writeln!(str_buf1, "- [ ] {}", &p.name).unwrap_or_else(|e| panic!("{e}")); } } } } // Add todos from prev days let mut cur = lines_to_not_use.len() - 1; for (idx, line) in str_buf2.split("\n").enumerate() { if lines_to_not_use.get(cur).unwrap() == &idx { cur = cur.saturating_sub(1); } else { writeln!(str_buf1, "{line}").unwrap_or_else(|e| panic!("{e}")); } } str_buf2.clear(); notes_file .write_all(str_buf1.as_bytes()) .unwrap_or_else(|e| panic!("Maaan look out for these files. Error is: {e}")); } fn main() { let args = Args::parse(); if args.patterns == None { dbg!("Warning: No patterns argument given. Will not generate any pattern-type todos"); } update_notes(&args.notes, args.patterns); }