This commit is contained in:
JMARyA 2024-06-06 08:48:36 +02:00
commit a7c1643b10
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
10 changed files with 2112 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
config.toml

1701
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "vk"
version = "0.1.0"
edition = "2021"
authors = ["JMARyA <jmarya@hydrar.de>"]
[dependencies]
chrono = "0.4.38"
clap = { version = "4.5.4", features = ["cargo"] }
crossterm = "0.27.0"
moka = { version = "0.12.7", features = ["sync"] }
reqwest = { version = "0.12.4", features = ["blocking"] }
serde = { version = "1.0.203", features = ["derive"] }
serde_json = "1.0.117"
toml = "0.8.14"

2
README.md Normal file
View file

@ -0,0 +1,2 @@
# vk
`vk` is a command line todo tool for Vikunja.

104
src/api/mod.rs Normal file
View file

@ -0,0 +1,104 @@
use serde::{Deserialize, Serialize};
mod project;
mod task;
pub use project::Project;
pub use task::Task;
use moka::sync::Cache;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Label {
pub id: usize,
pub title: String,
pub description: String,
pub hex_color: String,
pub created_by: User,
pub updated: String,
pub created: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: usize,
pub name: String,
pub username: String,
pub created: String,
pub updated: String,
}
pub struct VikunjaAPI {
host: String,
token: String,
cache: Cache<String, String>,
}
impl VikunjaAPI {
pub fn new(host: &str, token: &str) -> Self {
Self {
host: host.to_string(),
token: token.to_string(),
cache: Cache::new(100),
}
}
fn get_request(&self, path: &str) -> String {
if let Some(cached) = self.cache.get(path) {
cached
} else {
let client = reqwest::blocking::Client::new();
let ret = client
.get(&format!("{}/api/v1{}", self.host, path))
.header("Authorization", format!("Bearer {}", self.token))
.send()
.unwrap()
.text()
.unwrap();
self.cache.insert(path.to_string(), ret.clone());
ret
}
}
// projects
pub fn get_project_name_from_id(&self, id: isize) -> String {
let all_prj = self.get_all_projects();
let found = all_prj.into_iter().find(|x| x.id == id).unwrap();
found.title
}
pub fn get_all_projects(&self) -> Vec<Project> {
let resp = self.get_request("/projects");
serde_json::from_str(&resp).unwrap()
}
pub fn get_task_page(&self, page: usize) -> Vec<Task> {
let resp = self.get_request(&format!("/tasks/all?page={page}"));
serde_json::from_str(&resp).unwrap()
}
// tasks
pub fn get_all_tasks(&self) -> Vec<Task> {
let mut ret = Vec::new();
let mut page = 0;
loop {
let current_page = self.get_task_page(page);
if current_page.is_empty() {
break;
}
ret.extend(current_page);
page += 1;
}
ret
}
pub fn get_task(&self, id: isize) -> Task {
let resp = self.get_request(&format!("/tasks/{id}"));
serde_json::from_str(&resp).unwrap()
}
}

23
src/api/project.rs Normal file
View file

@ -0,0 +1,23 @@
use serde::{Deserialize, Serialize};
use super::User;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: isize,
pub title: String,
pub description: String,
pub identifier: String,
pub hex_color: String,
pub parent_project_id: usize,
pub default_bucket_id: usize,
pub done_bucket_id: usize,
pub owner: Option<User>,
pub is_archived: bool,
pub background_information: Option<String>,
pub background_blur_hash: String,
pub is_favorite: bool,
pub position: f64,
pub created: String,
pub updated: String,
}

36
src/api/task.rs Normal file
View file

@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use super::{Label, User};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Task {
pub id: usize,
pub title: String,
pub description: String,
pub done: bool,
pub done_at: String,
pub due_date: String,
pub reminders: Option<String>,
pub project_id: isize,
pub repeat_after: usize,
pub repeat_mode: usize,
pub priority: usize,
pub start_date: String,
pub end_date: String,
pub assignees: Option<Vec<User>>,
pub labels: Option<Vec<Label>>,
pub hex_color: String,
pub percent_done: f64,
pub identifier: String,
pub index: usize,
// pub related_tasks
// pub attachments
pub cover_image_attachment_id: usize,
pub is_favorite: bool,
pub created: String,
pub updated: String,
pub bucket_id: usize,
pub position: f64,
pub kanban_position: f64,
pub created_by: User,
}

21
src/args.rs Normal file
View file

@ -0,0 +1,21 @@
use clap::{arg, command};
pub fn get_args() -> clap::ArgMatches {
command!()
.about("CLI Tool for Vikunja")
.arg(arg!(-d --done "Show done tasks too").required(false))
.arg(arg!(-f --favorite "Show only favorites").required(false))
.subcommand(
command!()
.name("info")
.about("Show information on task")
.arg(arg!([task_id] "Task ID").required(true)),
)
.subcommand(
command!()
.name("prj")
.about("Commands about projects")
.subcommand(command!().name("ls").about("List projects")),
)
.get_matches()
}

7
src/config.rs Normal file
View file

@ -0,0 +1,7 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub host: String,
pub token: String,
}

201
src/main.rs Normal file
View file

@ -0,0 +1,201 @@
mod api;
mod args;
mod config;
use std::{collections::HashMap, io::stdout};
use api::{Project, Task, VikunjaAPI};
use chrono::{DateTime, Utc};
use crossterm::{
style::{Color, SetBackgroundColor, SetForegroundColor},
ExecutableCommand,
};
pub fn print_color(color: Color, txt: &str) {
stdout().execute(SetForegroundColor(color)).unwrap();
print!("{txt}");
stdout().execute(SetForegroundColor(Color::Reset)).unwrap();
}
fn print_task_oneline(task: &Task, api: &VikunjaAPI) {
let done_indicator = if task.done { "" } else { " " };
println!(
"[{}] ({}) '{}' [{}]",
done_indicator,
task.id,
task.title,
api.get_project_name_from_id(task.project_id),
);
}
fn print_current_tasks(api: &VikunjaAPI, done: bool, fav: bool) {
let current_tasks = api.get_all_tasks();
let selection: Vec<_> = if done {
current_tasks
} else {
current_tasks.into_iter().filter(|x| !x.done).collect()
};
let selection = if fav {
selection.into_iter().filter(|x| x.is_favorite).collect()
} else {
selection
};
for task in selection {
print_task_oneline(&task, api);
}
}
fn parse_datetime(datetime_str: &str) -> Option<DateTime<Utc>> {
if datetime_str == "0001-01-01T00:00:00Z" {
return None;
}
match DateTime::parse_from_rfc3339(datetime_str) {
Ok(dt) => Some(dt.with_timezone(&Utc)),
Err(_) => None, // Return None if parsing fails
}
}
pub fn time_since(event: DateTime<Utc>) -> String {
let now = Utc::now();
let duration = now.signed_duration_since(event);
if duration.num_days() > 0 {
return format!("{}d ago", duration.num_days());
} else if duration.num_hours() > 0 {
return format!("{}h ago", duration.num_hours());
} else if duration.num_minutes() > 0 {
return format!("{}m ago", duration.num_minutes());
} else {
return "Just now".to_string();
}
}
fn print_task_info(task_id: isize, api: &VikunjaAPI) {
let task = api.get_task(task_id);
let done_indicator = if task.done {
format!("{}", parse_datetime(&task.done_at).unwrap())
} else {
String::new()
};
let fav_indicator = if task.is_favorite { "" } else { "" };
println!(
"{}{}'{}' [{}] [{}]",
done_indicator,
fav_indicator,
task.title,
task.id,
api.get_project_name_from_id(task.project_id)
);
println!("Created by {}", task.created_by.username);
if let Some(due_date) = parse_datetime(&task.due_date) {
println!("Due at {due_date}");
}
if task.priority != 0 {
println!("Priority: {}", task.priority);
}
if let (Some(start_date), Some(end_date)) = (
parse_datetime(&task.start_date),
parse_datetime(&task.end_date),
) {
println!("{start_date} -> {end_date}");
}
println!("Labels: {}", task.labels.unwrap().first().unwrap().title);
println!(
"Created: {} | Updated: {}",
time_since(parse_datetime(&task.created).unwrap()),
time_since(parse_datetime(&task.updated).unwrap())
);
if task.description != "<p></p>" {
println!("---\n{}", task.description);
}
//pub assignees: Option<Vec<String>>,
//pub labels: Option<Vec<Label>>,
// pub percent_done: f64,
}
fn hex_to_color(hex: &str) -> Result<Color, String> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Err("Invalid hex color length".to_string());
}
let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid red component")?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid green component")?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid blue component")?;
Ok(Color::Rgb { r, g, b })
}
fn list_projects(api: &VikunjaAPI) {
let projects = api.get_all_projects();
let mut project_map: HashMap<usize, Vec<Project>> = HashMap::new();
for prj in projects {
project_map
.entry(prj.parent_project_id)
.or_insert_with(Vec::new)
.push(prj);
}
for prj in project_map.get(&0).unwrap() {
let color = if prj.hex_color.is_empty() {
Color::Reset
} else {
hex_to_color(&prj.hex_color).unwrap()
};
print_color(color, &prj.title);
print!(" [{}]\n", prj.id);
if let Some(sub_projects) = project_map.get(&(prj.id as usize)) {
for sub_prj in sub_projects {
let color = if sub_prj.hex_color.is_empty() {
Color::Reset
} else {
hex_to_color(&sub_prj.hex_color).unwrap()
};
print_color(color, &format!(" - {}", sub_prj.title));
print!(" [{}]\n", sub_prj.id);
}
}
}
}
fn main() {
let config: config::Config =
toml::from_str(&std::fs::read_to_string("config.toml").unwrap()).unwrap();
let api = VikunjaAPI::new(&config.host, &config.token);
let arg = args::get_args();
match arg.subcommand() {
Some(("info", task_info_arg)) => {
let task_id: &String = task_info_arg.get_one("task_id").unwrap();
print_task_info(task_id.parse().unwrap(), &api);
}
Some(("prj", prj_arg)) => match prj_arg.subcommand() {
Some(("ls", _)) => {
list_projects(&api);
}
_ => {}
},
_ => {
let done = arg.get_flag("done");
let fav = arg.get_flag("favorite");
print_current_tasks(&api, done, fav);
}
}
}