use crate::item::{Importance, Item};
use crate::list::{LineKind, List};
use clap::{Arg, ArgAction, ArgMatches, Command};
use console::Style;
use promptly::prompt_default;
use std::{env, fs, io};
use substring::Substring;
pub mod add;
pub mod archive;
pub mod done;
pub mod edit;
pub mod find;
pub mod important;
pub mod path;
pub mod pull;
pub mod quick;
pub mod remove;
pub mod show;
pub mod tidy;
pub mod urgent;
pub mod zen;
pub struct Action {
pub name: String,
pub command: Command,
}
#[derive(Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
pub enum FileType {
TodoTxt,
DoneTxt,
}
impl FileType {
pub fn filename(&self, args: &ArgMatches) -> String {
match self {
Self::TodoTxt => Self::_filename_for_todotxt(args),
Self::DoneTxt => Self::_filename_for_donetxt(args),
}
}
pub fn label(&self) -> String {
match self {
Self::TodoTxt => String::from("todo list"),
Self::DoneTxt => String::from("done list"),
}
}
pub fn load(&self, args: &ArgMatches) -> List {
let filename = self.filename(args);
let label = self.label();
List::from_url(filename)
.unwrap_or_else(|_| panic!("Could not read {label}"))
}
fn _file_exists(path: &str) -> bool {
match fs::metadata(path) {
Ok(f) => f.is_file(),
Err(_) => false,
}
}
fn _filename_for_todotxt(args: &ArgMatches) -> String {
let local_only = *args.get_one::<bool>("local").unwrap_or(&false);
if local_only {
let names =
["todo.txt", "TODO", "TODO.TXT", "ToDo", "ToDo.txt", "todo"];
for n in names {
let qname = env::current_dir()
.unwrap()
.into_os_string()
.into_string()
.unwrap() + "/" + n;
if Self::_file_exists(&qname) {
return qname;
}
}
panic!("Could not find a file called todo.txt or TODO in the current directory!")
}
if let Some(f) = args.get_one::<String>("file") {
return f.to_string();
};
if let Ok(f) = env::var("TODO_FILE") {
return f;
};
let dir = env::var("TODO_DIR").unwrap_or_else(|_| {
env::var("HOME").expect("Could not determine path to todo.txt!")
});
dir + "/todo.txt"
}
fn _filename_for_donetxt(args: &ArgMatches) -> String {
let local_only = *args.get_one::<bool>("local").unwrap_or(&false);
if local_only {
let names =
["done.txt", "DONE", "DONE.TXT", "Done", "Done.txt", "done"];
for n in names {
let qname = env::current_dir()
.unwrap()
.into_os_string()
.into_string()
.unwrap() + "/" + n;
if Self::_file_exists(&qname) {
return qname;
}
}
panic!("Could not find a file called done.txt or DONE in the current directory!")
}
if let Some(f) = args.get_one::<String>("done-file") {
return f.to_string();
};
if let Ok(f) = env::var("DONE_FILE") {
return f;
};
let dir = env::var("TODO_DIR").unwrap_or_else(|_| {
env::var("HOME").expect("Could not determine path to done.txt!")
});
dir + "/done.txt"
}
pub fn add_args(&self, cmd: Command) -> Command {
match self {
Self::TodoTxt => Self::_add_args_for_todotxt(cmd),
Self::DoneTxt => Self::_add_args_for_donetxt(cmd),
}
}
fn _add_args_for_todotxt(cmd: Command) -> Command {
cmd.arg(
Arg::new("file")
.short('f')
.long("file")
.value_name("FILE")
.help("The path or URL for todo.txt"),
)
.arg(
Arg::new("local")
.num_args(0)
.short('l')
.long("local")
.help("Look for files in local directory only"),
)
}
fn _add_args_for_donetxt(cmd: Command) -> Command {
cmd.arg(
Arg::new("done-file")
.long("done-file")
.value_name("FILE")
.help("The path or URL for done.txt"),
)
}
}
pub struct Outputter {
pub width: usize,
pub colour: bool,
pub with_creation_date: bool,
pub with_completion_date: bool,
pub with_line_numbers: bool,
pub with_newline: bool,
pub line_number_digits: usize,
pub io: Box<dyn io::Write>,
}
impl Outputter {
pub fn new(width: usize) -> Self {
Self {
width,
colour: false,
with_creation_date: false,
with_completion_date: false,
with_line_numbers: false,
with_newline: true,
line_number_digits: 2,
io: Box::new(io::stdout()),
}
}
pub fn new_based_on_terminal() -> Self {
let term = console::Term::stdout();
let (_height, width) = term.size();
Self::new(width.into())
}
pub fn add_args_minimal(cmd: Command) -> Command {
cmd.arg(
Arg::new("colour")
.num_args(0)
.long("colour")
.aliases(["color"])
.help("Coloured output"),
)
.arg(
Arg::new("no-colour")
.num_args(0)
.long("no-colour")
.aliases(["no-color", "nocolour", "nocolor"])
.help("Plain output"),
)
}
pub fn add_args(cmd: Command) -> Command {
Self::add_args_minimal(cmd)
.arg(
Arg::new("max-width")
.long("max-width")
.aliases(["maxwidth"])
.value_parser(clap::value_parser!(usize))
.value_name("COLS")
.help("Maximum width of terminal output"),
)
.arg(
Arg::new("show-lines")
.num_args(0)
.short('L')
.long("show-lines")
.aliases(["show-lines", "lines"])
.help("Show line numbers for tasks"),
)
.arg(
Arg::new("show-created")
.num_args(0)
.long("show-created")
.aliases(["showcreated", "created"])
.help("Show 'created' dates for tasks"),
)
.arg(
Arg::new("show-finished")
.num_args(0)
.long("show-finished")
.aliases(["showfinished", "finished"])
.help("Show 'finished' dates for tasks"),
)
}
pub fn from_argmatches_minimal(args: &ArgMatches) -> Self {
let mut cfg = Self::new_based_on_terminal();
cfg.colour = if *args.get_one::<bool>("no-colour").unwrap() {
false
} else if *args.get_one::<bool>("colour").unwrap() {
true
} else {
console::colors_enabled()
};
cfg
}
pub fn from_argmatches(args: &ArgMatches) -> Self {
let mut cfg = Self::from_argmatches_minimal(args);
cfg.with_creation_date = *args.get_one::<bool>("show-created").unwrap();
cfg.with_completion_date =
*args.get_one::<bool>("show-finished").unwrap();
cfg.with_line_numbers = *args.get_one::<bool>("show-lines").unwrap();
cfg.width = *args
.get_one::<usize>("max-width")
.unwrap_or(&cfg.width);
if cfg.width < 48 {
panic!("max-width must be at least 48!");
}
cfg
}
pub fn write_heading(&mut self, heading: String) {
let stream = &mut self.io;
let mut hh: String = format!("# {heading}");
if self.colour {
let s = Style::new()
.white()
.bright()
.bold()
.force_styling(true);
hh = s.apply_to(hh).to_string();
}
if self.with_newline {
writeln!(stream, "{hh}").expect("panik");
} else {
write!(stream, "{hh}").expect("panik");
}
}
pub fn write_separator(&mut self) {
let stream = &mut self.io;
writeln!(stream).expect("panik");
}
pub fn write_status(&mut self, status: String) {
let stream = &mut self.io;
let mut hh: String = status;
if self.colour {
let s = Style::new()
.white()
.bright()
.force_styling(true);
hh = s.apply_to(hh).to_string();
}
if self.with_newline {
writeln!(stream, "{hh}").expect("panik");
} else {
write!(stream, "{hh}").expect("panik");
}
}
pub fn write_notice(&mut self, hint: String) {
let stream = &mut self.io;
let mut hh: String = hint;
if self.colour {
let s = Style::new().magenta().force_styling(true);
hh = s.apply_to(hh).to_string();
}
if self.with_newline {
writeln!(stream, "{hh}").expect("panik");
} else {
write!(stream, "{hh}").expect("panik");
}
}
pub fn write_error(&mut self, errstr: String) {
let stream = &mut self.io;
let mut hh: String = errstr;
if self.colour {
let s = Style::new().red().force_styling(true);
hh = s.apply_to(hh).to_string();
}
if self.with_newline {
writeln!(stream, "{hh}").expect("panik");
} else {
write!(stream, "{hh}").expect("panik");
}
}
pub fn write_item(&mut self, i: &Item) {
let stream = &mut self.io;
let mut r: String = String::new();
if i.completion() {
r.push_str("x ");
} else {
r.push_str(" ");
}
if i.priority() == '\0' {
r.push_str("(?) ");
} else {
let style = match i.importance() {
Some(Importance::A) => {
Style::new().red().bold().force_styling(true)
}
Some(Importance::B) => {
Style::new().yellow().bold().force_styling(true)
}
Some(Importance::C) => {
Style::new().green().bold().force_styling(true)
}
Some(_) => Style::new().bold().force_styling(true),
_ => Style::new(),
};
let paren = format!("({}) ", style.apply_to(i.priority()));
r.push_str(&paren);
}
if self.with_completion_date {
if i.completion() && i.completion_date().is_some() {
let date = i
.completion_date()
.unwrap()
.format("%Y-%m-%d ")
.to_string();
r.push_str(&date);
} else if i.completion() {
r.push_str("????-??-?? ");
} else {
r.push_str(" ");
}
}
if self.with_creation_date {
if i.creation_date().is_some() {
let date = i
.creation_date()
.unwrap()
.format("%Y-%m-%d ")
.to_string();
r.push_str(&date);
} else {
r.push_str("????-??-?? ");
}
}
if self.with_line_numbers {
r.push_str(
format!(
"#{:0width$} ",
i.line_number(),
width = self.line_number_digits
)
.as_str(),
)
}
let len = self.width - console::strip_ansi_codes(&r).len();
r.push_str(i.description().substring(0, len));
if i.completion() || !i.is_startable() {
if self.colour {
r = format!(
"{}",
Style::new()
.dim()
.force_styling(true)
.apply_to(console::strip_ansi_codes(&r).to_string())
);
} else {
r = console::strip_ansi_codes(&r).to_string();
}
} else if !self.colour {
r = console::strip_ansi_codes(&r).to_string();
}
if self.with_newline {
writeln!(stream, "{r}").expect("panik");
} else {
write!(stream, "{r}").expect("panik");
}
}
}
impl Default for Outputter {
fn default() -> Self {
Self::new_based_on_terminal()
}
}
#[derive(Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
pub enum ConfirmationStatus {
Yes,
No,
Ask,
}
impl ConfirmationStatus {
pub fn from_argmatches(args: &ArgMatches) -> Self {
if *args.get_one::<bool>("no").unwrap() {
Self::No
} else if *args.get_one::<bool>("yes").unwrap() {
Self::Yes
} else {
Self::Ask
}
}
pub fn check(
&self,
outputter: &mut Outputter,
prompt_phrase: &str,
yes_phrase: &str,
no_phrase: &str,
) -> bool {
match self {
ConfirmationStatus::Yes => {
outputter.write_notice(format!("{yes_phrase}\n"));
true
}
ConfirmationStatus::No => {
outputter.write_notice(format!("{no_phrase}\n"));
false
}
ConfirmationStatus::Ask => {
let response = prompt_default(prompt_phrase, true).unwrap();
if response {
outputter.write_notice(format!("{yes_phrase}\n"));
} else {
outputter.write_notice(format!("{no_phrase}\n"));
}
response
}
}
}
pub fn add_args(cmd: Command) -> Command {
cmd.arg(
Arg::new("yes")
.num_args(0)
.short('y')
.long("yes")
.help("Assume 'yes' to prompts"),
)
.arg(
Arg::new("no")
.num_args(0)
.short('n')
.long("no")
.help("Assume 'no' to prompts"),
)
}
}
#[derive(Clone)]
pub struct SearchTerms {
pub terms: Vec<String>,
}
impl SearchTerms {
pub fn new() -> Self {
Self { terms: Vec::new() }
}
pub fn from_vec(terms: Vec<String>) -> Self {
Self { terms }
}
pub fn from_string(term: &str) -> Self {
Self {
terms: Vec::from([String::from(term)]),
}
}
pub fn add_args(cmd: Command) -> Command {
cmd.arg(
Arg::new("search-term")
.action(ArgAction::Append)
.required(true)
.help("A tag, context, line number, or string"),
)
}
pub fn from_argmatches(args: &ArgMatches) -> Self {
let terms = args
.get_many::<String>("search-term")
.unwrap()
.cloned()
.collect();
Self { terms }
}
pub fn item_matches(&self, item: &Item) -> bool {
for term in &self.terms {
match term.chars().next() {
Some('@') => {
if item.has_context(term) {
return true;
}
}
Some('+') => {
if item.has_tag(term) {
return true;
}
}
Some('#') => {
let n: usize = term.get(1..).unwrap().parse().unwrap();
if item.line_number() == n {
return true;
}
}
_ => {
let lc_term = term.to_lowercase();
if item
.description()
.to_lowercase()
.contains(&lc_term)
{
return true;
}
}
}
}
false
}
}
impl Default for SearchTerms {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SortOrder {
Urgency,
Importance,
TshirtSize,
Alphabetical,
DueDate,
Original,
Smart,
}
#[derive(Debug, Clone)]
pub struct InvalidSortOrder;
impl SortOrder {
pub fn add_args(cmd: Command, default_val: SortOrder) -> Command {
cmd.arg(
Arg::new("sort")
.num_args(1)
.short('s')
.long("sort")
.value_name("BY")
.help(format!(
"Sort by 'smart', 'urgency', 'importance', 'size', 'alpha', or 'due' (default: {})",
default_val.to_string()
))
)
}
pub fn to_string(&self) -> &str {
match self {
SortOrder::Urgency => "urgency",
SortOrder::Importance => "importance",
SortOrder::TshirtSize => "size",
SortOrder::Alphabetical => "alpha",
SortOrder::DueDate => "due",
SortOrder::Original => "original",
SortOrder::Smart => "smart",
}
}
pub fn from_argmatches(args: &ArgMatches, default_order: Self) -> Self {
match args.get_one::<String>("sort") {
Some(o) => Self::from_string(o)
.unwrap_or_else(|_| panic!("Expected sort order, got '{o}'")),
None => default_order,
}
}
pub fn from_string(sortby: &str) -> Result<Self, InvalidSortOrder> {
match sortby.to_lowercase().as_str() {
"urgency" | "urgent" | "urg" => Ok(SortOrder::Urgency),
"importance" | "import" | "imp" | "important" => {
Ok(SortOrder::Importance)
}
"tshirtsize" | "size" | "tshirt" | "quick" => {
Ok(SortOrder::TshirtSize)
}
"alphabetical" | "alphabet" | "alpha" => {
Ok(SortOrder::Alphabetical)
}
"due-date" | "duedate" | "due" => Ok(SortOrder::DueDate),
"original" | "orig" => Ok(SortOrder::Original),
"smart" => Ok(SortOrder::Smart),
_ => Err(InvalidSortOrder),
}
}
pub fn sort_items<'a>(&self, items: Vec<&'a Item>) -> Vec<&'a Item> {
let mut out = items.clone();
match self {
SortOrder::Urgency => {
out.sort_by_cached_key(|i| i.urgency().unwrap_or_default())
}
SortOrder::Importance => {
out.sort_by_cached_key(|i| i.importance().unwrap_or_default())
}
SortOrder::TshirtSize => {
out.sort_by_cached_key(|i| i.tshirt_size().unwrap_or_default())
}
SortOrder::Alphabetical => {
out.sort_by_cached_key(|i| i.description().to_lowercase())
}
SortOrder::DueDate => out.sort_by_cached_key(|i| i.due_date()),
SortOrder::Original => out.sort_by_cached_key(|i| i.line_number()),
SortOrder::Smart => out.sort_by_cached_key(|i| i.smart_key()),
};
out
}
}
#[derive(Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
pub enum Grouping {
None,
Urgency,
Importance,
TshirtSize,
}
impl Grouping {
pub fn add_args(cmd: Command) -> Command {
cmd.arg(
Arg::new("importance")
.num_args(0)
.short('i')
.long("importance")
.aliases(["import", "imp", "important"])
.help("Group by importance"),
)
.arg(
Arg::new("urgency")
.num_args(0)
.short('u')
.long("urgency")
.aliases(["urgent", "urg"])
.help("Group by urgency"),
)
.arg(
Arg::new("size")
.num_args(0)
.short('z')
.long("size")
.aliases(["tshirt-size", "tshirt", "quick"])
.help("Group by tshirt size"),
)
}
pub fn from_argmatches(args: &ArgMatches) -> Self {
let g = args
.get_one::<bool>("importance")
.unwrap_or(&false);
if *g {
return Self::Importance;
}
let g = args
.get_one::<bool>("urgency")
.unwrap_or(&false);
if *g {
return Self::Urgency;
}
let g = args.get_one::<bool>("size").unwrap_or(&false);
if *g {
return Self::TshirtSize;
}
Self::None
}
}
pub struct OutputCount {
pub count: usize,
}
impl OutputCount {
pub fn new(count: usize) -> Self {
Self { count }
}
pub fn add_args(cmd: Command) -> Command {
cmd.arg(
Arg::new("number")
.num_args(1)
.short('n')
.long("number")
.value_parser(clap::value_parser!(usize))
.value_name("N")
.help("Maximum number to show (default: 3)"),
)
}
pub fn from_argmatches(args: &ArgMatches) -> Self {
let count = args.get_one::<usize>("number").unwrap_or(&3);
Self::new(*count)
}
}
pub fn execute_simple_list_action(
args: &ArgMatches,
selection_order: SortOrder,
) {
let output_order = SortOrder::from_argmatches(args, selection_order);
let output_count = OutputCount::from_argmatches(args);
let list = FileType::TodoTxt.load(args);
let mut outputter = Outputter::from_argmatches(args);
outputter.line_number_digits = list.lines.len().to_string().len();
let selected = selection_order
.sort_items(list.items())
.into_iter()
.filter(|i| i.is_startable() && !i.completion())
.take(output_count.count)
.collect();
for i in output_order.sort_items(selected).iter() {
outputter.write_item(i);
}
}
pub fn maybe_housekeeping_warnings(outputter: &mut Outputter, list: &List) {
let mut done_blank = false;
let count_completed = list.count_completed();
if count_completed > 9 {
if !done_blank {
outputter.write_separator();
done_blank = true;
}
outputter.write_notice(format!(
"There are {count_completed} finished tasks. Consider running `tada archive`."
));
}
let count_blank = list.count_blank();
if count_blank > 9 {
if !done_blank {
outputter.write_separator();
}
outputter.write_notice(format!(
"There are {count_blank} blank/comment lines. Consider running `tada tidy`."
));
}
}