#![deny(warnings)]
use anyhow::bail;
use clap::Parser;
#[cfg(feature = "cache")]
use etcetera::{AppStrategy, AppStrategyArgs};
use log::{info, trace};
#[cfg(feature = "cache")]
use rememberthemilk::cache::TaskCache;
use rememberthemilk::{Perms, API};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write;
use std::process::ExitCode;
const RTM_APP_NAME: &str = "rtm";
const RTM_AUTH_ID: &str = "rtm_auth";
const RTM_SETTINGS: &str = "config";
#[derive(Serialize, Deserialize)]
pub struct Settings {
pub filter: String,
}
impl Default for Settings {
fn default() -> Self {
Settings {
filter: "status:incomplete AND (dueBefore:today OR due:today)".into(),
}
}
}
#[derive(Parser, Debug)]
enum Command {
Tasks {
#[clap(long)]
filter: Option<String>,
#[clap(long)]
extid: Option<String>,
},
Lists,
AddTag {
tag: String,
#[clap(long)]
filter: String,
},
AddTask {
name: String,
#[clap(long)]
external_id: Option<String>,
},
AuthApp {
key: String,
secret: String,
#[clap(default_value = "read", long)]
perm: Perms,
},
Methods,
#[cfg(feature = "cache")]
Sync,
#[cfg(feature = "tui")]
Tui,
Logout,
}
#[derive(Copy, Clone, Debug)]
enum ColourOption {
Auto,
Always,
Never,
}
impl std::str::FromStr for ColourOption {
type Err = &'static str;
fn from_str(s: &str) -> Result<ColourOption, &'static str> {
match s {
"auto" => Ok(ColourOption::Auto),
"always" => Ok(ColourOption::Always),
"never" => Ok(ColourOption::Never),
_ => Err("Invalid option for --colour"),
}
}
}
#[derive(Parser, Debug)]
struct Opt {
#[clap(short, long)]
verbose: bool,
#[clap(short, long)]
smart: bool,
#[clap(default_value = "auto", long)]
colour: ColourOption,
#[clap(subcommand)]
cmd: Command,
}
impl Opt {
fn get_stdout(&self) -> termcolor::StandardStream {
use termcolor::ColorChoice;
let choice = match self.colour {
ColourOption::Auto => ColorChoice::Auto,
ColourOption::Always => ColorChoice::Always,
ColourOption::Never => ColorChoice::Never,
};
termcolor::StandardStream::stdout(choice)
}
}
async fn get_rtm_api(perm: Perms) -> Result<API, anyhow::Error> {
trace!("Loading API configuration...");
let config: rememberthemilk::RTMConfig = confy::load(RTM_APP_NAME, Some(RTM_AUTH_ID))?;
let mut api = if config.api_key.is_some() && config.api_secret.is_some() {
info!("Creating API object.");
API::from_config(config)
} else {
eprintln!("Error, no API key saved. Use `rtm auth-app` to supply them.");
bail!("No auth key");
};
trace!("Checking API permissions...");
if !api.has_token(perm).await.unwrap() {
info!("Token doesn't have {perm:?} permission, authenticating...");
println!("We don't have the correct permissions - trying to authenticate.");
auth_user(&mut api, perm).await?;
};
Ok(api)
}
#[cfg(feature = "cache")]
async fn get_rtm_cache(api: API) -> Result<TaskCache, anyhow::Error> {
let strategy = etcetera::choose_app_strategy(AppStrategyArgs {
top_level_domain: "org".into(),
author: "Chris Emerson".into(),
app_name: "rtm".into(),
})
.unwrap();
let cache_dir = strategy.cache_dir();
std::fs::create_dir_all(&cache_dir)?;
let db_path = strategy.in_cache_dir("sync.sqlite");
Ok(TaskCache::new(&db_path, api).await?)
}
async fn auth_user(api: &mut API, perm: Perms) -> Result<(), anyhow::Error> {
let auth = api.start_auth(perm).await?;
println!("auth_url: {}", auth.url);
println!("Press enter when authorised...");
{
use std::io::BufRead;
let stdin = std::io::stdin();
let mut lines = stdin.lock().lines();
lines.next().unwrap().unwrap();
}
if !api.check_auth(&auth).await? {
bail!("Error authenticating");
}
confy::store(RTM_APP_NAME, Some(RTM_AUTH_ID), api.to_config())?;
Ok(())
}
async fn auth_app(key: String, secret: String, perm: Perms) -> Result<ExitCode, anyhow::Error> {
let mut api = API::new(key, secret);
auth_user(&mut api, perm).await?;
println!("Successfully authenticated.");
Ok(ExitCode::SUCCESS)
}
async fn logout() -> Result<ExitCode, anyhow::Error> {
let mut config: rememberthemilk::RTMConfig = confy::load(RTM_APP_NAME, Some(RTM_AUTH_ID))?;
config.clear_user_data();
confy::store(RTM_APP_NAME, Some(RTM_AUTH_ID), config)?;
Ok(ExitCode::SUCCESS)
}
async fn get_methods(_opts: &Opt) -> Result<ExitCode, anyhow::Error> {
let api = get_rtm_api(Perms::Read).await?;
let methods = api.get_methods().await?;
for method in methods {
println!("{method}");
}
Ok(ExitCode::SUCCESS)
}
fn format_human_time(secs: u64) -> String {
if secs > 24 * 60 * 60 {
let days = secs / (24 * 60 * 60);
format!("{} day{}", days, if days > 1 { "s" } else { "" })
} else if secs > 60 * 60 {
let hours = secs / (60 * 60);
format!("{} hour{}", hours, if hours > 1 { "s" } else { "" })
} else if secs > 60 {
let minutes = secs / 60;
format!("{} minute{}", minutes, if minutes > 1 { "s" } else { "" })
} else {
format!("{} sec{}", secs, if secs > 1 { "s" } else { "" })
}
}
fn get_default_filter() -> Result<String, anyhow::Error> {
let settings: Settings = confy::load(RTM_APP_NAME, RTM_SETTINGS)?;
Ok(settings.filter)
}
async fn list_tasks(
opts: &Opt,
filter: &Option<String>,
extid: &Option<String>,
) -> Result<ExitCode, anyhow::Error> {
let api = get_rtm_api(Perms::Read).await?;
let default_filter = get_default_filter()?;
let extid_filter;
let filter = match (filter, extid) {
(Some(ref s), None) => &s[..],
(None, Some(ref s)) => {
extid_filter = api.get_filter_extid(s);
&extid_filter[..]
}
(Some(_), Some(_)) => {
bail!("Supplying both --filter and --extid is not supported.")
}
(None, None) => &default_filter,
};
let all_tasks = api.get_tasks_filtered(filter).await?;
let mut lists = HashMap::new();
if !all_tasks.list.is_empty() {
let all_lists = api.get_lists().await?;
for list in all_lists {
lists.insert(list.id.clone(), list);
}
}
use termcolor::{Color, ColorSpec, WriteColor};
if all_tasks.list.is_empty() {
return Ok(ExitCode::from(1));
}
let mut stdout = opts.get_stdout();
for list in all_tasks.list {
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Magenta)))?;
writeln!(stdout, "#{}", lists[&list.id].name)?;
if let Some(v) = list.taskseries {
stdout.reset()?;
for ts in v {
log::trace!("{:?}", ts.task);
for task in &ts.task {
let time_left = task.get_time_left();
use rememberthemilk::TimeLeft::*;
match time_left {
Remaining(secs) => {
let colour = if secs < 60 * 60 {
ColorSpec::new().set_fg(Some(Color::Red)).clone()
} else {
ColorSpec::new().set_fg(Some(Color::Yellow)).clone()
};
stdout.set_color(&colour)?;
write!(stdout, "{}", format_human_time(secs))?;
}
Overdue(secs) => {
stdout.set_color(ColorSpec::new().set_bg(Some(Color::Red)))?;
write!(stdout, "{} ago", format_human_time(secs))?;
}
Completed | NoDue => {
ColorSpec::new().set_fg(Some(Color::Green));
}
};
}
write!(stdout, " {}", ts.name)?;
stdout.set_color(ColorSpec::new().set_bg(Some(Color::Black)))?;
writeln!(stdout)?;
if opts.verbose {
writeln!(stdout, " id: {}", ts.id)?;
writeln!(stdout, " created: {}", ts.created)?;
writeln!(stdout, " modified: {}", ts.modified)?;
writeln!(stdout, " tags: {:?}", &ts.tags[..])?;
if let Some(repeat) = ts.repeat {
if repeat.every {
writeln!(stdout, " repeat: every {}", repeat.rule)?;
} else {
writeln!(stdout, " repeat: after {}", repeat.rule)?;
}
}
}
if opts.verbose && !ts.task.is_empty() {
let task = &ts.task[0];
writeln!(stdout, " id: {}", task.id)?;
if let Some(due) = task.due {
if task.has_due_time {
writeln!(stdout, " due: {}", due)?;
} else {
writeln!(stdout, " due: {}", due.date_naive())?;
}
}
if let Some(added) = task.added {
writeln!(stdout, " added: {}", added)?;
}
if let Some(completed) = task.completed {
writeln!(stdout, " completed: {}", completed)?;
}
if let Some(deleted) = task.deleted {
writeln!(stdout, " deleted: {}", deleted)?;
}
}
}
}
}
stdout.reset()?;
Ok(ExitCode::SUCCESS)
}
async fn list_lists() -> Result<ExitCode, anyhow::Error> {
let api = get_rtm_api(Perms::Read).await?;
let all_lists = api.get_lists().await?;
for list in all_lists {
println!("{}", list.name);
}
Ok(ExitCode::SUCCESS)
}
async fn add_tag(filter: String, tag: String) -> Result<ExitCode, anyhow::Error> {
let api = get_rtm_api(Perms::Write).await?;
let timeline = api.get_timeline().await?;
let tasks = api.get_tasks_filtered(&filter).await?;
for list in tasks.list {
if let Some(ref v) = list.taskseries {
for ts in v {
let to_tag = !ts.tags.contains(&tag);
if to_tag {
println!(" Adding tag to {}...", ts.name);
api.add_tag(&timeline, &list, ts, &ts.task[0], &[&tag[..]])
.await?;
}
}
}
}
Ok(ExitCode::SUCCESS)
}
async fn add_task(
opt: &Opt,
name: &str,
external_id: Option<&str>,
) -> Result<ExitCode, anyhow::Error> {
let api = get_rtm_api(Perms::Write).await?;
let timeline = api.get_timeline().await?;
let added = api
.add_task(&timeline, name, None, None, external_id, opt.smart)
.await?;
if let Some(list) = added {
if let Some(taskseries) = list.taskseries {
if !taskseries.is_empty() {
print_taskseries(&taskseries[0]);
} else {
println!("Successful result, but no task in series.")
}
} else {
println!("Successful result, but no task series.")
}
} else {
println!("Successful result, but no list returned.")
}
Ok(ExitCode::SUCCESS)
}
fn print_taskseries(task: &rememberthemilk::TaskSeries) {
println!("Added task id {}", task.id);
println!("Name: {}", task.name);
println!("Tags: {}", task.tags.join(", "));
for task in &task.task {
if task.completed.is_none() {
println!(" Due: {:?}", task.due);
}
}
}
#[cfg(feature = "cache")]
async fn run_sync(_opt: &Opt) -> Result<ExitCode, anyhow::Error> {
let cache = get_rtm_cache(get_rtm_api(Perms::Read).await?).await?;
cache.sync().await?;
Ok(ExitCode::SUCCESS)
}
#[cfg(feature = "tui")]
mod tui;
#[tokio::main]
async fn main() -> Result<ExitCode, anyhow::Error> {
env_logger::init();
#[cfg(feature = "console-subscriber")]
console_subscriber::init();
let opt = Opt::parse();
Ok(match opt.cmd {
Command::Tasks {
ref filter,
ref extid,
} => list_tasks(&opt, filter, extid).await?,
Command::Lists => list_lists().await?,
Command::AddTag { filter, tag } => add_tag(filter, tag).await?,
Command::AddTask {
ref name,
ref external_id,
} => add_task(&opt, name, external_id.as_deref()).await?,
Command::AuthApp { key, secret, perm } => auth_app(key, secret, perm).await?,
Command::Methods => get_methods(&opt).await?,
#[cfg(feature = "cache")]
Command::Sync => run_sync(&opt).await?,
#[cfg(feature = "tui")]
Command::Tui => tui::tui().await?,
Command::Logout => logout().await?,
})
}