use serde_yaml; use serde::ser::{Serialize}; use serde; use chrono::{DateTime, Local}; use chrono::naive::NaiveDateTime; use regex::Regex; use croner; use clap::Parser; fn main() { let flags = Flags::new(); let db = DB::new(flags.path).unwrap(); println!("{}", db.due().to_string()); } #[derive(Debug, Parser)] struct Flags { #[arg(short = 'f', long = "path", default_value="$PTTODO_FILE")] path: String, #[arg(short = 'a', long = "add")] add: Option, #[arg(short = 's', long = "add-schedule")] add_schedule: Option, #[arg(short = 'e', long = "edit", default_value="false")] edit: bool, } impl Flags { fn new() -> Flags { let mut result = Flags::parse(); if result.path.get(..1) == Some("$") { result.path = std::env::var( result.path.get(1..).unwrap() ).expect(format!("'{}' unset", result.path).as_str()); } result } } #[derive(Debug)] pub struct DB(Vec); impl DB { pub fn new(path: String) -> Result { let metadata = match std::fs::metadata(path.clone()) { Ok(v) => Ok(v), Err(msg) => Err(format!("failed to load {}: {}", path, msg)), }?; let mut files = vec![]; if metadata.is_file() { files.push(path.clone()); } else if metadata.is_dir() { match std::fs::read_dir(path.clone()) { Ok(paths) => { files.extend(paths .filter(|x| x.is_ok()) .map(|x| x.unwrap()) .filter(|x| x.metadata().unwrap().is_file()) .map(|x| x.path().display().to_string()) ); Ok(()) }, Err(msg) => Err(format!("failed to read {}: {}", path.clone(), msg)), }?; } let mut result = vec![]; for file in files { let item = TasksAndMetadata::new(file)?; result.push(item); } Ok(DB(result)) } pub fn incomplete(&self) -> Tasks { let mut result = Tasks::new(); for set in &self.0 { result.0.extend(set.tasks.incomplete().0); } result } pub fn due(&self) -> Tasks { let mut result = Tasks::new(); for set in &self.0 { result.0.extend(set.tasks.due().0); } result } } #[cfg(test)] mod test_taskss { use super::*; #[test] fn read_dir_files() { let db = DB::new("./src/testdata/taskss.d/files.d".to_string()).expect("failed to construct from dir of files"); assert_eq!(2, db.0.len()); assert_eq!(2, db.due().len()); assert_eq!(2, db.incomplete().len()); } #[test] fn read_dir_file() { let db = DB::new("./src/testdata/taskss.d/file.d".to_string()).expect("failed to construct from dir of a single file"); assert_eq!(1, db.0.len()); assert_eq!(1, db.due().len()); assert_eq!(1, db.incomplete().len()); } #[test] fn read_single_file() { let db = DB::new("./src/testdata/taskss.d/single_file.yaml".to_string()).expect("failed to construct from single file"); assert_eq!(1, db.0.len()); assert_eq!(1, db.due().len()); assert_eq!(2, db.incomplete().len()); } } #[derive(Debug)] pub struct TasksAndMetadata { tasks: Tasks, file: String, version: TS, } impl TasksAndMetadata { pub fn new(file: String) -> Result { let version = match std::fs::metadata(file.clone()) { Ok(m) => Ok(TS::from_system_time(m.modified().unwrap())), Err(msg) => Err(format!("couldnt get version from {}: {}", file, msg)), }?; match Tasks::from_file(file.clone()) { Ok(tasks) => Ok(TasksAndMetadata{ tasks: tasks, file: file, version: version, }), Err(msg) => Err(msg), } } } #[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct Tasks(Vec); impl Tasks { pub fn new() -> Tasks { Tasks(vec![]) } pub fn from_file(path: String) -> Result { let r = match std::fs::File::open(path.clone()) { Ok(f) => Ok(f), Err(msg) => Err(format!("could not open {}: {}", path, msg)), }?; Tasks::from_reader(r) } pub fn from_reader(r: impl std::io::Read) -> Result { let result = Tasks::_from_reader(r)?; if !result.is_legacy() { return Ok(result); } let mut v2 = Tasks::new(); for k in vec!["todo", "scheduled", "done"] { v2.0.extend( result.0[0] .get_value(k.to_string()) .or(Some(serde_yaml::Value::Null)) .unwrap() .as_sequence() .or(Some(&vec![])) .unwrap() .iter() .map(|v| { Task::from_value(match k { "done" => { let mut t = Task::new(); t.set_value( "_done".to_string(), v.clone(), ); serde_yaml::Value::from(t.0) }, _ => v.clone(), }) }) ); } Ok(v2) } fn _from_reader(mut r: impl std::io::Read) -> Result { let mut buff = String::new(); match r.read_to_string(&mut buff) { Err(msg) => { return Err(format!("failed to read body: {}", msg)); }, _ => {} }; let mut result = Tasks::new(); match serde_yaml::from_str::>(&buff) { Ok(v) => { result.0.extend(v.iter().map(|x| { Task::from_value(x.clone()) })); Ok(result) }, Err(msg) => match Task::from_str(buff) { Ok(t) => { result.0.push(t); Ok(result) }, Err(_) => Err(format!("failed to parse yaml: {}", msg)), }, } } pub fn to_string(&self) -> String { let mut buffer = Vec::new(); let mut serializer = serde_yaml::Serializer::new(&mut buffer); _ = serde_yaml::Value::Sequence( self.0.iter() .map(|x| { let mut x = x.clone(); if x.is_due() { x.unset("ts".to_string()); } x }) .map(|x| { let is = x.get_value("is".to_string()); if x.0.len() == 1 && is.is_some() { return is.unwrap(); } serde_yaml::Value::from(x.0.clone()) }) .map(|x| serde_yaml::Value::from(x.clone())) .collect() ).serialize(&mut serializer) .expect("failed to serialize"); String::from_utf8(buffer).expect("illegal utf8 characters found") } pub fn len(&self) -> usize { self.0.len() } pub fn incomplete(&self) -> Tasks { Tasks(self.0.iter() .filter(|x| !x.is_done()) .map(|x| x.clone()) .collect() ) } pub fn due(&self) -> Tasks { Tasks(self.0.iter() .filter(|x| x.is_due()) .map(|x| x.clone()) .collect() ) } fn is_legacy(&self) -> bool { self.len() == 1 && self.0[0].get_value("done".to_string()).is_some() && self.0[0].get_value("scheduled".to_string()).is_some() && self.0[0].get_value("todo".to_string()).is_some() } } #[cfg(test)] mod test_tasks { use super::*; #[test] fn due() { let tasks = Tasks::from_file("./src/testdata/tasks_due_scheduled_done.yaml".to_string()).expect("failed to open file"); eprintln!("{:?}", tasks); assert_eq!(2, tasks.due().len()); } #[test] fn from_reader_legacy() { let tasks = Tasks::from_reader( std::fs::File::open("./src/testdata/legacy.yaml").expect("failed to open file") ).expect("failed to read file"); assert_eq!(8, tasks.len()); assert_eq!(5, tasks.due().len()); assert_eq!("a".to_string(), tasks.due().0[0].get("is".to_string()).expect("missing 0th is")); assert_eq!("b".to_string(), tasks.due().0[1].get("todo".to_string()).expect("missing 1st todo")); assert_eq!("c".to_string(), tasks.due().0[2].get("is".to_string()).expect("missing 2nd is")); assert_eq!("d".to_string(), tasks.due().0[3].get("todo".to_string()).expect("missing 3rd todo")); assert_eq!("e".to_string(), tasks.due().0[4].get("todo".to_string()).expect("missing 4th todo")); } #[test] fn from_reader() { let tasks = Tasks::from_reader( std::fs::File::open("./src/testdata/mvp.yaml").expect("failed to open file") ).expect("failed to read file"); assert_eq!(2, tasks.0.len()); assert_eq!(1, tasks.0[0].0.len()); assert!(tasks.0[0].get("is".to_string()).is_some()); assert_eq!("x".to_string(), tasks.0[0].get("is".to_string()).unwrap()); assert_eq!(1, tasks.0[1].0.len()); assert_eq!("y and z".to_string(), tasks.0[1].get("is".to_string()).unwrap()); } } #[derive(Debug, serde::Serialize, serde::Deserialize, Clone)] pub struct Task(serde_yaml::Mapping); impl Task { pub fn new() -> Task { Task(serde_yaml::Mapping::new()) } pub fn from_reader(mut r: impl std::io::Read) -> Result { let mut buff = String::new(); match r.read_to_string(&mut buff) { Err(msg) => Err(format!("failed to read body: {}", msg)), _ => Task::from_str(buff), } } pub fn from_str(s: String) -> Result { match serde_yaml::from_str::(&s) { Ok(v) => Ok(Task::from_value(v)), Err(msg) => Err(format!("failed to read value: {}", msg)), } } pub fn from_value(v: serde_yaml::Value) -> Task { let mut result = Task::new(); match v.as_mapping() { Some(m) => { result.0 = m.clone(); }, None => { result.set_value("is".to_string(), v); }, }; result } pub fn is_done(&self) -> bool { self.get_value("_done".to_string()).is_some() } pub fn is_due(&self) -> bool { self.is_due_at(TS::now()) && !self.is_done() } fn is_due_at(&self, now: TS) -> bool { match self.when() { Some(when) => { now.unix() >= when.next(self.ts()).unix() }, None => true, } } fn when(&self) -> Option { match self.get("schedule".to_string()) { Some(v) => match When::new(v) { Ok(when) => Some(when), Err(msg) => { eprintln!("Task.when(): {}", msg); return None; }, }, None => None, } } fn ts(&self) -> TS { match self.get("ts".to_string()) { Some(v) => match TS::new(v) { Ok(ts) => ts, Err(_) => TS::from_unix(0), }, None => TS::from_unix(0), } } fn get(&self, k: String) -> Option { match self.get_value(k) { None => None, Some(v) => match v.as_str() { Some(s) => Some(s.to_string()), None => None, }, } } fn get_value(&self, k: String) -> Option { match self.0.get(k) { Some(v) => Some(v.clone()), None => None, } } fn unset(&mut self, k: String) { self.0.remove(serde_yaml::Value::String(k)); } #[allow(dead_code)] // used in test fn set(&mut self, k: String, v: String) { self.0.insert( serde_yaml::Value::String(k), serde_yaml::Value::String(v), ); } fn set_value(&mut self, k: String, v: serde_yaml::Value) { self.0.insert( serde_yaml::Value::String(k), v ); } } #[cfg(test)] mod test_task { use super::*; #[test] fn from_str() { assert!(Task::from_str("{ invalid".to_string()).is_err()); assert!(Task::from_str("1".to_string()).is_ok()); assert!(Task::from_str("'1'".to_string()).is_ok()); assert!(Task::from_str("null".to_string()).is_ok()); assert!(Task::from_str("true".to_string()).is_ok()); assert!(Task::from_str("[]".to_string()).is_ok()); assert!(Task::from_str("{}".to_string()).is_ok()); } #[test] fn from_reader() { let task = Task::from_reader( std::fs::File::open("./src/testdata/mvp.uuid-123-456-xyz.yaml").expect("failed to open file") ).expect("failed to read 123..."); assert_eq!(1, task.0.len()); assert!(task.get("is".to_string()).is_some()); assert_eq!("plaintext".to_string(), task.get("is".to_string()).unwrap()); let task = Task::from_reader( std::fs::File::open("./src/testdata/mvp.uuid-789-012-abc.yaml").expect("failed to open file") ).expect("failed to read 789..."); assert_eq!(3, task.0.len()); assert!(task.get("is".to_string()).is_none()); assert_eq!("todo here".to_string(), task.get("todo".to_string()).unwrap()); assert_eq!("* * * * *".to_string(), task.get("schedule".to_string()).unwrap()); assert_eq!("hello world\n".to_string(), task.get("details".to_string()).unwrap()); assert!(task.is_due()); } #[test] fn is_done() { let mut t = Task::new(); t.set("_done".to_string(), "anything".to_string()); eprintln!("{:?}", t.0); assert!(t.is_done()); t.set_value("_done".to_string(), serde_yaml::Value::Null); eprintln!("{:?}", t.0); assert!(t.is_done()); } #[test] fn crud() { let mut t = Task::new(); t.set("k".to_string(), "v".to_string()); assert_eq!( t.get("k".to_string()), Some("v".to_string()), ); } #[test] fn is_due_duration() { let mut t = Task::new(); assert!(t.is_due()); let then = TS::new("2000-01-02T15:00Z".to_string()).unwrap(); let now = TS::new("2000-01-02T16:00Z".to_string()).unwrap(); t.set("ts".to_string(), then.to_string()); t.set("schedule".to_string(), "1h".to_string()); assert!(!t.is_due_at(TS::from_unix(now.unix()-1))); assert!(t.is_due_at(TS::from_unix(now.unix()))); assert!(t.is_due_at(TS::from_unix(now.unix()+1))); } #[test] fn is_due_schedule() { let mut t = Task::new(); assert!(t.is_due()); let then = TS::new("2000-01-02T15:00Z".to_string()).unwrap(); let now = TS::new("2000-01-02T16:00Z".to_string()).unwrap(); t.set("ts".to_string(), then.to_string()); t.set("schedule".to_string(), "2000-01-02T16:00Z".to_string()); assert!(!t.is_due_at(TS::from_unix(now.unix()-1))); assert!(t.is_due_at(TS::from_unix(now.unix()))); assert!(t.is_due_at(TS::from_unix(now.unix()+1))); } #[test] fn is_due_cron() { let mut t = Task::new(); assert!(t.is_due()); let then = TS::new("2000-01-02T15:00Z".to_string()).unwrap(); let now = TS::new("2000-01-02T16:00Z".to_string()).unwrap(); t.set("ts".to_string(), then.to_string()); t.set("schedule".to_string(), "0 16 * * *".to_string()); assert!(!t.is_due_at(TS::from_unix(now.unix()-1))); assert!(t.is_due_at(TS::from_unix(now.unix()))); assert!(t.is_due_at(TS::from_unix(now.unix()+1))); } } #[derive(Debug)] struct When(String); impl When { fn new(src: String) -> Result { match Self::new_duration(src.clone()) { Some(x) => { return Ok(x); }, None => {}, }; match Self::new_ts(src.clone()) { Some(x) => { return Ok(x); }, None => {}, }; match Self::new_cron(src.clone()) { Some(x) => { return Ok(x); }, None => {}, }; Err(format!("cannot parse when: {}", src)) } fn new_duration(src: String) -> Option { match Duration::new(src.clone()) { Ok(_) => Some(When{0: src.clone()}), _ => None, } } fn new_ts(src: String) -> Option { match TS::new(src.clone()) { Ok(_) => Some(When{0: src.clone()}), _ => None, } } fn new_cron(src: String) -> Option { match Cron::new(src.clone()) { Ok(_) => Some(When{0: src.clone()}), _ => None, } } fn next(&self, now: TS) -> TS { match Duration::new(self.0.clone()) { Ok(duration) => { return TS::from_unix( now.unix() + duration.0 ); }, _ => {}, }; match TS::new(self.0.clone()) { Ok(ts) => { return ts; }, _ => {}, }; match Cron::new(self.0.clone()) { Ok(x) => { return x.next(now); }, _ => {}, }; assert!(false, "invalid when cooked"); now } } #[cfg(test)] mod test_when { use super::*; #[test] fn parse() { match When::new("1d2h3m".to_string()) { Ok(when) => { assert_eq!( 1714521600 + 60*3 + 60*60*2 + 60*60*24*1, when.next( TS::new("2024-05-01T00".to_string()) .unwrap() ).unix() ); }, Err(err) => assert!(false, "failed to parse when: {}", err), }; match When::new("2024-05-01T00:00Z".to_string()) { Ok(when) => { assert_eq!( 1714521600 , when.next( TS::new("2024-05-01T00".to_string()) .unwrap() ).unix() ); }, Err(err) => assert!(false, "failed to parse when: {}", err), }; match When::new("0 1 * * *".to_string()) { Ok(when) => { assert_eq!( 1714521600 + 60*60, when.next(TS::from_unix(1714521600)).unix() ); }, Err(err) => assert!(false, "failed to parse when: {}", err), }; } } #[derive(Debug)] struct Cron(String); impl Cron { fn new(src: String) -> Result { match croner::Cron::new(src.as_str()).parse() { Ok(_) => Ok(Cron{0: src}), Err(msg) => Err(format!("bad cron: {}", msg)), } } fn next(&self, now: TS) -> TS { match croner::Cron::new(self.0.as_str()).parse() { Ok(c) => match c.find_next_occurrence( &DateTime::from_timestamp(now.unix() as i64, 0).unwrap(), true, ) { Ok(dt) => { return TS::from_unix(dt.timestamp() as u64); }, Err(_) => TS::from_unix(0), }, _ => TS::from_unix(0), } } } #[cfg(test)] mod test_cron { use super::*; #[test] fn parse() { match Cron::new("* * * * *".to_string()) { Ok(_) => {} Err(err) => assert!(false, "failed to parse cron: {}", err), }; match Cron::new("1 * * * *".to_string()) { Ok(c) => assert_eq!(1714525200+60, c.next(TS::from_unix(1714525200)).unix()), Err(err) => assert!(false, "failed to parse cron: {}", err), }; } } #[derive(Debug)] struct Duration(u64); impl Duration { fn new(src: String) -> Result { if src.len() == 0 { return Err("no empty duration".to_string()); } let duration = Regex::new(r"^([0-9]+d)?([0-9]+h)?([0-9]+m)?$").unwrap(); match duration.is_match(&src) { false => { return Err("ill formatted duration".to_string()); }, _ => {}, }; let caps = duration.captures(&src).unwrap(); let mut sum: u64 = 0; match caps.get(1) { Some(d) => { sum += 60 * 60 * 24 * Self::to_n(d.as_str()); }, _ => {}, }; match caps.get(2) { Some(h) => { sum += 60 * 60 * Self::to_n(h.as_str()); }, _ => {}, }; match caps.get(3) { Some(m) => { sum += 60 * Self::to_n(m.as_str()); }, _ => {}, }; Ok(Duration{0: sum}) } fn to_n(s: &str) -> u64 { let s = s.to_string(); let (s, _) = s.split_at(s.len()-1); match s.parse::() { Ok(n) => n, _ => 0, } } } #[cfg(test)] mod test_duration { use super::*; #[test] fn parse() { match Duration::new("1d2h3m".to_string()) { Ok(d) => assert_eq!(60*60*24 + 60*60*2 + 60*3, d.0), Err(err) => assert!(false, "failed to parse duration: {}", err), }; match Duration::new("1d".to_string()) { Ok(d) => assert_eq!(60*60*24, d.0), Err(err) => assert!(false, "failed to parse duration: {}", err), }; match Duration::new("2h".to_string()) { Ok(d) => assert_eq!(60*60*2, d.0), Err(err) => assert!(false, "failed to parse duration: {}", err), }; match Duration::new("3m".to_string()) { Ok(d) => assert_eq!(60*3, d.0), Err(err) => assert!(false, "failed to parse duration: {}", err), }; } } #[derive(Debug)] struct TS(u64); impl TS { fn now() -> TS { Self::from_unix(Local::now().timestamp() as u64) } fn from_system_time(st: std::time::SystemTime) -> TS { TS::from_unix(st.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_secs()) } fn from_unix(src: u64) -> TS { TS{0: src} } fn new(src: String) -> Result { // %Y-%m-%dT%H:%MZ match DateTime::parse_from_str( &format!("{} +0000", src), "%Y-%m-%dT%H:%MZ %z", ) { Ok(v) => { return Ok(TS(v.timestamp() as u64)) }, _ => {}, }; // %Y-%m-%dT%H match NaiveDateTime::parse_from_str( &format!("{}:00", src), "%Y-%m-%dT%H:%M", ) { Ok(v) => { return Ok(TS(v.and_utc().timestamp() as u64)) }, _ => {}, }; // %Y-%m-%d match NaiveDateTime::parse_from_str( &format!("{}T00:00", src), "%Y-%m-%dT%H:%M", ) { Ok(v) => { return Ok(TS(v.and_utc().timestamp() as u64)) }, _ => {}, }; // Sun Dec 3 23:29:27 EST 2023 match DateTime::parse_from_str( &format!("{}", src) .replace("PDT", "-0800") .replace("MDT", "-0700") .replace("EDT", "-0400") .replace("PST", "-0700") .replace("MST", "-0600") .replace("EST", "-0500") .replace(" 1 ", " 01 ") .replace(" 2 ", " 02 ") .replace(" 3 ", " 03 ") .replace(" 4 ", " 04 ") .replace(" 5 ", " 05 ") .replace(" 6 ", " 06 ") .replace(" 7 ", " 07 ") .replace(" 8 ", " 08 ") .replace(" 9 ", " 09 ") .replace(" ", " "), "%a %b %d %H:%M:%S %z %Y", ) { Ok(v) => Ok(TS(v.timestamp() as u64)), Err(msg) => Err(format!("failed to parse legacy golang time: {}", msg)), } } fn unix(&self) -> u64 { self.0 } #[allow(dead_code)] // used in test fn to_string(&self) -> String { DateTime::from_timestamp(self.0 as i64, 0) .unwrap() .format("%Y-%m-%dT%H:%MZ") .to_string() } } #[cfg(test)] mod test_ts { use super::*; #[test] fn parse() { match TS::new("Tue Nov 7 07:33:11 PST 2023".to_string()) { Ok(ts) => { assert_eq!("2023-11-07T14:33Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("Sun Jun 4 05:24:32 PDT 2023".to_string()) { Ok(ts) => { assert_eq!("2023-06-04T13:24Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("Sat Nov 4 08:36:01 MDT 2023".to_string()) { Ok(ts) => { assert_eq!("2023-11-04T15:36Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("Sun Nov 5 22:27:17 MST 2023".to_string()) { Ok(ts) => { assert_eq!("2023-11-06T04:27Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("Thu Apr 18 16:17:41 EDT 2024".to_string()) { Ok(ts) => { assert_eq!("2024-04-18T20:17Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("Sun Dec 3 23:29:27 EST 2023".to_string()) { Ok(ts) => { assert_eq!("2023-12-04T04:29Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("2024-05-01T00:00Z".to_string()) { Ok(ts) => { assert_eq!(1714521600, ts.unix()); assert_eq!("2024-05-01T00:00Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("2024-05-01T00".to_string()) { Ok(ts) => { assert_eq!(1714521600, ts.unix()); assert_eq!("2024-05-01T00:00Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; match TS::new("2024-05-01".to_string()) { Ok(ts) => { assert_eq!(1714521600, ts.unix()); assert_eq!("2024-05-01T00:00Z", ts.to_string()); }, Err(err) => assert!(false, "failed to parse ts: {}", err), }; } }