From 7873d99df51762c795589da66348e21bb74cfb5f Mon Sep 17 00:00:00 2001 From: Matthias Beyer Date: Sun, 14 Jul 2019 12:28:17 +0200 Subject: [PATCH] Reimplement imag-todo Parts of this commit were written by Leon, but in the process we needed to squash. Here's his original commit message: > Change todo listing behaviours > > This commit changes the todo binary to have the following behaviour: > - `imag-todo`: Print all non-hidden pending todos > - `imag-todo pending`: Print all non-hidden pending todos > - `imag-todo list`: Print all non-hidden non-done todos > - `--done`: Include done > - `--no-pending`: Exclude pending > > Each and every command respects the hidden attribute only on the view > layer, but still pipes the hidden entries to stdout. > > Internally, this introduces a black- and whitelist todo-state matcher, > that can be configured to match only certain todos and thereby > improves reusability of functions over the domain binary. Signed-off-by: Leon Schuermann Signed-off-by: Matthias Beyer --- bin/domain/imag-todo/Cargo.toml | 16 +- bin/domain/imag-todo/etc/on-add.sh | 4 - bin/domain/imag-todo/etc/on-modify.sh | 4 - bin/domain/imag-todo/src/lib.rs | 439 ++++++++++++++++++++------ bin/domain/imag-todo/src/ui.rs | 183 +++++++++-- 5 files changed, 507 insertions(+), 139 deletions(-) delete mode 100644 bin/domain/imag-todo/etc/on-add.sh delete mode 100644 bin/domain/imag-todo/etc/on-modify.sh diff --git a/bin/domain/imag-todo/Cargo.toml b/bin/domain/imag-todo/Cargo.toml index 12a2719a..f1e8dc91 100644 --- a/bin/domain/imag-todo/Cargo.toml +++ b/bin/domain/imag-todo/Cargo.toml @@ -1,5 +1,5 @@ [package] -authors = ["mario "] +authors = ["Matthias Beyer "] name = "imag-todo" version = "0.10.0" @@ -25,10 +25,18 @@ toml = "0.5.1" toml-query = "0.9.2" is-match = "0.1.0" failure = "0.1.5" +chrono = "0.4" +filters = "0.3" +kairos = "0.3" +resiter = "0.4.0" -libimagrt = { version = "0.10.0", path = "../../../lib/core/libimagrt" } -libimagerror = { version = "0.10.0", path = "../../../lib/core/libimagerror" } -libimagtodo = { version = "0.10.0", path = "../../../lib/domain/libimagtodo" } +libimagrt = { version = "0.10.0", path = "../../../lib/core/libimagrt" } +libimagstore = { version = "0.10.0", path = "../../../lib/core/libimagstore" } +libimagerror = { version = "0.10.0", path = "../../../lib/core/libimagerror" } +libimagentryedit = { version = "0.10.0", path = "../../../lib/entry/libimagentryedit" } +libimagtodo = { version = "0.10.0", path = "../../../lib/domain/libimagtodo" } +libimagutil = { version = "0.10.0", path = "../../../lib/etc/libimagutil" } +libimagentryview = { version = "0.10.0", path = "../../../lib/entry/libimagentryview" } [dependencies.clap] version = "2.33.0" diff --git a/bin/domain/imag-todo/etc/on-add.sh b/bin/domain/imag-todo/etc/on-add.sh deleted file mode 100644 index a58e4989..00000000 --- a/bin/domain/imag-todo/etc/on-add.sh +++ /dev/null @@ -1,4 +0,0 @@ -#/!usr/bin/env bash - -imag todo tw-hook --add - diff --git a/bin/domain/imag-todo/etc/on-modify.sh b/bin/domain/imag-todo/etc/on-modify.sh deleted file mode 100644 index 89be96d0..00000000 --- a/bin/domain/imag-todo/etc/on-modify.sh +++ /dev/null @@ -1,4 +0,0 @@ -#/!usr/bin/env bash - -imag todo tw-hook --delete - diff --git a/bin/domain/imag-todo/src/lib.rs b/bin/domain/imag-todo/src/lib.rs index 16f42262..f6598a7f 100644 --- a/bin/domain/imag-todo/src/lib.rs +++ b/bin/domain/imag-todo/src/lib.rs @@ -35,30 +35,48 @@ )] extern crate clap; -#[macro_use] extern crate log; extern crate toml; extern crate toml_query; -#[macro_use] extern crate is_match; -extern crate failure; +extern crate chrono; +extern crate filters; +extern crate kairos; +#[macro_use] extern crate log; +#[macro_use] extern crate failure; +extern crate resiter; extern crate libimagrt; +extern crate libimagstore; extern crate libimagerror; +extern crate libimagentryedit; extern crate libimagtodo; +extern crate libimagutil; +extern crate libimagentryview; -use std::process::{Command, Stdio}; -use std::io::stdin; use std::io::Write; +use std::result::Result as RResult; + +use clap::ArgMatches; +use chrono::NaiveDateTime; use failure::Error; use failure::Fallible as Result; +use failure::err_msg; use clap::App; +use resiter::AndThen; +use resiter::IterInnerOkOrElse; -use libimagrt::runtime::Runtime; +use libimagentryedit::edit::Edit; +use libimagentryview::viewer::ViewFromIter; +use libimagentryview::viewer::Viewer; use libimagrt::application::ImagApplication; -use libimagtodo::taskstore::TaskStore; -use libimagerror::trace::{MapErrTrace, trace_error}; -use libimagerror::iter::TraceIterator; -use libimagerror::exit::ExitUnwrap; -use libimagerror::io::ToExitCode; +use libimagrt::runtime::Runtime; +use libimagstore::iter::get::*; +use libimagstore::store::Entry; +use libimagstore::store::FileLockEntry; +use libimagtodo::entry::Todo; +use libimagtodo::priority::Priority; +use libimagtodo::status::Status; +use libimagtodo::store::TodoStore; +use libimagutil::date::datetime_to_string; mod ui; @@ -70,21 +88,20 @@ pub enum ImagTodo {} impl ImagApplication for ImagTodo { fn run(rt: Runtime) -> Result<()> { match rt.cli().subcommand_name() { - Some("tw-hook") => tw_hook(&rt), - Some("list") => list(&rt), - Some(other) => { + Some("create") => create(&rt), + Some("show") => show(&rt), + Some("mark") => mark(&rt), + Some("pending") | None => list_todos(&rt, &StatusMatcher::new().is(Status::Pending), false), + Some("list") => list(&rt), + Some(other) => { debug!("Unknown command"); - let _ = rt.handle_unknown_subcommand("imag-todo", other, rt.cli()) - .map_err_trace_exit_unwrap() - .code() - .map(::std::process::exit); + if rt.handle_unknown_subcommand("imag-todo", other, rt.cli())?.success() { + Ok(()) + } else { + Err(err_msg("Failed to handle unknown subcommand")) + } } - None => { - warn!("No command"); - }, - }; - - Ok(()) + } // end match scmd } fn build_cli<'a>(app: App<'a, 'a>) -> App<'a, 'a> { @@ -104,99 +121,311 @@ impl ImagApplication for ImagTodo { } } -fn tw_hook(rt: &Runtime) { - let subcmd = rt.cli().subcommand_matches("tw-hook").unwrap(); - if subcmd.is_present("add") { - let stdin = stdin(); +/// A black- and whitelist for matching statuses of todo entries +/// +/// The blacklist is checked first, followed by the whitelist. +/// In case the whitelist is empty, the StatusMatcher works with a +/// blacklist-only approach. +#[derive(Debug)] +pub struct StatusMatcher { + is: Vec, + is_not: Vec, +} - // implements BufRead which is required for `Store::import_task_from_reader()` - let stdin = stdin.lock(); +impl StatusMatcher { + pub fn new() -> Self { + StatusMatcher { + is: Vec::new(), + is_not: Vec::new(), + } + } - let (_, line, uuid ) = rt - .store() - .import_task_from_reader(stdin) - .map_err_trace_exit_unwrap(); + pub fn is(mut self, s: Status) -> Self { + self.add_is(s); + self + } - writeln!(rt.stdout(), "{}\nTask {} stored in imag", line, uuid) - .to_exit_code() - .unwrap_or_exit(); + pub fn add_is(&mut self, s: Status) { + self.is.push(s); + } - } else if subcmd.is_present("delete") { - // The used hook is "on-modify". This hook gives two json-objects - // per usage und wants one (the second one) back. - let stdin = stdin(); - rt.store().delete_tasks_by_imports(stdin.lock()).map_err_trace().ok(); - } else { - // Should not be possible, as one argument is required via - // ArgGroup - unreachable!(); + pub fn is_not(mut self, s: Status) -> Self { + self.add_is_not(s); + self + } + + pub fn add_is_not(&mut self, s: Status) { + self.is_not.push(s); + } + + pub fn matches(&self, todo: Status) -> bool { + if self.is_not.iter().find(|t| **t == todo).is_some() { + // On blacklist + false + } else if self.is.len() < 1 || self.is.iter().find(|t| **t == todo).is_some() { + // No whitelist or on whitelist + true + } else { + // Not on blacklist, but whitelist exists and not on it either + false + } } } -fn list(rt: &Runtime) { - use toml_query::read::TomlValueReadTypeExt; +fn create(rt: &Runtime) -> Result<()> { + debug!("Creating todo"); + let scmd = rt.cli().subcommand().1.unwrap(); // safe by clap - let subcmd = rt.cli().subcommand_matches("list").unwrap(); - let verbose = subcmd.is_present("verbose"); + let scheduled: Option = get_datetime_arg(&scmd, "create-scheduled")?; + let hidden: Option = get_datetime_arg(&scmd, "create-hidden")?; + let due: Option = get_datetime_arg(&scmd, "create-due")?; + let prio: Option = scmd.value_of("create-prio").map(prio_from_str).transpose()?; + let status: Status = scmd.value_of("create-status").map(Status::from_str).unwrap()?; + let edit = scmd.is_present("create-edit"); + let text = scmd.value_of("text").unwrap(); - // Helper for toml_query::read::TomlValueReadExt::read() return value, which does only - // return Result instead of Result>, which is a real inconvenience. - // - let no_identifier = |e: &::toml_query::error::Error| -> bool { - is_match!(e, &::toml_query::error::Error::IdentifierNotFoundInDocument(_)) - }; + trace!("Creating todo with these variables:"); + trace!("scheduled = {:?}", scheduled); + trace!("hidden = {:?}", hidden); + trace!("due = {:?}", due); + trace!("prio = {:?}", prio); + trace!("status = {:?}", status); + trace!("edit = {}", edit); + trace!("text = {:?}", text); - let res = rt.store().all_tasks() // get all tasks - .map(|iter| { // and if this succeeded - // filter out the ones were we can read the uuid - let uuids : Vec<_> = iter.trace_unwrap_exit().filter_map(|storeid| { - match rt.store().retrieve(storeid) { - Ok(fle) => { - match fle.get_header().read_string("todo.uuid") { - Ok(Some(ref u)) => Some(u.clone()), - Ok(None) => { - error!("Header missing field in {}", fle.get_location()); - None - }, - Err(e) => { - if !no_identifier(&e) { - trace_error(&Error::from(e)); - } - None - } - } - }, - Err(e) => { - trace_error(&e); - None - }, - } - }) - .collect(); + let mut entry = rt.store().create_todo(status, scheduled, hidden, due, prio, true)?; + debug!("Created: todo {}", entry.get_uuid()?); - // compose a `task` call with them, ... - let outstring = if verbose { // ... if verbose - let output = Command::new("task") - .stdin(Stdio::null()) - .args(&uuids) - .spawn() - .unwrap_or_else(|e| { - error!("Failed to execute `task` on the commandline: {:?}. I'm dying now.", e); - ::std::process::exit(1) - }) - .wait_with_output() - .unwrap_or_else(|e| panic!("failed to unwrap output: {}", e)); + debug!("Setting content"); + *entry.get_content_mut() = text.to_string(); - String::from_utf8(output.stdout) - .unwrap_or_else(|e| panic!("failed to execute: {}", e)) - } else { // ... else just join them - uuids.join("\n") - }; + if edit { + debug!("Editing content"); + entry.edit_content(&rt)?; + } - // and then print that - writeln!(rt.stdout(), "{}", outstring).to_exit_code().unwrap_or_exit(); - }); - - res.map_err_trace().ok(); + rt.report_touched(entry.get_location()).map_err(Error::from) +} + +fn mark(rt: &Runtime) -> Result<()> { + fn mark_todos_as(rt: &Runtime, status: Status) -> Result<()> { + rt.ids::()? + .ok_or_else(|| err_msg("No ids supplied"))? + .into_iter() + .map(Ok) + .into_get_iter(rt.store()) + .map_inner_ok_or_else(|| err_msg("Did not find one entry")) + .and_then_ok(|e| rt.report_touched(e.get_location()).map_err(Error::from).map(|_| e)) + .and_then_ok(|mut e| e.set_status(status.clone())) + .collect() + } + + let scmd = rt.cli().subcommand().1.unwrap(); + match scmd.subcommand_name() { + Some("done") => mark_todos_as(rt, Status::Done), + Some("delete") => mark_todos_as(rt, Status::Deleted), + Some("pending") => mark_todos_as(rt, Status::Pending), + Some(other) => Err(format_err!("Unknown mark type selected: {}", other)), + None => Err(format_err!("No mark type selected, doing nothing!")), + } +} + +/// Generic todo listing function +/// +/// Supports filtering of todos by status using the passed in StatusMatcher +fn list_todos(rt: &Runtime, matcher: &StatusMatcher, show_hidden: bool) -> Result<()> { + use filters::failable::filter::FailableFilter; + debug!("Listing todos with status filter {:?}", matcher); + + let now = { + let now = chrono::offset::Local::now(); + NaiveDateTime::new(now.date().naive_local(), now.time()) + }; + + let filter_hidden = |todo: &FileLockEntry<'_>| -> Result { + Ok(todo.get_hidden()?.map(|hid| hid > now).unwrap_or(true)) + }; + + struct TodoViewer { + details: bool, + } + impl Viewer for TodoViewer { + fn view_entry(&self, entry: &Entry, sink: &mut W) -> RResult<(), libimagentryview::error::Error> + where W: Write + { + use libimagentryview::error::Error as E; + + if !entry.is_todo().map_err(E::from)? { + return Err(format_err!("Not a Todo: {}", entry.get_location())).map_err(E::from); + } + + let uuid = entry.get_uuid().map_err(E::from)?; + let status = entry.get_status().map_err(E::from)?; + let status = status.as_str(); + let first_line = entry.get_content() + .lines() + .next() + .unwrap_or(""); + + if !self.details { + writeln!(sink, "{uuid} - {status} : {first_line}", + uuid = uuid, + status = status, + first_line = first_line) + } else { + let sched = get_dt_str(entry.get_scheduled(), "Not scheduled")?; + let hidden = get_dt_str(entry.get_hidden(), "Not hidden")?; + let due = get_dt_str(entry.get_due(), "No due")?; + let priority = entry.get_priority().map_err(E::from)?.map(|p| p.as_str().to_string()) + .unwrap_or("No prio".to_string()); + + writeln!(sink, "{uuid} - {status} - {sched} - {hidden} - {due} - {prio}: {first_line}", + uuid = uuid, + status = status, + sched = sched, + hidden = hidden, + due = due, + prio = priority, + first_line = first_line) + } + .map_err(libimagentryview::error::Error::from) + } + } + + let viewer = TodoViewer { details: false }; + + rt.store() + .get_todos()? + .into_get_iter() + .map_inner_ok_or_else(|| err_msg("Did not find one entry")) + .filter_map(|r| { + match r.and_then(|e| e.get_status().map(|s| (s, e))) { + Err(e) => Some(Err(e)), + Ok((st, e)) => if matcher.matches(st) { + Some(Ok(e)) + } else { + None + } + } + }) + .and_then_ok(|entry| { + if show_hidden || filter_hidden.filter(&entry)? { + viewer.view_entry(&entry, &mut rt.stdout())?; + } + + rt.report_touched(entry.get_location()).map_err(Error::from) + }) + .collect() +} + +/// Generic todo items list function +/// +/// This sets up filtes based on the command line and prints out a list of todos +fn list(rt: &Runtime) -> Result<()> { + debug!("Listing todo"); + let scmd = rt.cli().subcommand().1; + let table = scmd.map(|s| s.is_present("list-table")).unwrap_or(true); + let hidden = scmd.map(|s| s.is_present("list-hidden")).unwrap_or(false); + let done = scmd.map(|s| s.is_present("list-done")).unwrap_or(false); + let nopending = scmd.map(|s| s.is_present("list-nopending")).unwrap_or(true); + + trace!("table = {}", table); + trace!("hidden = {}", hidden); + trace!("done = {}", done); + trace!("nopending = {}", nopending); + + let mut matcher = StatusMatcher::new(); + if !done { matcher.add_is_not(Status::Done); } + if nopending { matcher.add_is_not(Status::Pending); } + + // TODO: Support printing as ASCII table + list_todos(rt, &matcher, hidden) +} + +fn show(rt: &Runtime) -> Result<()> { + #[derive(Default)] + struct TodoShow; + impl Viewer for TodoShow { + + fn view_entry(&self, entry: &Entry, sink: &mut W) -> RResult<(), libimagentryview::error::Error> + where W: Write + { + use libimagentryview::error::Error as E; + + if !entry.is_todo().map_err(E::from)? { + return Err(format_err!("Not a Todo: {}", entry.get_location())).map_err(E::from); + } + + let uuid = entry.get_uuid().map_err(E::from)?; + let status = entry.get_status().map_err(E::from)?; + let status = status.as_str(); + let text = entry.get_content(); + let sched = get_dt_str(entry.get_scheduled(), "Not scheduled")?; + let hidden = get_dt_str(entry.get_hidden(), "Not hidden")?; + let due = get_dt_str(entry.get_due(), "No due")?; + let priority = entry.get_priority().map_err(E::from)?.map(|p| p.as_str().to_string()) + .unwrap_or("No prio".to_string()); + + writeln!(sink, "{uuid}\nStatus: {status}\nPriority: {prio}\nScheduled: {sched}\nHidden: {hidden}\nDue: {due}\n\n{text}", + uuid = uuid, + status = status, + sched = sched, + hidden = hidden, + due = due, + prio = priority, + text = text) + .map_err(Error::from) + .map_err(libimagentryview::error::Error::from) + } + } + + rt.ids::()? + .ok_or_else(|| err_msg("No ids supplied"))? + .into_iter() + .map(Ok) + .into_get_iter(rt.store()) + .map_inner_ok_or_else(|| err_msg("Did not find one entry")) + .and_then_ok(|e| rt.report_touched(e.get_location()).map_err(Error::from).map(|_| e)) + .collect::>>()? + .into_iter() + .view::(&mut rt.stdout()) + .map_err(Error::from) +} + +// +// utility functions +// + +fn get_datetime_arg(scmd: &ArgMatches, argname: &'static str) -> Result> { + use kairos::timetype::TimeType; + use kairos::parser; + + match scmd.value_of(argname) { + None => Ok(None), + Some(v) => match parser::parse(v)? { + parser::Parsed::TimeType(TimeType::Moment(moment)) => Ok(Some(moment)), + parser::Parsed::TimeType(other) => { + Err(format_err!("You did not pass a date, but a {}", other.name())) + }, + parser::Parsed::Iterator(_) => { + Err(format_err!("Argument {} results in a list of dates, but we need a single date.", v)) + } + } + } +} + +fn prio_from_str>(s: S) -> Result { + match s.as_ref() { + "h" => Ok(Priority::High), + "m" => Ok(Priority::Medium), + "l" => Ok(Priority::Low), + other => Err(format_err!("Unsupported Priority: '{}'", other)), + } +} + +fn get_dt_str(d: Result>, s: &str) -> RResult { + Ok(d.map_err(libimagentryview::error::Error::from)? + .map(|v| datetime_to_string(&v)) + .unwrap_or(s.to_string())) } diff --git a/bin/domain/imag-todo/src/ui.rs b/bin/domain/imag-todo/src/ui.rs index 073508cf..3299b7de 100644 --- a/bin/domain/imag-todo/src/ui.rs +++ b/bin/domain/imag-todo/src/ui.rs @@ -17,45 +17,184 @@ // Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA // -use clap::{Arg, App, ArgGroup, SubCommand}; +use std::path::PathBuf; +use clap::{Arg, ArgMatches, App, SubCommand}; +use failure::Fallible as Result; + +use libimagstore::storeid::StoreId; +use libimagstore::storeid::IntoStoreId; +use libimagrt::runtime::IdPathProvider; + pub fn build_ui<'a>(app: App<'a, 'a>) -> App<'a, 'a> { app - .subcommand(SubCommand::with_name("tw-hook") - .about("For use in a taskwarrior hook") + .subcommand(SubCommand::with_name("create") + .about("Create task") .version("0.1") - .arg(Arg::with_name("add") - .long("add") - .short("a") - .takes_value(false) + .arg(Arg::with_name("create-scheduled") + .long("scheduled") + .short("s") + .takes_value(true) .required(false) - .help("For use in an on-add hook")) + .help("Set a 'scheduled' date/time") + ) - .arg(Arg::with_name("delete") - .long("delete") + .arg(Arg::with_name("create-hidden") + .long("hidden") + .short("h") + .takes_value(true) + .required(false) + .help("Set a 'hidden' date/time") + ) + + .arg(Arg::with_name("create-due") + .long("due") .short("d") + .takes_value(true) + .required(false) + .help("Set a 'due' date/time") + ) + + .arg(Arg::with_name("create-prio") + .long("prio") + .short("p") + .takes_value(true) + .required(false) + .help("Set a priority") + .possible_values(&["h", "m", "l"]) + ) + + .arg(Arg::with_name("create-status") + .long("status") + .takes_value(true) + .required(false) + .help("Set a status, useful if the task is already done") + .default_value("pending") + .possible_values(&["pending", "done", "deleted"]) + ) + + .arg(Arg::with_name("create-edit") + .long("edit") + .short("e") .takes_value(false) .required(false) - .help("For use in an on-delete hook")) + .help("Create and then edit the entry") + ) - .group(ArgGroup::with_name("taskwarrior hooks") - .args(&[ "add", - "delete", - ]) - .required(true)) + .arg(Arg::with_name("text") + .index(1) + .multiple(true) + .required(true) + .help("Text for the todo") + ) + ) + + .subcommand(SubCommand::with_name("pending") + .arg(Arg::with_name("todos") + .index(1) + .takes_value(true) + .required(false) + .help("List pending todos (same as 'list' command without arguments)") + ) ) .subcommand(SubCommand::with_name("list") - .about("List all tasks") + .about("List tasks (default)") .version("0.1") - .arg(Arg::with_name("verbose") - .long("verbose") - .short("v") + .arg(Arg::with_name("list-table") + .long("table") + .short("T") .takes_value(false) .required(false) - .help("Asks taskwarrior for all the details") + .help("Print a nice ascii-table") ) - ) + + .arg(Arg::with_name("list-hidden") + .long("hidden") + .short("H") + .takes_value(false) + .required(false) + .help("Print also hidden todos") + ) + + .arg(Arg::with_name("list-done") + .long("done") + .short("D") + .takes_value(false) + .required(false) + .help("Print also done todos") + ) + + .arg(Arg::with_name("list-nopending") + .long("no-pending") + .short("P") + .takes_value(false) + .required(false) + .help("Do not print pending tasks") + ) + + ) + + .subcommand(SubCommand::with_name("show") + .arg(Arg::with_name("todos") + .index(1) + .takes_value(true) + .required(false) + .help("Show the passed todos") + ) + ) + + .subcommand(SubCommand::with_name("mark") + .about("Mark tasks as pending, done or deleted") + .version("0.1") + + .subcommand(SubCommand::with_name("pending") + .arg(Arg::with_name("todos") + .index(1) + .takes_value(true) + .required(false) + .help("List pending todos (same as 'list' command without arguments)") + ) + ) + + .subcommand(SubCommand::with_name("done") + .arg(Arg::with_name("todos") + .index(1) + .takes_value(true) + .required(false) + .help("Mark the passed todos as done") + ) + ) + + .subcommand(SubCommand::with_name("deleted") + .arg(Arg::with_name("todos") + .index(1) + .takes_value(true) + .required(false) + .help("Mark the passed todos as deleted") + ) + ) + ) + } + +pub struct PathProvider; +impl IdPathProvider for PathProvider { + fn get_ids(matches: &ArgMatches) -> Result>> { + match matches.subcommand() { + ("show", Some(scmd)) => scmd.values_of("todos"), + ("show", None) => unimplemented!(), + _ => unimplemented!() + } + .map(|v| v + .into_iter() + .map(PathBuf::from) + .map(|pb| pb.into_storeid()) + .collect::>>() + ) + .transpose() + } +} +