Compare commits
10 commits
d038923726
...
a57b417c5e
Author | SHA1 | Date | |
---|---|---|---|
a57b417c5e | |||
dc2b3a3d0e | |||
53b517876c | |||
054249aa79 | |||
312aed18c8 | |||
477ce5ca98 | |||
a721556902 | |||
41524a18af | |||
7c6efb4755 | |||
8c6310eb61 |
11 changed files with 908 additions and 330 deletions
614
Cargo.lock
generated
614
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
|
|
25
README.md
25
README.md
|
@ -45,10 +45,15 @@ vk rm 42
|
||||||
|
|
||||||
# Mark as done
|
# Mark as done
|
||||||
vk done 42
|
vk done 42
|
||||||
|
vk done -u 42 # You can undo this
|
||||||
|
|
||||||
|
# Mark as favorite
|
||||||
|
vk fav 42
|
||||||
|
vk fav -u 42 # Undo
|
||||||
|
|
||||||
# Assign a user to a task
|
# Assign a user to a task
|
||||||
vk assign me 42
|
vk assign me 42
|
||||||
vk assign -u me 42 # You can undo this
|
vk assign -u me 42 # Undo
|
||||||
```
|
```
|
||||||
|
|
||||||
**Working with projects:**
|
**Working with projects:**
|
||||||
|
@ -78,3 +83,21 @@ vk labels new mylabel
|
||||||
# Remove a label
|
# Remove a label
|
||||||
vk labels rm mylabel
|
vk labels rm mylabel
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Working with comments:**
|
||||||
|
```shell
|
||||||
|
# Show comments of task
|
||||||
|
vk comments 42
|
||||||
|
|
||||||
|
# Comment on a task
|
||||||
|
vk comment 42 "my comment"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Relations:**
|
||||||
|
```shell
|
||||||
|
# Make Task #42 be a parent task to #7
|
||||||
|
vk relation 7 parent 42
|
||||||
|
|
||||||
|
# Make #42 blocked by #7
|
||||||
|
vk relation 42 blocked 7
|
||||||
|
```
|
||||||
|
|
128
src/api/mod.rs
128
src/api/mod.rs
|
@ -4,9 +4,20 @@ mod project;
|
||||||
mod task;
|
mod task;
|
||||||
|
|
||||||
pub use project::Project;
|
pub use project::Project;
|
||||||
|
pub use task::Comment;
|
||||||
|
pub use task::Relation;
|
||||||
pub use task::Task;
|
pub use task::Task;
|
||||||
|
|
||||||
use moka::sync::Cache;
|
use moka::sync::Cache;
|
||||||
|
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 {
|
||||||
|
@ -57,7 +68,7 @@ impl ProjectID {
|
||||||
Some(Self(
|
Some(Self(
|
||||||
api.get_all_projects()
|
api.get_all_projects()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|x| x.title.contains(project))?
|
.find(|x| x.title.to_lowercase().contains(&project.to_lowercase()))?
|
||||||
.id,
|
.id,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -152,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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -199,7 +210,6 @@ impl VikunjaAPI {
|
||||||
"hex_color": color
|
"hex_color": color
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
serde_json::from_str(&resp).unwrap()
|
serde_json::from_str(&resp).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -227,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(
|
||||||
|
@ -242,6 +252,8 @@ impl VikunjaAPI {
|
||||||
"label_id": label_id
|
"label_id": label_id
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// tasks
|
// tasks
|
||||||
|
@ -259,35 +271,52 @@ 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) {
|
||||||
self.delete_request(&format!("/tasks/{id}"));
|
self.delete_request(&format!("/tasks/{id}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_task(&self, title: &str, project: &ProjectID) -> Task {
|
pub fn new_task(
|
||||||
|
&self,
|
||||||
|
title: &str,
|
||||||
|
project: &ProjectID,
|
||||||
|
description: Option<String>,
|
||||||
|
due_date: Option<String>,
|
||||||
|
fav: bool,
|
||||||
|
label: Option<String>,
|
||||||
|
priority: Option<isize>,
|
||||||
|
) -> Result<Task, String> {
|
||||||
let id = project.0;
|
let id = project.0;
|
||||||
|
|
||||||
|
let labels = if let Some(label) = label {
|
||||||
|
let label = self
|
||||||
|
.get_all_labels()
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.title.trim() == label)
|
||||||
|
.map_or_else(|| Err(format!("Label '{label}' not found")), Ok)?;
|
||||||
|
vec![label]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"title": title
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
"due_date": due_date,
|
||||||
|
"is_favorite": fav,
|
||||||
|
"priority": priority,
|
||||||
|
"labels": labels
|
||||||
});
|
});
|
||||||
|
|
||||||
// todo :
|
|
||||||
// description
|
|
||||||
// due_date
|
|
||||||
// end_date
|
|
||||||
// is_favorite
|
|
||||||
// labels
|
|
||||||
// priority
|
|
||||||
|
|
||||||
let resp = self.put_request(&format!("/projects/{id}/tasks"), &data);
|
let resp = self.put_request(&format!("/projects/{id}/tasks"), &data);
|
||||||
serde_json::from_str(&resp).unwrap()
|
Ok(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!({
|
||||||
|
@ -295,13 +324,25 @@ 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) -> Option<Task> {
|
||||||
|
let resp = self.post_request(
|
||||||
|
&format!("/tasks/{task_id}"),
|
||||||
|
&serde_json::json!({
|
||||||
|
"is_favorite": fav
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 {
|
||||||
let resp = self.post_request(
|
let resp = self.post_request(
|
||||||
"/login",
|
"/login",
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
|
"long_token": true,
|
||||||
"username": username,
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"totp_passcode": totp
|
"totp_passcode": totp
|
||||||
|
@ -323,8 +364,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"),
|
||||||
|
@ -332,6 +375,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) {
|
||||||
|
@ -339,4 +384,43 @@ impl VikunjaAPI {
|
||||||
let user_id = user.first().unwrap().id;
|
let user_id = user.first().unwrap().id;
|
||||||
self.delete_request(&format!("/tasks/{task_id}/assignees/{user_id}"));
|
self.delete_request(&format!("/tasks/{task_id}/assignees/{user_id}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_task_comments(&self, task_id: isize) -> Vec<Comment> {
|
||||||
|
let resp = self.get_request(&format!("/tasks/{task_id}/comments"));
|
||||||
|
serde_json::from_str(&resp).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_relation(
|
||||||
|
&self,
|
||||||
|
task_id: isize,
|
||||||
|
relation: &Relation,
|
||||||
|
other_task_id: isize,
|
||||||
|
) -> TaskRelation {
|
||||||
|
let resp = self.put_request(
|
||||||
|
&format!("/tasks/{task_id}/relations"),
|
||||||
|
&serde_json::json!({
|
||||||
|
"task_id": task_id,
|
||||||
|
"other_task_id": other_task_id,
|
||||||
|
"relation_kind": relation.api()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
serde_json::from_str(&resp).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_comment(&self, task_id: isize, comment: &str) -> Comment {
|
||||||
|
let resp = self.put_request(
|
||||||
|
&format!("/tasks/{task_id}/comments"),
|
||||||
|
&serde_json::json!({
|
||||||
|
"comment": comment
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
serde_json::from_str(&resp).unwrap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,9 +9,9 @@ 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: Option<usize>,
|
||||||
pub done_bucket_id: usize,
|
pub done_bucket_id: Option<usize>,
|
||||||
pub owner: Option<User>,
|
pub owner: Option<User>,
|
||||||
pub is_archived: bool,
|
pub is_archived: bool,
|
||||||
pub background_information: Option<String>,
|
pub background_information: Option<String>,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use super::{Label, User};
|
use super::{Label, User};
|
||||||
|
@ -23,7 +25,7 @@ pub struct Task {
|
||||||
pub percent_done: f64,
|
pub percent_done: f64,
|
||||||
pub identifier: String,
|
pub identifier: String,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
// pub related_tasks
|
pub related_tasks: Option<HashMap<String, Vec<Task>>>,
|
||||||
// pub attachments
|
// pub attachments
|
||||||
pub cover_image_attachment_id: usize,
|
pub cover_image_attachment_id: usize,
|
||||||
pub is_favorite: bool,
|
pub is_favorite: bool,
|
||||||
|
@ -31,6 +33,95 @@ pub struct Task {
|
||||||
pub updated: String,
|
pub updated: String,
|
||||||
pub bucket_id: usize,
|
pub bucket_id: usize,
|
||||||
pub position: f64,
|
pub position: f64,
|
||||||
pub kanban_position: f64,
|
pub kanban_position: Option<f64>,
|
||||||
pub created_by: Option<User>,
|
pub created_by: Option<User>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Comment {
|
||||||
|
pub id: isize,
|
||||||
|
pub author: User,
|
||||||
|
pub comment: String,
|
||||||
|
pub created: String,
|
||||||
|
pub updated: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TaskRelation {
|
||||||
|
pub created: String,
|
||||||
|
pub created_by: User,
|
||||||
|
pub other_task_id: isize,
|
||||||
|
pub task_id: isize,
|
||||||
|
pub relation_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Relation {
|
||||||
|
Unknown,
|
||||||
|
Subtask,
|
||||||
|
ParentTask,
|
||||||
|
Related,
|
||||||
|
DuplicateOf,
|
||||||
|
Duplicates,
|
||||||
|
Blocking,
|
||||||
|
Blocked,
|
||||||
|
Precedes,
|
||||||
|
Follows,
|
||||||
|
CopiedFrom,
|
||||||
|
CopiedTo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Relation {
|
||||||
|
pub fn try_parse(val: &str) -> Option<Self> {
|
||||||
|
match val {
|
||||||
|
"unknown" => Some(Self::Unknown),
|
||||||
|
"subtask" | "sub" => Some(Self::Subtask),
|
||||||
|
"parenttask" | "parent" => Some(Self::ParentTask),
|
||||||
|
"related" => Some(Self::Related),
|
||||||
|
"duplicateof" => Some(Self::DuplicateOf),
|
||||||
|
"duplicates" => Some(Self::Duplicates),
|
||||||
|
"blocking" => Some(Self::Blocking),
|
||||||
|
"blocked" => Some(Self::Blocked),
|
||||||
|
"precedes" => Some(Self::Precedes),
|
||||||
|
"follows" => Some(Self::Follows),
|
||||||
|
"copiedfrom" => Some(Self::CopiedFrom),
|
||||||
|
"copiedto" => Some(Self::CopiedTo),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn repr(&self) -> String {
|
||||||
|
match self {
|
||||||
|
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 {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
36
src/args.rs
36
src/args.rs
|
@ -49,7 +49,12 @@ pub fn get_args() -> clap::ArgMatches {
|
||||||
arg!(-p --project <project> "Project to add task to")
|
arg!(-p --project <project> "Project to add task to")
|
||||||
.required(false)
|
.required(false)
|
||||||
.default_value("Inbox"),
|
.default_value("Inbox"),
|
||||||
),
|
)
|
||||||
|
.arg(arg!(-d --description <description> "Task Description").required(false))
|
||||||
|
.arg(arg!(--due <due> "Task Due").required(false))
|
||||||
|
.arg(arg!(-l --label <label> "Task Label").required(false))
|
||||||
|
.arg(arg!(--priority <priority> "Task Label").required(false))
|
||||||
|
.arg(arg!(-f --favorite "Mark task as favorite").required(false)),
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
command!()
|
command!()
|
||||||
|
@ -68,6 +73,35 @@ pub fn get_args() -> clap::ArgMatches {
|
||||||
.arg(arg!([user] "User").required(true))
|
.arg(arg!([user] "User").required(true))
|
||||||
.arg(arg!([task_id] "Task ID").required(true)),
|
.arg(arg!([task_id] "Task ID").required(true)),
|
||||||
)
|
)
|
||||||
|
.subcommand(
|
||||||
|
command!()
|
||||||
|
.name("comments")
|
||||||
|
.about("Show task comments")
|
||||||
|
.arg(arg!([task_id] "Task ID").required(true)),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
command!()
|
||||||
|
.name("comment")
|
||||||
|
.about("Comment on a task")
|
||||||
|
.arg(arg!([task_id] "Task ID").required(true))
|
||||||
|
.arg(arg!([comment] "Comment").required(true)),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
command!()
|
||||||
|
.name("relation")
|
||||||
|
.about("Set task relations")
|
||||||
|
.arg(arg!(-d --delete "Delete the relation").required(false))
|
||||||
|
.arg(arg!([task_id] "Task ID").required(true))
|
||||||
|
.arg(arg!([relation] "Relation").required(true))
|
||||||
|
.arg(arg!([second_task_id] "Other Task ID").required(true)),
|
||||||
|
)
|
||||||
|
.subcommand(
|
||||||
|
command!()
|
||||||
|
.name("fav")
|
||||||
|
.about("Favorite a task")
|
||||||
|
.arg(arg!(-u --undo "Remove favorite from task").required(false))
|
||||||
|
.arg(arg!([task_id] "Task ID").required(true)),
|
||||||
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
command!()
|
command!()
|
||||||
.name("label")
|
.name("label")
|
||||||
|
|
268
src/main.rs
268
src/main.rs
|
@ -3,12 +3,17 @@ mod args;
|
||||||
mod config;
|
mod config;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use api::{ProjectID, VikunjaAPI};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn main() {
|
use api::{ProjectID, Relation, VikunjaAPI};
|
||||||
let arg = args::get_args();
|
use clap::ArgMatches;
|
||||||
let config_path = dirs::home_dir().unwrap().join(".config").join("vk.toml");
|
use once_cell::sync::Lazy;
|
||||||
|
use ui::{hex_to_color, print_color};
|
||||||
|
|
||||||
|
static CONFIG_PATH: Lazy<PathBuf> =
|
||||||
|
Lazy::new(|| 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();
|
||||||
|
@ -23,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}"),
|
||||||
|
@ -39,7 +105,52 @@ 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 parse_datetime(input: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||||
|
let formats = [
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%d %H:%M",
|
||||||
|
"%Y-%m-%d",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.f",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%.fZ",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%:z",
|
||||||
|
"%Y-%m-%dT%H:%M:%S%z",
|
||||||
|
"%+%",
|
||||||
|
];
|
||||||
|
|
||||||
|
let input = input.trim();
|
||||||
|
|
||||||
|
for format in &formats {
|
||||||
|
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(input, format) {
|
||||||
|
let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap();
|
||||||
|
return Some(chrono::TimeZone::from_utc_datetime(
|
||||||
|
&chrono::Utc,
|
||||||
|
&naive_datetime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Ok(naive_datetime) = chrono::NaiveDateTime::parse_from_str(input, format) {
|
||||||
|
return Some(chrono::TimeZone::from_utc_datetime(
|
||||||
|
&chrono::Utc,
|
||||||
|
&naive_datetime,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Ok(datetime) = chrono::DateTime::parse_from_rfc3339(input) {
|
||||||
|
return Some(datetime.with_timezone(&chrono::Utc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
@ -47,30 +158,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());
|
||||||
|
@ -82,32 +170,26 @@ 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(("labels", label_args)) => match label_args.subcommand() {
|
Some(("comments", c_arg)) => {
|
||||||
Some(("ls", _)) => {
|
let task_id: &String = c_arg.get_one("task_id").unwrap();
|
||||||
ui::print_all_labels(&api);
|
let comments = api.get_task_comments(task_id.parse().unwrap());
|
||||||
}
|
|
||||||
Some(("rm", rm_label_arg)) => {
|
|
||||||
let title: &String = rm_label_arg.get_one("title").unwrap();
|
|
||||||
|
|
||||||
api.remove_label(title);
|
for comment in comments {
|
||||||
|
ui::task::print_comment(&comment);
|
||||||
}
|
}
|
||||||
Some(("new", new_label_arg)) => {
|
}
|
||||||
let description: Option<&String> = new_label_arg.get_one("description");
|
Some(("comment", comment_arg)) => {
|
||||||
let color: Option<&String> = new_label_arg.get_one("color");
|
let task_id: &String = comment_arg.get_one("task_id").unwrap();
|
||||||
let title: &String = new_label_arg.get_one("title").unwrap();
|
let comment: &String = comment_arg.get_one("comment").unwrap();
|
||||||
|
|
||||||
api.new_label(
|
api.new_comment(task_id.parse().unwrap(), comment);
|
||||||
title.as_str(),
|
}
|
||||||
description.map(|x| x.as_str()),
|
Some(("labels", label_args)) => label_commands(label_args, &api),
|
||||||
color.map(|x| x.as_str()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
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();
|
||||||
|
@ -115,16 +197,57 @@ 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();
|
||||||
let project: &String = new_task_arg.get_one("project").unwrap();
|
let project: &String = new_task_arg.get_one("project").unwrap();
|
||||||
let project = ProjectID::parse(&api, project).unwrap();
|
let project = ProjectID::parse(&api, project).unwrap();
|
||||||
let task = api.new_task(title.as_str(), &project);
|
let description: Option<String> = new_task_arg
|
||||||
ui::task::print_task_info(task.id, &api);
|
.get_one::<String>("description")
|
||||||
|
.map(std::borrow::ToOwned::to_owned);
|
||||||
|
let due_date: Option<String> = new_task_arg
|
||||||
|
.get_one::<String>("due")
|
||||||
|
.map(std::borrow::ToOwned::to_owned);
|
||||||
|
let due_date = due_date.map(|x| {
|
||||||
|
if let Some(parsed) = parse_datetime(&x) {
|
||||||
|
parsed.to_rfc3339()
|
||||||
|
} else {
|
||||||
|
print_color(crossterm::style::Color::Red, "Failed to parse due date");
|
||||||
|
println!();
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let label: Option<String> = new_task_arg
|
||||||
|
.get_one::<String>("label")
|
||||||
|
.map(std::borrow::ToOwned::to_owned);
|
||||||
|
let priority: Option<String> = new_task_arg
|
||||||
|
.get_one::<String>("priority")
|
||||||
|
.map(std::borrow::ToOwned::to_owned);
|
||||||
|
let fav = new_task_arg.get_flag("favorite");
|
||||||
|
// todo : add args
|
||||||
|
|
||||||
|
let task = api.new_task(
|
||||||
|
title.as_str(),
|
||||||
|
&project,
|
||||||
|
description,
|
||||||
|
due_date,
|
||||||
|
fav,
|
||||||
|
label,
|
||||||
|
priority.map(|x| x.parse().unwrap()),
|
||||||
|
);
|
||||||
|
if let Err(msg) = task {
|
||||||
|
print_color(crossterm::style::Color::Red, &msg);
|
||||||
|
println!();
|
||||||
|
std::process::exit(1);
|
||||||
|
} else {
|
||||||
|
ui::task::print_task_info(task.unwrap().id, &api);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(("done", done_args)) => {
|
Some(("done", done_args)) => {
|
||||||
let task_id: &String = done_args.get_one("task_id").unwrap();
|
let task_id: &String = done_args.get_one("task_id").unwrap();
|
||||||
|
@ -132,6 +255,37 @@ fn main() {
|
||||||
api.done_task(task_id.parse().unwrap(), done);
|
api.done_task(task_id.parse().unwrap(), done);
|
||||||
ui::task::print_task_info(task_id.parse().unwrap(), &api);
|
ui::task::print_task_info(task_id.parse().unwrap(), &api);
|
||||||
}
|
}
|
||||||
|
Some(("fav", fav_args)) => {
|
||||||
|
let task_id: &String = fav_args.get_one("task_id").unwrap();
|
||||||
|
let undo = fav_args.get_flag("undo");
|
||||||
|
|
||||||
|
api.fav_task(task_id.parse().unwrap(), !undo);
|
||||||
|
ui::task::print_task_info(task_id.parse().unwrap(), &api);
|
||||||
|
}
|
||||||
|
Some(("relation", rel_args)) => {
|
||||||
|
let task_id: &String = rel_args.get_one("task_id").unwrap();
|
||||||
|
let relation: &String = rel_args.get_one("relation").unwrap();
|
||||||
|
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();
|
||||||
|
|
||||||
|
if delete {
|
||||||
|
api.remove_relation(
|
||||||
|
task_id.parse().unwrap(),
|
||||||
|
&relation,
|
||||||
|
sec_task_id.parse().unwrap(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
api.add_relation(
|
||||||
|
task_id.parse().unwrap(),
|
||||||
|
&relation,
|
||||||
|
sec_task_id.parse().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui::task::print_task_info(task_id.parse().unwrap(), &api);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let done = arg.get_flag("done");
|
let done = arg.get_flag("done");
|
||||||
let fav = arg.get_flag("favorite");
|
let fav = arg.get_flag("favorite");
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{Project, ProjectID, Task, VikunjaAPI},
|
api::{Comment, Project, ProjectID, Relation, Task, VikunjaAPI},
|
||||||
ui::{
|
ui::{
|
||||||
format_html_to_terminal, hex_to_color, is_in_past, parse_datetime, print_color,
|
format_html_to_terminal, hex_to_color, is_in_past, parse_datetime, print_color,
|
||||||
print_label, time_relative,
|
print_label, time_relative,
|
||||||
|
@ -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()
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -157,10 +160,6 @@ pub fn print_task_info(task_id: isize, api: &VikunjaAPI) {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
if task.description != "<p></p>" && !task.description.is_empty() {
|
|
||||||
println!("---\n{}", format_html_to_terminal(&task.description));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(assigned) = task.assignees {
|
if let Some(assigned) = task.assignees {
|
||||||
print!("Assigned to: ");
|
print!("Assigned to: ");
|
||||||
for assignee in assigned {
|
for assignee in assigned {
|
||||||
|
@ -169,5 +168,36 @@ pub fn print_task_info(task_id: isize, api: &VikunjaAPI) {
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(related) = task.related_tasks {
|
||||||
|
for relation in related {
|
||||||
|
print_color(
|
||||||
|
crossterm::style::Color::Magenta,
|
||||||
|
&format!("{}: ", Relation::try_parse(&relation.0).unwrap().repr()),
|
||||||
|
);
|
||||||
|
for t in relation.1 {
|
||||||
|
// todo : add done indication
|
||||||
|
print_color(crossterm::style::Color::Blue, &t.title);
|
||||||
|
print_color(crossterm::style::Color::Yellow, &format!(" ({})", t.id));
|
||||||
|
print!(" ");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.description != "<p></p>" && !task.description.is_empty() {
|
||||||
|
println!("---\n{}", format_html_to_terminal(&task.description));
|
||||||
|
}
|
||||||
|
|
||||||
// pub percent_done: f64,
|
// pub percent_done: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn print_comment(comment: &Comment) {
|
||||||
|
print_color(crossterm::style::Color::Blue, &comment.author.username);
|
||||||
|
print!(
|
||||||
|
" ({}): ",
|
||||||
|
time_relative(parse_datetime(&comment.created).unwrap())
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
print!("{}", format_html_to_terminal(&comment.comment));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue