imag-todo 0.10.1

Part of the imag core distribution: imag-todo command
Documentation
//
// imag - the personal information management suite for the commandline
// Copyright (C) 2015-2020 Matthias Beyer <mail@beyermatthias.de> and contributors
//
// This library is free software; you can redistribute it and/or
// modify it under the terms of the GNU Lesser General Public
// License as published by the Free Software Foundation; version
// 2.1 of the License.
//
// This library is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
//

#![forbid(unsafe_code)]

#![deny(
    non_camel_case_types,
    non_snake_case,
    path_statements,
    trivial_numeric_casts,
    unstable_features,
    unused_allocation,
    unused_import_braces,
    unused_imports,
    unused_must_use,
    unused_mut,
    unused_qualifications,
    while_true,
)]

extern crate clap;
extern crate toml;
extern crate toml_query;
extern crate chrono;
extern crate filters;
extern crate kairos;
#[macro_use] extern crate log;
#[macro_use] extern crate failure;
extern crate resiter;
extern crate handlebars;
extern crate prettytable;

#[cfg(feature = "import-taskwarrior")]
extern crate task_hookrs;

#[cfg(feature = "import-taskwarrior")]
extern crate uuid;

#[cfg(feature = "import-taskwarrior")]
extern crate libimagentrytag;

#[cfg(feature = "import-taskwarrior")]
extern crate libimagentrylink;

extern crate libimagrt;
extern crate libimagstore;
extern crate libimagerror;
extern crate libimagentryedit;
extern crate libimagtodo;
extern crate libimagutil;
extern crate libimagentryview;
extern crate libimaginteraction;

use std::ops::Deref;
use std::io::Write;
use std::result::Result as RResult;
use std::str::FromStr;

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 prettytable::Table;
use prettytable::Cell;
use prettytable::Row;

use libimagentryedit::edit::Edit;
use libimagentryview::viewer::Viewer;
use libimagrt::application::ImagApplication;
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;

mod ui;
mod import;
mod util;

/// Marker enum for implementing ImagApplication on
///
/// This is used by binaries crates to execute business logic
/// or to build a CLI completion.
pub enum ImagTodo {}
impl ImagApplication for ImagTodo {
    fn run(rt: Runtime) -> Result<()> {
        match rt.cli().subcommand_name() {
            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("import")         => import::import(&rt),
            Some(other)         => {
                debug!("Unknown command");
                if rt.handle_unknown_subcommand("imag-todo", other, rt.cli())?.success() {
                    Ok(())
                } else {
                    Err(err_msg("Failed to handle unknown subcommand"))
                }
            }
        } // end match scmd
    }

    fn build_cli<'a>(app: App<'a, 'a>) -> App<'a, 'a> {
        ui::build_ui(app)
    }

    fn name() -> &'static str {
        env!("CARGO_PKG_NAME")
    }

    fn description() -> &'static str {
        "Interface with taskwarrior"
    }

    fn version() -> &'static str {
        env!("CARGO_PKG_VERSION")
    }
}

/// 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, Default)]
pub struct StatusMatcher {
    is: Vec<Status>,
    is_not: Vec<Status>,
}

impl StatusMatcher {
    pub fn new() -> Self {
        StatusMatcher { ..Default::default() }
    }

    pub fn is(mut self, s: Status) -> Self {
        self.add_is(s);
        self
    }

    pub fn add_is(&mut self, s: Status) {
        self.is.push(s);
    }

    #[allow(clippy::wrong_self_convention)]
    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().any(|t| *t == todo) {
            // On blacklist
            false
        } else {
            // No whitelist or on whitelist
            // or
            // Not on blacklist, but whitelist exists and not on it either
            self.is.is_empty() || self.is.iter().any(|t| *t == todo)
        }
    }
}

fn create(rt: &Runtime) -> Result<()> {
    debug!("Creating todo");
    let scmd = rt.cli().subcommand().1.unwrap(); // safe by clap

    let scheduled: Option<NaiveDateTime> = get_datetime_arg(&scmd, "create-scheduled")?;
    let hidden: Option<NaiveDateTime>    = get_datetime_arg(&scmd, "create-hidden")?;
    let due: Option<NaiveDateTime>       = get_datetime_arg(&scmd, "create-due")?;
    let prio: Option<Priority>           = 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();

    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 mut entry = rt.store().create_todo(status, scheduled, hidden, due, prio, true)?;
    debug!("Created: todo {}", entry.get_uuid()?);

    debug!("Setting content");
    *entry.get_content_mut() = text.to_string();

    if edit {
        debug!("Editing content");
        entry.edit_content(&rt)?;
    }

    rt.report_touched(entry.get_location())
}

fn mark(rt: &Runtime) -> Result<()> {
    fn mark_todos_as(rt: &Runtime, status: Status) -> Result<()> {
        rt.ids::<crate::ui::PathProvider>()?
            .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(|_| 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("deleted") => 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);

    struct TodoViewer {
        details: bool,
    }
    impl Viewer for TodoViewer {
        fn view_entry<W>(&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("<empty description>");

            if !self.details {
                writeln!(sink, "{uuid} - {status} : {first_line}",
                         uuid = uuid,
                         status = status,
                         first_line = first_line)
            } else {
                let sched    = util::get_dt_str(entry.get_scheduled(), "Not scheduled")?;
                let hidden   = util::get_dt_str(entry.get_hidden(), "Not hidden")?;
                let due      = util::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_else(|| "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)
        }
    }

    fn process<'a, I>(rt: &Runtime, matcher: &StatusMatcher, show_hidden: bool, iter: I) -> Result<()>
        where I: Iterator<Item = Result<FileLockEntry<'a>>> + Sized
    {
        let viewer = TodoViewer { details: false };

        let now = {
            let now = chrono::offset::Local::now();
            NaiveDateTime::new(now.date().naive_local(), now.time())
        };

        let filter_hidden = |todo: &FileLockEntry<'_>| -> Result<bool> {
            Ok(todo.get_hidden()?.map(|hid| hid > now).unwrap_or(true))
        };

        iter
            .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 !rt.output_is_pipe() && (show_hidden || filter_hidden.filter(&entry)?) {
                    if let Err(e) = viewer.view_entry(&entry, &mut rt.stdout()) {
                        use libimagentryview::error::Error;
                        match e {
                            Error::Other(e) => return Err(e),
                            Error::Io(e)    => if e.kind() != std::io::ErrorKind::BrokenPipe {
                                return Err(failure::Error::from(e))
                            },
                        }
                    }
                }

                rt.report_touched(entry.get_location())
            })
            .collect()
    };

    if rt.ids_from_stdin() {
        let iter = rt.ids::<crate::ui::PathProvider>()?
            .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"));

        process(&rt, matcher, show_hidden, iter)
    } else {
        let iter = rt.store().get_todos()?
            .into_get_iter()
            .map_inner_ok_or_else(|| err_msg("Did not find one entry"));

        process(&rt, matcher, show_hidden, iter)
    }
}

/// 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<()> {
    let scmd        = rt.cli().subcommand_matches("show").unwrap();
    let show_format = util::get_todo_print_format("todo.show_format", rt, &scmd)?;
    let out         = rt.stdout();
    let mut outlock = out.lock();

    fn show_with_table<'a, I>(rt: &Runtime, iter: I) -> Result<()>
        where I: Iterator<Item = FileLockEntry<'a>>
    {
        const HEADER: &[&str] = &[
            "uuid",
            "status",
            "sched",
            "hidden",
            "due",
            "priority",
            "text",
        ];

        let mut table = {
            let mut t = Table::new();
            let header = HEADER.iter().map(|s| Cell::new(s)).collect::<Vec<Cell>>();
            t.set_titles(Row::from(header));
            t
        };

        iter.map(|entry| {
            use libimagentryview::error::Error as E;

            let uuid     = entry.get_uuid().map_err(E::from)?.to_hyphenated().to_string();
            let status   = entry.get_status().map_err(E::from)?;
            let status   = status.as_str().to_string();
            let sched    = util::get_dt_str(entry.get_scheduled(), "Not scheduled")?;
            let hidden   = util::get_dt_str(entry.get_hidden(), "Not hidden")?;
            let due      = util::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_else(|| "No prio".to_string());

            let text     = entry.get_content().to_owned();

            let v = [
                uuid,
                status,
                sched,
                hidden,
                due,
                priority,
                text,
            ];
            table.add_row(v.iter().map(|s| Cell::new(s)).collect());

            Ok(entry)
        })
        .and_then_ok(|e| rt.report_touched(e.get_location()).map_err(Error::from).map(|_| e))
        .collect::<Result<Vec<_>>>()?;

        table.print(&mut rt.stdout())
            .map(|_| ())
            .map_err(Error::from)
    }

    let iter = rt
        .ids::<crate::ui::PathProvider>()?
        .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(|_| e))
        .collect::<Result<Vec<_>>>()?
        .into_iter();

    if scmd.is_present("show-no-table") {
        iter.enumerate()
            .map(|(i, elem)| {
                let data = util::build_data_object_for_handlebars(i, elem.deref())?;
                let s = show_format.render("format", &data)?;
                writeln!(outlock, "{}", s).map_err(Error::from)
            })
            .collect()
    } else {
        show_with_table(rt, iter)
    }
}

//
// utility functions
//

fn get_datetime_arg(scmd: &ArgMatches, argname: &'static str) -> Result<Option<NaiveDateTime>> {
    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: AsRef<str>>(s: S) -> Result<Priority> {
    match s.as_ref() {
        "h" => Ok(Priority::High),
        "m" => Ok(Priority::Medium),
        "l" => Ok(Priority::Low),
        other => Err(format_err!("Unsupported Priority: '{}'", other)),
    }
}