diff --git a/Cargo.toml b/Cargo.toml index 391a7e5..49def5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -argparse = "0.2.2" +regex = "1" chrono = "0.4.19" +clap = { version = "3.1.6", features = ["derive"] } rusqlite = { version="0.27.0", features=["chrono"] } diff --git a/src/main.rs b/src/main.rs index eb36fa6..fe087b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,128 +1,104 @@ -extern crate argparse; +mod storage; +mod utils; -use argparse::{ArgumentParser, Store, StoreTrue}; -use chrono::{DateTime, Duration, Utc}; -use rusqlite::{params, Connection, Error}; +use chrono::{DateTime, Utc}; +use clap::{Parser, Subcommand}; +use regex::Regex; +pub use storage::{open_sqlite_storage, Memo, MemoStorage}; +use utils::{parse_human_duration, HumanDisplay}; -struct Memo { - id: u32, - body: String, - due: DateTime, +#[derive(Parser)] +#[clap(author, version, about, long_about = None)] +#[clap(propagate_version = true, disable_colored_help = true)] +#[clap(subcommand_required = false)] +#[clap(disable_help_subcommand = true)] +struct Cli { + #[clap(subcommand)] + command: Option, + + #[clap(short, long, help = "location for database file")] + db_path: Option, } -fn init_db(path: &str) -> Result { - let connection = Connection::open(path)?; - - connection.execute( - "CREATE TABLE IF NOT EXISTS memo ( - id INTEGER PRIMARY KEY, - body TEXT NOT NULL, - due DATETIME - );", - [], - )?; - - return Ok(connection); -} - -fn all(conn: Connection) -> Result, rusqlite::Error> { - let mut statement = conn.prepare("SELECT * FROM memo ORDER BY due, id")?; - let mut rows = statement.query([])?; - - let mut results = Vec::new(); - - while let Some(row) = rows.next()? { - results.push(Memo { - id: row.get(0)?, - body: row.get(1)?, - due: row.get(2)?, - }); - } - - return Ok(results); -} - -pub trait PrettyPrint { - fn pretty(&self) -> String; -} - -impl PrettyPrint for Duration { - fn pretty(&self) -> String { - if self.num_days().abs() > 400 { - return format!("{}y {}m", self.num_days().abs() / 365, (self.num_days() % (30*12)) / 30); - } else if self.num_days().abs() >= 365 { - return format!("{}y", self.num_days().abs() / 365); - } else if self.num_days().abs() >= 90 { - return format!("{}m", self.num_days().abs() / 30); // sort of - } else if self.num_days().abs() >= 1 { - return format!("{}d", self.num_days().abs()); - } else if self.num_hours().abs() > 0 { - let h = self.num_minutes().abs() / 60; - let m = self.num_minutes().abs() % 60; - return format!("{}h {}min", h, m); - } else if self.num_minutes().abs() > 0 { - return format!("{}min", self.num_minutes().abs()); - } else if self.num_seconds().abs() > 0 { - return format!("{}s", self.num_seconds().abs()); - } - return self.to_string(); - } +#[derive(Subcommand)] +enum Commands { + #[clap(trailing_var_arg = true)] + New { + #[clap(multiple_values = true)] + #[clap(min_values = 1)] + #[clap(required = true)] + body: Vec, + #[clap(short, long, help = "due time relative to now")] + due: Option, + }, + Done { + search: String, + #[clap(long)] + many: bool, + }, + Del { + id: u32, + }, } fn main() { + let args = Cli::parse(); let home_path = std::env!("HOME").to_string(); - if home_path.len() < 1 { - panic!("Cannot work without a home folder"); - } - - let mut version = false; - let mut memo: String = "".to_string(); let mut db_path: String = home_path + "/.local/share/memo-cli.db"; - { - // this block limits scope of borrows by ap.refer() method - let mut ap = ArgumentParser::new(); - ap.set_description("A simple tool to remember things"); - ap.refer(&mut version).add_option( - &["-V", "--version"], - StoreTrue, - "Show current version and die", - ); - ap.refer(&mut memo) - .add_option(&["-n", "--new"], Store, "Add a new memo"); - ap.refer(&mut db_path) - .add_option(&["--path"], Store, "Specify db path"); - - ap.parse_args_or_exit(); + if let Some(db) = args.db_path { + db_path = db; } - if version { - const VERSION: &str = env!("CARGO_PKG_VERSION"); - println!("memo-cli v{}", VERSION); - return; - } + let storage = open_sqlite_storage(&db_path).unwrap(); - let connection = - init_db(&db_path).unwrap_or_else(|err| panic!("Could not open db : {}", err.to_string())); - - if memo.len() > 0 { - connection - .execute( - "INSERT INTO memo (body, due) VALUES (?, ?)", - params![memo, Utc::now()], - ) - .unwrap_or_else(|err| panic!("Could not insert into db : {}", err.to_string())); - println!("New memo : '{}'", memo); - } else { - let zero_duration = Duration::seconds(0); - let memos = all(connection) - .unwrap_or_else(|err| panic!("Could not read all memos : {}", err.to_string())); - for m in memos { - let delta = m.due - Utc::now(); - if delta.le(&zero_duration) { - println!("[*] {} : +{} \t[{}]", m.body, delta.pretty(), m.id); + match args.command { + Some(Commands::New { body, due }) => { + let mut due_date: Option> = None; + if let Some(d) = due { + if d.len() > 0 { + due_date = Some(Utc::now() + parse_human_duration(d.as_str()).unwrap()); + } + } + let txt = body.join(" "); + storage.add(txt.as_str(), due_date).unwrap(); + println!("new memo: '{}'", txt); + } + Some(Commands::Done { search, many }) => { + let rex = Regex::new(search.as_str()); + let mut found = false; + let mut to_remove: Option = None; + if let Some(re) = rex.ok() { + for memo in storage.all().unwrap() { + if re.is_match(memo.body.as_str()) { + if many { + storage.del(memo.id).unwrap(); + println!("[-] task #{} done", memo.id); + } else if found { + println!("[!] would remove multiple tasks"); + to_remove = None; + break; + } else { + to_remove = Some(memo); + found = true; + } + } + } + if let Some(rm) = to_remove { + storage.del(rm.id).unwrap(); + println!("[-] task #{} done", rm.id); + } } else { - println!(" * {} : -{} \t[{}]", m.body, delta.pretty(), m.id); + println!("[!] invalid regex"); + } + } + Some(Commands::Del { id }) => { + storage.del(id).unwrap(); + println!("[-] task #{} deleted", id); + } + None => { + for m in storage.all().unwrap() { + println!("{}", m.human()); } } } diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..ce97ca0 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,107 @@ +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Error}; +use std::fmt; + +pub struct Memo { + pub id: u32, + pub body: String, + pub due: Option>, +} + +impl fmt::Display for Memo { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut due_str = "null".to_string(); + if self.due.is_some() { + due_str = self.due.unwrap().to_string(); + } + return write!( + f, + "Memo(id={id}, body={body}, due={due})", + id = self.id, + body = self.body, + due = due_str, + ); + } +} + +pub trait MemoStorage { + fn all(&self) -> Result, Error>; + fn add(&self, body: &str, due: Option>) -> Result<(), Error>; + fn del(&self, id: u32) -> Result; + fn get(&self, id: u32) -> Result; +} + +// SQLiteStorage + +pub struct SQLiteStorage { + conn: Connection, +} + +pub fn open_sqlite_storage(path: &str) -> Result { + let connection = Connection::open(path)?; + // TODO check if table exist and is valid + connection.execute( + "CREATE TABLE IF NOT EXISTS memo ( + id INTEGER PRIMARY KEY, + body TEXT NOT NULL, + due DATETIME + );", + [], + )?; + return Ok(SQLiteStorage { conn: connection }); +} + +impl MemoStorage for SQLiteStorage { + fn all(&self) -> Result, Error> { + let mut statement = self.conn.prepare("SELECT * FROM memo ORDER BY due, id")?; + let mut rows = statement.query([])?; + let mut results = Vec::new(); + + while let Some(row) = rows.next()? { + results.push(Memo { + id: row.get(0)?, + body: row.get(1)?, + due: row.get(2)?, + }); + } + + return Ok(results); + } + + fn add(&self, body: &str, due: Option>) -> Result<(), Error> { + // TODO join these 2 ifs? + if due.is_some() { + self.conn.execute( + "INSERT INTO memo (body, due) VALUES (?, ?)", + params![body, due], + )?; + } else { + self.conn + .execute("INSERT INTO memo (body) VALUES (?)", params![body])?; + } + return Ok(()); + } + + fn del(&self, id: u32) -> Result { + let count = self + .conn + .execute("DELETE FROM memo WHERE id = ?", params![id])?; + if count > 0 { + return Ok(true); + } else { + return Ok(false); + } + } + + fn get(&self, id: u32) -> Result { + return Ok(self + .conn + .query_row("SELECT * FROM memo WHERE id = ?", params![id], |row| { + return Ok(Memo { + id: row.get(0)?, + body: row.get(1)?, + due: row.get(2).unwrap_or(None), + }); + })?); + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..cf0813a --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,82 @@ +use crate::storage::Memo; +use chrono::{Duration, Utc}; +use regex::{Error, Regex}; + +pub trait HumanDisplay { + fn human(&self) -> String; +} + +impl HumanDisplay for Duration { + fn human(&self) -> String { + if self.num_days().abs() > 400 { + return format!( + "{}y {}m", + self.num_days().abs() / 365, + (self.num_days() % (30 * 12)) / 30 + ); + } else if self.num_days().abs() >= 365 { + return format!("{}y", self.num_days().abs() / 365); + } else if self.num_days().abs() >= 90 { + return format!("{}m", self.num_days().abs() / 30); // sort of + } else if self.num_days().abs() >= 1 { + return format!("{}d", self.num_days().abs()); + } else if self.num_hours().abs() > 0 { + let h = self.num_minutes().abs() / 60; + let m = self.num_minutes().abs() % 60; + return format!("{}h {}min", h, m); + } else if self.num_minutes().abs() > 0 { + return format!("{}min", self.num_minutes().abs()); + } else if self.num_seconds().abs() > 0 { + return format!("{}s", self.num_seconds().abs()); + } + return self.to_string(); + } +} + +impl HumanDisplay for Memo { + fn human(&self) -> String { + if self.due.is_some() { + let delta = self.due.unwrap() - Utc::now(); + if delta.le(&Duration::seconds(0)) { + return format!("[*] {} (+{})", self.body, delta.human()); + } else { + return format!(" * {} (-{})", self.body, delta.human()); + } + } else { + return format!(" * {}", self.body); + } + } +} + +pub fn parse_human_duration(input: &str) -> Result { + let mut secs: i64 = 0; + println!("{}", input); + + for (token, mult) in [("d", 60 * 60 * 24), ("h", 60 * 60), ("m", 60)] { + let re = Regex::new(format!("((?:-|)[0-9]+){token}", token = token).as_str())?; + if let Some(captures) = re.captures(input) { + if let Some(cap) = captures.get(1) { + secs += mult * cap.as_str().parse::().unwrap(); // safely unwrap because regex gave only digits + } + } + } + + return Ok(Duration::seconds(secs)); +} + +// TODO is it possible to make this a method? +pub fn vec_to_str(arr: &Vec) -> String { + let mut out = String::default(); + let mut first = true; + out += "["; + for el in arr { + if !first { + out += ","; + } + out += " "; + out += el.to_string().as_mut_str(); + first = false; + } + out += " ]"; + return out; +}