This commit is contained in:
JMARyA 2024-06-07 11:48:16 +02:00
parent a721556902
commit 477ce5ca98
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
9 changed files with 164 additions and 113 deletions

1
Cargo.lock generated
View file

@ -1681,6 +1681,7 @@ dependencies = [
"dirs", "dirs",
"html2text", "html2text",
"moka", "moka",
"once_cell",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -11,6 +11,7 @@ crossterm = "0.27.0"
dirs = "5.0.1" dirs = "5.0.1"
html2text = "0.12.5" html2text = "0.12.5"
moka = { version = "0.12.7", features = ["sync"] } moka = { version = "0.12.7", features = ["sync"] }
once_cell = "1.19.0"
reqwest = { version = "0.12.4", features = ["blocking", "json"] } reqwest = { version = "0.12.4", features = ["blocking", "json"] }
serde = { version = "1.0.203", features = ["derive"] } serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117" serde_json = "1.0.117"

View file

@ -11,6 +11,14 @@ pub use task::Task;
use moka::sync::Cache; use moka::sync::Cache;
use task::TaskRelation; use task::TaskRelation;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VikunjaError {
pub code: Option<isize>,
pub message: String,
}
impl VikunjaError {}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Label { pub struct Label {
pub id: usize, pub id: usize,
@ -155,7 +163,7 @@ impl VikunjaAPI {
serde_json::from_str(&resp).unwrap() 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)); self.delete_request(&format!("/projects/{}", project_id.0));
} }
@ -202,7 +210,6 @@ impl VikunjaAPI {
"hex_color": color "hex_color": color
}), }),
); );
serde_json::from_str(&resp).unwrap() serde_json::from_str(&resp).unwrap()
} }
@ -230,13 +237,13 @@ impl VikunjaAPI {
self.delete_request(&format!("/tasks/{task_id}/labels/{label_id}")); 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 labels = self.get_all_labels();
let label_id = labels let label_id = labels
.into_iter() .into_iter()
.find(|x| x.title.trim() == label) .find(|x| x.title.trim() == label)
.unwrap() .map_or_else(|| Err(format!("Label '{label}' not found")), Ok)?
.id; .id;
self.put_request( self.put_request(
@ -245,6 +252,8 @@ impl VikunjaAPI {
"label_id": label_id "label_id": label_id
}), }),
); );
Ok(())
} }
// tasks // tasks
@ -262,9 +271,9 @@ impl VikunjaAPI {
serde_json::from_str(&resp).unwrap() serde_json::from_str(&resp).unwrap()
} }
pub fn get_task(&self, id: isize) -> Task { pub fn get_task(&self, id: isize) -> Result<Task, ()> {
let resp = self.get_request(&format!("/tasks/{id}")); 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) { pub fn delete_task(&self, id: isize) {
@ -290,7 +299,7 @@ impl VikunjaAPI {
serde_json::from_str(&resp).unwrap() 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<Task> {
let resp = self.post_request( let resp = self.post_request(
&format!("/tasks/{task_id}"), &format!("/tasks/{task_id}"),
&serde_json::json!({ &serde_json::json!({
@ -298,17 +307,18 @@ impl VikunjaAPI {
"done_at": if done { Some(chrono::Utc::now().to_rfc3339()) } else { None } "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<Task> {
let resp = self.post_request( let resp = self.post_request(
&format!("/tasks/{task_id}"), &format!("/tasks/{task_id}"),
&serde_json::json!({ &serde_json::json!({
"is_favorite": fav "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 { pub fn login(&self, username: &str, password: &str, totp: Option<&str>) -> String {
@ -336,8 +346,10 @@ impl VikunjaAPI {
serde_json::from_str(&resp).ok() serde_json::from_str(&resp).ok()
} }
pub fn assign_to_task(&self, user: &str, task_id: isize) { pub fn assign_to_task(&self, user: &str, task_id: isize) -> Result<(), String> {
let user = self.search_user(user).unwrap(); let user = self
.search_user(user)
.map_or_else(|| Err(String::from("User not found")), Ok)?;
self.put_request( self.put_request(
&format!("/tasks/{task_id}/assignees"), &format!("/tasks/{task_id}/assignees"),
@ -345,6 +357,8 @@ impl VikunjaAPI {
"user_id": user.first().unwrap().id "user_id": user.first().unwrap().id
}), }),
); );
Ok(())
} }
pub fn remove_assign_to_task(&self, user: &str, task_id: isize) { pub fn remove_assign_to_task(&self, user: &str, task_id: isize) {
@ -358,7 +372,7 @@ impl VikunjaAPI {
serde_json::from_str(&resp).unwrap() 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!( self.delete_request(&format!(
"/tasks/{task_id}/relations/{}/{other_task_id}", "/tasks/{task_id}/relations/{}/{other_task_id}",
relation.api() relation.api()
@ -368,7 +382,7 @@ impl VikunjaAPI {
pub fn add_relation( pub fn add_relation(
&self, &self,
task_id: isize, task_id: isize,
relation: Relation, relation: &Relation,
other_task_id: isize, other_task_id: isize,
) -> TaskRelation { ) -> TaskRelation {
let resp = self.put_request( let resp = self.put_request(

View file

@ -9,7 +9,7 @@ pub struct Project {
pub description: String, pub description: String,
pub identifier: String, pub identifier: String,
pub hex_color: String, pub hex_color: String,
pub parent_project_id: usize, pub parent_project_id: isize,
pub default_bucket_id: usize, pub default_bucket_id: usize,
pub done_bucket_id: usize, pub done_bucket_id: usize,
pub owner: Option<User>, pub owner: Option<User>,

View file

@ -91,36 +91,36 @@ impl Relation {
pub fn repr(&self) -> String { pub fn repr(&self) -> String {
match self { match self {
Relation::Unknown => "Unknown", Self::Unknown => "Unknown",
Relation::Subtask => "Subtask", Self::Subtask => "Subtask",
Relation::ParentTask => "Parent Task", Self::ParentTask => "Parent Task",
Relation::Related => "Related", Self::Related => "Related",
Relation::DuplicateOf => "Duplicate of", Self::DuplicateOf => "Duplicate of",
Relation::Duplicates => "Duplicates", Self::Duplicates => "Duplicates",
Relation::Blocking => "Blocking", Self::Blocking => "Blocking",
Relation::Blocked => "Blocked by", Self::Blocked => "Blocked by",
Relation::Precedes => "Precedes", Self::Precedes => "Precedes",
Relation::Follows => "Follows", Self::Follows => "Follows",
Relation::CopiedFrom => "Copied from", Self::CopiedFrom => "Copied from",
Relation::CopiedTo => "Copied to", Self::CopiedTo => "Copied to",
} }
.to_string() .to_string()
} }
pub fn api(&self) -> String { pub fn api(&self) -> String {
match self { match self {
Relation::Unknown => "unknown", Self::Unknown => "unknown",
Relation::Subtask => "subtask", Self::Subtask => "subtask",
Relation::ParentTask => "parenttask", Self::ParentTask => "parenttask",
Relation::Related => "related", Self::Related => "related",
Relation::DuplicateOf => "duplicateof", Self::DuplicateOf => "duplicateof",
Relation::Duplicates => "duplicates", Self::Duplicates => "duplicates",
Relation::Blocking => "blocking", Self::Blocking => "blocking",
Relation::Blocked => "blocked", Self::Blocked => "blocked",
Relation::Precedes => "precedes", Self::Precedes => "precedes",
Relation::Follows => "follows", Self::Follows => "follows",
Relation::CopiedFrom => "copiedfrom", Self::CopiedFrom => "copiedfrom",
Relation::CopiedTo => "copiedto", Self::CopiedTo => "copiedto",
} }
.to_string() .to_string()
} }

View file

@ -3,14 +3,17 @@ mod args;
mod config; mod config;
mod ui; mod ui;
use std::path::PathBuf;
use api::{ProjectID, Relation, VikunjaAPI}; use api::{ProjectID, Relation, VikunjaAPI};
use clap::ArgMatches;
use once_cell::sync::Lazy;
use ui::{hex_to_color, print_color};
// todo : error handling static CONFIG_PATH: Lazy<PathBuf> =
Lazy::new(|| dirs::home_dir().unwrap().join(".config").join("vk.toml"));
fn main() {
let arg = args::get_args();
let config_path = dirs::home_dir().unwrap().join(".config").join("vk.toml");
fn login_cmd(arg: &ArgMatches) {
if let Some(("login", login_arg)) = arg.subcommand() { if let Some(("login", login_arg)) = arg.subcommand() {
let username: &String = login_arg.get_one("username").unwrap(); let username: &String = login_arg.get_one("username").unwrap();
let password: &String = login_arg.get_one("password").unwrap(); let password: &String = login_arg.get_one("password").unwrap();
@ -25,14 +28,75 @@ fn main() {
let api = VikunjaAPI::new(&host, ""); 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}\""); 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); 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( ui::print_color(
crossterm::style::Color::Red, crossterm::style::Color::Red,
&format!("Could not read config file: {e}"), &format!("Could not read config file: {e}"),
@ -41,7 +105,15 @@ fn main() {
std::process::exit(1); 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); let api = VikunjaAPI::new(&config.host, &config.token);
match arg.subcommand() { match arg.subcommand() {
@ -49,30 +121,7 @@ fn main() {
let task_id: &String = task_info_arg.get_one("task_id").unwrap(); let task_id: &String = task_info_arg.get_one("task_id").unwrap();
ui::task::print_task_info(task_id.parse().unwrap(), &api); ui::task::print_task_info(task_id.parse().unwrap(), &api);
} }
Some(("prj", prj_arg)) => match prj_arg.subcommand() { Some(("prj", prj_arg)) => project_commands(prj_arg, &api),
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(("rm", rm_args)) => { Some(("rm", rm_args)) => {
let task_id: &String = rm_args.get_one("task_id").unwrap(); let task_id: &String = rm_args.get_one("task_id").unwrap();
api.delete_task(task_id.parse().unwrap()); api.delete_task(task_id.parse().unwrap());
@ -84,8 +133,9 @@ fn main() {
if undo { if undo {
api.remove_assign_to_task(user, task_id.parse().unwrap()); api.remove_assign_to_task(user, task_id.parse().unwrap());
} else { } else if let Err(msg) = api.assign_to_task(user, task_id.parse().unwrap()) {
api.assign_to_task(user, task_id.parse().unwrap()); print_color(crossterm::style::Color::Red, &msg);
println!();
} }
} }
Some(("comments", c_arg)) => { Some(("comments", c_arg)) => {
@ -102,27 +152,7 @@ fn main() {
api.new_comment(task_id.parse().unwrap(), comment); api.new_comment(task_id.parse().unwrap(), comment);
} }
Some(("labels", label_args)) => match label_args.subcommand() { Some(("labels", label_args)) => label_commands(label_args, &api),
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(("label", label_args)) => { Some(("label", label_args)) => {
let label: &String = label_args.get_one("label").unwrap(); let label: &String = label_args.get_one("label").unwrap();
let task_id: &String = label_args.get_one("task_id").unwrap(); let task_id: &String = label_args.get_one("task_id").unwrap();
@ -130,9 +160,12 @@ fn main() {
if undo { if undo {
api.label_task_remove(label, task_id.parse().unwrap()); api.label_task_remove(label, task_id.parse().unwrap());
} else { } else if let Err(msg) = api.label_task(label, task_id.parse().unwrap()) {
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)) => { Some(("new", new_task_arg)) => {
let title: &String = new_task_arg.get_one("title").unwrap(); 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 sec_task_id: &String = rel_args.get_one("second_task_id").unwrap();
let delete = rel_args.get_flag("delete"); let delete = rel_args.get_flag("delete");
let relation = Relation::try_parse(&relation).unwrap(); let relation = Relation::try_parse(relation).unwrap();
if delete { if delete {
api.remove_relation( api.remove_relation(
task_id.parse().unwrap(), task_id.parse().unwrap(),
relation, &relation,
sec_task_id.parse().unwrap(), sec_task_id.parse().unwrap(),
); );
} else { } else {
api.add_relation( api.add_relation(
task_id.parse().unwrap(), task_id.parse().unwrap(),
relation, &relation,
sec_task_id.parse().unwrap(), sec_task_id.parse().unwrap(),
); );
} }

View file

@ -32,7 +32,7 @@ pub fn print_color_bg(color: Color, txt: &str) {
} }
/// Convert a HEX Color String into a `Color` struct /// Convert a HEX Color String into a `Color` struct
fn hex_to_color(hex: &str) -> Result<Color, String> { pub fn hex_to_color(hex: &str) -> Result<Color, String> {
let hex = hex.trim_start_matches('#'); let hex = hex.trim_start_matches('#');
if hex.len() != 6 { if hex.len() != 6 {
@ -78,15 +78,14 @@ pub fn time_relative(event: DateTime<Utc>) -> String {
}; };
if is_past { if is_past {
format!("{} ago", time_string) format!("{time_string} ago")
} else { } else {
format!("in {}", time_string) format!("in {time_string}")
} }
} }
fn is_in_past(dt: DateTime<Utc>) -> bool { fn is_in_past(dt: DateTime<Utc>) -> bool {
let now = Utc::now(); dt < Utc::now()
dt < now
} }
fn print_label(label: &Label) { fn print_label(label: &Label) {

View file

@ -10,7 +10,7 @@ use crate::{
pub fn list_projects(api: &VikunjaAPI) { pub fn list_projects(api: &VikunjaAPI) {
let projects = api.get_all_projects(); let projects = api.get_all_projects();
let mut project_map: HashMap<usize, Vec<Project>> = HashMap::new(); let mut project_map: HashMap<isize, Vec<Project>> = HashMap::new();
for prj in projects { for prj in projects {
project_map project_map
@ -28,7 +28,7 @@ pub fn list_projects(api: &VikunjaAPI) {
print_color(color, &prj.title); print_color(color, &prj.title);
println!(" [{}]", prj.id); 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 { for sub_prj in sub_projects {
let color = if sub_prj.hex_color.is_empty() { let color = if sub_prj.hex_color.is_empty() {
Color::Reset Color::Reset

View file

@ -88,18 +88,21 @@ pub fn print_current_tasks(
} }
pub fn print_task_info(task_id: isize, api: &VikunjaAPI) { 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 { if task.done {
print_color( print_color(
crossterm::style::Color::Green, crossterm::style::Color::Green,
&format!( &format!(
"{} ✓ ", "{} ✓ ",
if let Some(dt) = parse_datetime(&task.done_at) { parse_datetime(&task.done_at).map_or_else(String::new, time_relative)
time_relative(dt)
} else {
String::new()
}
), ),
); );
} }