diff --git a/Cargo.lock b/Cargo.lock index c85a573..16af043 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,7 @@ dependencies = [ "dirs", "html2text", "moka", + "once_cell", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index f825df1..cfff14d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ crossterm = "0.27.0" dirs = "5.0.1" html2text = "0.12.5" moka = { version = "0.12.7", features = ["sync"] } +once_cell = "1.19.0" reqwest = { version = "0.12.4", features = ["blocking", "json"] } serde = { version = "1.0.203", features = ["derive"] } serde_json = "1.0.117" diff --git a/src/api/mod.rs b/src/api/mod.rs index 1c01bfa..70b1be9 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,6 +11,14 @@ pub use task::Task; use moka::sync::Cache; use task::TaskRelation; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VikunjaError { + pub code: Option, + pub message: String, +} + +impl VikunjaError {} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Label { pub id: usize, @@ -155,7 +163,7 @@ impl VikunjaAPI { serde_json::from_str(&resp).unwrap() } - pub fn delete_project(&self, project_id: ProjectID) { + pub fn delete_project(&self, project_id: &ProjectID) { self.delete_request(&format!("/projects/{}", project_id.0)); } @@ -202,7 +210,6 @@ impl VikunjaAPI { "hex_color": color }), ); - serde_json::from_str(&resp).unwrap() } @@ -230,13 +237,13 @@ impl VikunjaAPI { self.delete_request(&format!("/tasks/{task_id}/labels/{label_id}")); } - pub fn label_task(&self, label: &str, task_id: isize) { + pub fn label_task(&self, label: &str, task_id: isize) -> Result<(), String> { let labels = self.get_all_labels(); let label_id = labels .into_iter() .find(|x| x.title.trim() == label) - .unwrap() + .map_or_else(|| Err(format!("Label '{label}' not found")), Ok)? .id; self.put_request( @@ -245,6 +252,8 @@ impl VikunjaAPI { "label_id": label_id }), ); + + Ok(()) } // tasks @@ -262,9 +271,9 @@ impl VikunjaAPI { serde_json::from_str(&resp).unwrap() } - pub fn get_task(&self, id: isize) -> Task { + pub fn get_task(&self, id: isize) -> Result { let resp = self.get_request(&format!("/tasks/{id}")); - serde_json::from_str(&resp).unwrap() + serde_json::from_str(&resp).map_or(Err(()), Ok) } pub fn delete_task(&self, id: isize) { @@ -290,7 +299,7 @@ impl VikunjaAPI { serde_json::from_str(&resp).unwrap() } - pub fn done_task(&self, task_id: isize, done: bool) -> Task { + pub fn done_task(&self, task_id: isize, done: bool) -> Option { let resp = self.post_request( &format!("/tasks/{task_id}"), &serde_json::json!({ @@ -298,17 +307,18 @@ impl VikunjaAPI { "done_at": if done { Some(chrono::Utc::now().to_rfc3339()) } else { None } }), ); - serde_json::from_str(&resp).unwrap() + serde_json::from_str(&resp).ok() } - pub fn fav_task(&self, task_id: isize, fav: bool) -> Task { + pub fn fav_task(&self, task_id: isize, fav: bool) -> Option { let resp = self.post_request( &format!("/tasks/{task_id}"), &serde_json::json!({ "is_favorite": fav }), ); - serde_json::from_str(&resp).unwrap() + + serde_json::from_str(&resp).ok() } pub fn login(&self, username: &str, password: &str, totp: Option<&str>) -> String { @@ -336,8 +346,10 @@ impl VikunjaAPI { serde_json::from_str(&resp).ok() } - pub fn assign_to_task(&self, user: &str, task_id: isize) { - let user = self.search_user(user).unwrap(); + pub fn assign_to_task(&self, user: &str, task_id: isize) -> Result<(), String> { + let user = self + .search_user(user) + .map_or_else(|| Err(String::from("User not found")), Ok)?; self.put_request( &format!("/tasks/{task_id}/assignees"), @@ -345,6 +357,8 @@ impl VikunjaAPI { "user_id": user.first().unwrap().id }), ); + + Ok(()) } pub fn remove_assign_to_task(&self, user: &str, task_id: isize) { @@ -358,7 +372,7 @@ impl VikunjaAPI { serde_json::from_str(&resp).unwrap() } - pub fn remove_relation(&self, task_id: isize, relation: Relation, other_task_id: isize) { + pub fn remove_relation(&self, task_id: isize, relation: &Relation, other_task_id: isize) { self.delete_request(&format!( "/tasks/{task_id}/relations/{}/{other_task_id}", relation.api() @@ -368,7 +382,7 @@ impl VikunjaAPI { pub fn add_relation( &self, task_id: isize, - relation: Relation, + relation: &Relation, other_task_id: isize, ) -> TaskRelation { let resp = self.put_request( diff --git a/src/api/project.rs b/src/api/project.rs index 0cfbf77..9a135f8 100644 --- a/src/api/project.rs +++ b/src/api/project.rs @@ -9,7 +9,7 @@ pub struct Project { pub description: String, pub identifier: String, pub hex_color: String, - pub parent_project_id: usize, + pub parent_project_id: isize, pub default_bucket_id: usize, pub done_bucket_id: usize, pub owner: Option, diff --git a/src/api/task.rs b/src/api/task.rs index 91d636a..52c64d4 100644 --- a/src/api/task.rs +++ b/src/api/task.rs @@ -91,36 +91,36 @@ impl Relation { pub fn repr(&self) -> String { match self { - Relation::Unknown => "Unknown", - Relation::Subtask => "Subtask", - Relation::ParentTask => "Parent Task", - Relation::Related => "Related", - Relation::DuplicateOf => "Duplicate of", - Relation::Duplicates => "Duplicates", - Relation::Blocking => "Blocking", - Relation::Blocked => "Blocked by", - Relation::Precedes => "Precedes", - Relation::Follows => "Follows", - Relation::CopiedFrom => "Copied from", - Relation::CopiedTo => "Copied to", + Self::Unknown => "Unknown", + Self::Subtask => "Subtask", + Self::ParentTask => "Parent Task", + Self::Related => "Related", + Self::DuplicateOf => "Duplicate of", + Self::Duplicates => "Duplicates", + Self::Blocking => "Blocking", + Self::Blocked => "Blocked by", + Self::Precedes => "Precedes", + Self::Follows => "Follows", + Self::CopiedFrom => "Copied from", + Self::CopiedTo => "Copied to", } .to_string() } pub fn api(&self) -> String { match self { - Relation::Unknown => "unknown", - Relation::Subtask => "subtask", - Relation::ParentTask => "parenttask", - Relation::Related => "related", - Relation::DuplicateOf => "duplicateof", - Relation::Duplicates => "duplicates", - Relation::Blocking => "blocking", - Relation::Blocked => "blocked", - Relation::Precedes => "precedes", - Relation::Follows => "follows", - Relation::CopiedFrom => "copiedfrom", - Relation::CopiedTo => "copiedto", + Self::Unknown => "unknown", + Self::Subtask => "subtask", + Self::ParentTask => "parenttask", + Self::Related => "related", + Self::DuplicateOf => "duplicateof", + Self::Duplicates => "duplicates", + Self::Blocking => "blocking", + Self::Blocked => "blocked", + Self::Precedes => "precedes", + Self::Follows => "follows", + Self::CopiedFrom => "copiedfrom", + Self::CopiedTo => "copiedto", } .to_string() } diff --git a/src/main.rs b/src/main.rs index 72555ca..cc5edf2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,14 +3,17 @@ mod args; mod config; mod ui; +use std::path::PathBuf; + use api::{ProjectID, Relation, VikunjaAPI}; +use clap::ArgMatches; +use once_cell::sync::Lazy; +use ui::{hex_to_color, print_color}; -// todo : error handling - -fn main() { - let arg = args::get_args(); - let config_path = dirs::home_dir().unwrap().join(".config").join("vk.toml"); +static CONFIG_PATH: Lazy = + Lazy::new(|| dirs::home_dir().unwrap().join(".config").join("vk.toml")); +fn login_cmd(arg: &ArgMatches) { if let Some(("login", login_arg)) = arg.subcommand() { let username: &String = login_arg.get_one("username").unwrap(); let password: &String = login_arg.get_one("password").unwrap(); @@ -25,14 +28,75 @@ fn main() { let api = VikunjaAPI::new(&host, ""); - let token = api.login(username, password, totp.map(|x| x.as_str())); + let token = api.login(username, password, totp.map(std::string::String::as_str)); let config = format!("host = \"{host}\"\ntoken = \"{token}\""); - std::fs::write(config_path, config).unwrap(); + std::fs::write(CONFIG_PATH.clone(), config).unwrap(); std::process::exit(0); } +} - let content = &std::fs::read_to_string(config_path).unwrap_or_else(|e| { +fn project_commands(arg: &ArgMatches, api: &VikunjaAPI) { + match arg.subcommand() { + Some(("add", add_prj_arg)) => { + let title: &String = add_prj_arg.get_one("title").unwrap(); + let description: Option<&String> = add_prj_arg.get_one("description"); + let color: Option<&String> = add_prj_arg.get_one("color"); + let parent: Option<&String> = add_prj_arg.get_one("parent"); + api.new_project( + title, + description.map(std::string::String::as_str), + color.map(std::string::String::as_str), + parent.map(|x| ProjectID::parse(api, x).unwrap()), + ); + } + Some(("rm", rm_prj_arg)) => { + let prj: &String = rm_prj_arg.get_one("project").unwrap(); + api.delete_project(&ProjectID::parse(api, prj).unwrap()); + } + _ => { + ui::project::list_projects(api); + } + } +} + +fn label_commands(arg: &ArgMatches, api: &VikunjaAPI) { + match arg.subcommand() { + Some(("rm", rm_label_arg)) => { + let title: &String = rm_label_arg.get_one("title").unwrap(); + + api.remove_label(title); + } + Some(("new", new_label_arg)) => { + let description: Option<&String> = new_label_arg.get_one("description"); + let color: Option<&String> = new_label_arg.get_one("color"); + let title: &String = new_label_arg.get_one("title").unwrap(); + + if let Some(color) = color { + if hex_to_color(color).is_err() { + print_color( + crossterm::style::Color::Red, + &format!("'{color}' is no hex color"), + ); + println!(); + std::process::exit(1); + } + } + + api.new_label( + title.as_str(), + description.map(std::string::String::as_str), + color.map(std::string::String::as_str), + ); + } + _ => { + ui::print_all_labels(api); + } + } +} + +fn load_config() -> config::Config { + let content = &std::fs::read_to_string(CONFIG_PATH.clone()).unwrap_or_else(|e| { ui::print_color( crossterm::style::Color::Red, &format!("Could not read config file: {e}"), @@ -41,7 +105,15 @@ fn main() { std::process::exit(1); }); - let config: config::Config = toml::from_str(content).unwrap(); + toml::from_str(content).unwrap() +} + +fn main() { + let arg = args::get_args(); + + login_cmd(&arg); + + let config = load_config(); let api = VikunjaAPI::new(&config.host, &config.token); match arg.subcommand() { @@ -49,30 +121,7 @@ fn main() { let task_id: &String = task_info_arg.get_one("task_id").unwrap(); ui::task::print_task_info(task_id.parse().unwrap(), &api); } - Some(("prj", prj_arg)) => match prj_arg.subcommand() { - Some(("ls", _)) => { - ui::project::list_projects(&api); - } - Some(("add", add_prj_arg)) => { - let title: &String = add_prj_arg.get_one("title").unwrap(); - let description: Option<&String> = add_prj_arg.get_one("description"); - let color: Option<&String> = add_prj_arg.get_one("color"); - let parent: Option<&String> = add_prj_arg.get_one("parent"); - api.new_project( - title, - description.map(|x| x.as_str()), - color.map(|x| x.as_str()), - parent.map(|x| ProjectID::parse(&api, x).unwrap()), - ); - } - Some(("rm", rm_prj_arg)) => { - let prj: &String = rm_prj_arg.get_one("project").unwrap(); - api.delete_project(ProjectID::parse(&api, prj).unwrap()); - } - _ => { - ui::project::list_projects(&api); - } - }, + Some(("prj", prj_arg)) => project_commands(prj_arg, &api), Some(("rm", rm_args)) => { let task_id: &String = rm_args.get_one("task_id").unwrap(); api.delete_task(task_id.parse().unwrap()); @@ -84,8 +133,9 @@ fn main() { if undo { api.remove_assign_to_task(user, task_id.parse().unwrap()); - } else { - api.assign_to_task(user, task_id.parse().unwrap()); + } else if let Err(msg) = api.assign_to_task(user, task_id.parse().unwrap()) { + print_color(crossterm::style::Color::Red, &msg); + println!(); } } Some(("comments", c_arg)) => { @@ -102,27 +152,7 @@ fn main() { api.new_comment(task_id.parse().unwrap(), comment); } - Some(("labels", label_args)) => match label_args.subcommand() { - Some(("rm", rm_label_arg)) => { - let title: &String = rm_label_arg.get_one("title").unwrap(); - - api.remove_label(title); - } - Some(("new", new_label_arg)) => { - let description: Option<&String> = new_label_arg.get_one("description"); - let color: Option<&String> = new_label_arg.get_one("color"); - let title: &String = new_label_arg.get_one("title").unwrap(); - - api.new_label( - title.as_str(), - description.map(|x| x.as_str()), - color.map(|x| x.as_str()), - ); - } - _ => { - ui::print_all_labels(&api); - } - }, + Some(("labels", label_args)) => label_commands(label_args, &api), Some(("label", label_args)) => { let label: &String = label_args.get_one("label").unwrap(); let task_id: &String = label_args.get_one("task_id").unwrap(); @@ -130,9 +160,12 @@ fn main() { if undo { api.label_task_remove(label, task_id.parse().unwrap()); - } else { - api.label_task(label, task_id.parse().unwrap()); + } else if let Err(msg) = api.label_task(label, task_id.parse().unwrap()) { + print_color(crossterm::style::Color::Red, &msg); + println!(); + std::process::exit(1); } + ui::task::print_task_info(task_id.parse().unwrap(), &api); } Some(("new", new_task_arg)) => { let title: &String = new_task_arg.get_one("title").unwrap(); @@ -160,18 +193,18 @@ fn main() { let sec_task_id: &String = rel_args.get_one("second_task_id").unwrap(); let delete = rel_args.get_flag("delete"); - let relation = Relation::try_parse(&relation).unwrap(); + let relation = Relation::try_parse(relation).unwrap(); if delete { api.remove_relation( task_id.parse().unwrap(), - relation, + &relation, sec_task_id.parse().unwrap(), ); } else { api.add_relation( task_id.parse().unwrap(), - relation, + &relation, sec_task_id.parse().unwrap(), ); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2d1f923..32b7299 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -32,7 +32,7 @@ pub fn print_color_bg(color: Color, txt: &str) { } /// Convert a HEX Color String into a `Color` struct -fn hex_to_color(hex: &str) -> Result { +pub fn hex_to_color(hex: &str) -> Result { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { @@ -78,15 +78,14 @@ pub fn time_relative(event: DateTime) -> String { }; if is_past { - format!("{} ago", time_string) + format!("{time_string} ago") } else { - format!("in {}", time_string) + format!("in {time_string}") } } fn is_in_past(dt: DateTime) -> bool { - let now = Utc::now(); - dt < now + dt < Utc::now() } fn print_label(label: &Label) { diff --git a/src/ui/project.rs b/src/ui/project.rs index 2b22899..d0c2d1e 100644 --- a/src/ui/project.rs +++ b/src/ui/project.rs @@ -10,7 +10,7 @@ use crate::{ pub fn list_projects(api: &VikunjaAPI) { let projects = api.get_all_projects(); - let mut project_map: HashMap> = HashMap::new(); + let mut project_map: HashMap> = HashMap::new(); for prj in projects { project_map @@ -28,7 +28,7 @@ pub fn list_projects(api: &VikunjaAPI) { print_color(color, &prj.title); println!(" [{}]", prj.id); - if let Some(sub_projects) = project_map.get(&(prj.id as usize)) { + if let Some(sub_projects) = project_map.get(&(prj.id)) { for sub_prj in sub_projects { let color = if sub_prj.hex_color.is_empty() { Color::Reset diff --git a/src/ui/task.rs b/src/ui/task.rs index aa10638..9e51667 100644 --- a/src/ui/task.rs +++ b/src/ui/task.rs @@ -88,18 +88,21 @@ pub fn print_current_tasks( } pub fn print_task_info(task_id: isize, api: &VikunjaAPI) { - let task = api.get_task(task_id); + let task = api.get_task(task_id).unwrap_or_else(|()| { + print_color( + crossterm::style::Color::Red, + &format!("Could not get task #{task_id}"), + ); + println!(); + std::process::exit(1); + }); if task.done { print_color( crossterm::style::Color::Green, &format!( "{} ✓ ", - if let Some(dt) = parse_datetime(&task.done_at) { - time_relative(dt) - } else { - String::new() - } + parse_datetime(&task.done_at).map_or_else(String::new, time_relative) ), ); }