command overhaul, finished core features, made it modular

This commit is contained in:
əlemi 2022-03-14 03:41:26 +01:00
parent ec1e1bf586
commit 0fce795d3a
No known key found for this signature in database
GPG key ID: BBCBFE5D7244634E
4 changed files with 278 additions and 112 deletions

View file

@ -6,6 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
argparse = "0.2.2" regex = "1"
chrono = "0.4.19" chrono = "0.4.19"
clap = { version = "3.1.6", features = ["derive"] }
rusqlite = { version="0.27.0", features=["chrono"] } rusqlite = { version="0.27.0", features=["chrono"] }

View file

@ -1,128 +1,104 @@
extern crate argparse; mod storage;
mod utils;
use argparse::{ArgumentParser, Store, StoreTrue}; use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc}; use clap::{Parser, Subcommand};
use rusqlite::{params, Connection, Error}; use regex::Regex;
pub use storage::{open_sqlite_storage, Memo, MemoStorage};
use utils::{parse_human_duration, HumanDisplay};
struct Memo { #[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<Commands>,
#[clap(short, long, help = "location for database file")]
db_path: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
#[clap(trailing_var_arg = true)]
New {
#[clap(multiple_values = true)]
#[clap(min_values = 1)]
#[clap(required = true)]
body: Vec<String>,
#[clap(short, long, help = "due time relative to now")]
due: Option<String>,
},
Done {
search: String,
#[clap(long)]
many: bool,
},
Del {
id: u32, id: u32,
body: String, },
due: DateTime<Utc>,
}
fn init_db(path: &str) -> Result<Connection, Error> {
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<Vec<Memo>, 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();
}
} }
fn main() { fn main() {
let args = Cli::parse();
let home_path = std::env!("HOME").to_string(); 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"; let mut db_path: String = home_path + "/.local/share/memo-cli.db";
{ if let Some(db) = args.db_path {
// this block limits scope of borrows by ap.refer() method db_path = db;
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 version { let storage = open_sqlite_storage(&db_path).unwrap();
const VERSION: &str = env!("CARGO_PKG_VERSION");
println!("memo-cli v{}", VERSION); match args.command {
return; Some(Commands::New { body, due }) => {
let mut due_date: Option<DateTime<Utc>> = None;
if let Some(d) = due {
if d.len() > 0 {
due_date = Some(Utc::now() + parse_human_duration(d.as_str()).unwrap());
} }
}
let connection = let txt = body.join(" ");
init_db(&db_path).unwrap_or_else(|err| panic!("Could not open db : {}", err.to_string())); storage.add(txt.as_str(), due_date).unwrap();
println!("new memo: '{}'", txt);
if memo.len() > 0 { }
connection Some(Commands::Done { search, many }) => {
.execute( let rex = Regex::new(search.as_str());
"INSERT INTO memo (body, due) VALUES (?, ?)", let mut found = false;
params![memo, Utc::now()], let mut to_remove: Option<Memo> = None;
) if let Some(re) = rex.ok() {
.unwrap_or_else(|err| panic!("Could not insert into db : {}", err.to_string())); for memo in storage.all().unwrap() {
println!("New memo : '{}'", memo); 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 { } else {
let zero_duration = Duration::seconds(0); to_remove = Some(memo);
let memos = all(connection) found = true;
.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) { if let Some(rm) = to_remove {
println!("[*] {} : +{} \t[{}]", m.body, delta.pretty(), m.id); storage.del(rm.id).unwrap();
println!("[-] task #{} done", rm.id);
}
} else { } 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());
} }
} }
} }

107
src/storage.rs Normal file
View file

@ -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<DateTime<Utc>>,
}
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<Vec<Memo>, Error>;
fn add(&self, body: &str, due: Option<DateTime<Utc>>) -> Result<(), Error>;
fn del(&self, id: u32) -> Result<bool, Error>;
fn get(&self, id: u32) -> Result<Memo, Error>;
}
// SQLiteStorage
pub struct SQLiteStorage {
conn: Connection,
}
pub fn open_sqlite_storage(path: &str) -> Result<SQLiteStorage, Error> {
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<Vec<Memo>, 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<DateTime<Utc>>) -> 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<bool, Error> {
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<Memo, Error> {
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),
});
})?);
}
}

82
src/utils.rs Normal file
View file

@ -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<Duration, Error> {
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::<i64>().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<T: std::fmt::Display>(arr: &Vec<T>) -> 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;
}