mirror of
https://git.alemi.dev/memo-cli.git
synced 2024-11-21 23:04:50 +01:00
command overhaul, finished core features, made it modular
This commit is contained in:
parent
ec1e1bf586
commit
0fce795d3a
4 changed files with 278 additions and 112 deletions
|
@ -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"] }
|
||||
|
|
198
src/main.rs
198
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<Utc>,
|
||||
#[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>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
#[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,
|
||||
},
|
||||
}
|
||||
|
||||
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<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 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<Memo> = 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
107
src/storage.rs
Normal file
107
src/storage.rs
Normal 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
82
src/utils.rs
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue