From e84a17288044e2018ea0d2b142e846b2d1454ff9 Mon Sep 17 00:00:00 2001 From: Dheepak Krishnamurthy Date: Tue, 26 Sep 2023 04:06:16 -0400 Subject: [PATCH] WIP --- .config/config.json5 | 2 +- src/app.rs | 4 +- src/components/task_report.rs | 409 +++++++++++++++++++++++++++++++++- src/config.rs | 18 +- src/main.rs | 2 +- 5 files changed, 420 insertions(+), 15 deletions(-) diff --git a/.config/config.json5 b/.config/config.json5 index 17f0ea8..34d7614 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -2,7 +2,7 @@ "keybindings": { // KeyBindings for TaskReport "TaskReport": { - "": "Quit", // Quit the application + "": "Quit", // Quit the application "": "Quit", // Another way to quit "": "Quit", // Yet another way to quit "": "Suspend" // Suspend the application diff --git a/src/app.rs b/src/app.rs index 26bc7b4..54f0cc9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -31,8 +31,8 @@ pub struct App { } impl App { - pub fn new(tick_rate: f64, frame_rate: f64) -> Result { - let app = TaskReport::new(); + pub fn new(tick_rate: f64, frame_rate: f64, report: &str) -> Result { + let app = TaskReport::new().report(report.into()); let config = Config::new()?; let mode = Mode::TaskReport; Ok(Self { diff --git a/src/components/task_report.rs b/src/components/task_report.rs index 91901a9..fda4934 100644 --- a/src/components/task_report.rs +++ b/src/components/task_report.rs @@ -1,12 +1,15 @@ use std::{collections::HashMap, time::Duration}; +use chrono::{DateTime, Local, NaiveDateTime, TimeZone}; use color_eyre::eyre::Result; use crossterm::event::{KeyCode, KeyEvent}; +use itertools::Itertools; use ratatui::{prelude::*, widgets::*}; use serde_derive::{Deserialize, Serialize}; -use task_hookrs::{import::import, task::Task}; +use task_hookrs::{import::import, task::Task, uda::UDAValue}; use tokio::sync::mpsc::UnboundedSender; use tui_input::backend::crossterm::EventHandler; +use unicode_truncate::UnicodeTruncateStr; use uuid::Uuid; use super::{Component, Frame}; @@ -15,6 +18,75 @@ use crate::{ config::{Config, KeyBindings}, }; +pub fn format_date_time(dt: NaiveDateTime) -> String { + let dt = Local.from_local_datetime(&dt).unwrap(); + dt.format("%Y-%m-%d %H:%M:%S").to_string() +} + +pub fn format_date(dt: NaiveDateTime) -> String { + let offset = Local.offset_from_utc_datetime(&dt); + let dt = DateTime::::from_naive_utc_and_offset(dt, offset); + dt.format("%Y-%m-%d").to_string() +} + +pub fn vague_format_date_time(from_dt: NaiveDateTime, to_dt: NaiveDateTime, with_remainder: bool) -> String { + let to_dt = Local.from_local_datetime(&to_dt).unwrap(); + let from_dt = Local.from_local_datetime(&from_dt).unwrap(); + let mut seconds = (to_dt - from_dt).num_seconds(); + let minus = if seconds < 0 { + seconds *= -1; + "-" + } else { + "" + }; + + let year = 60 * 60 * 24 * 365; + let month = 60 * 60 * 24 * 30; + let week = 60 * 60 * 24 * 7; + let day = 60 * 60 * 24; + let hour = 60 * 60; + let minute = 60; + + if seconds >= 60 * 60 * 24 * 365 { + return if with_remainder { + format!("{}{}y{}mo", minus, seconds / year, (seconds - year * (seconds / year)) / month) + } else { + format!("{}{}y", minus, seconds / year) + }; + } else if seconds >= 60 * 60 * 24 * 90 { + return if with_remainder { + format!("{}{}mo{}w", minus, seconds / month, (seconds - month * (seconds / month)) / week) + } else { + format!("{}{}mo", minus, seconds / month) + }; + } else if seconds >= 60 * 60 * 24 * 14 { + return if with_remainder { + format!("{}{}w{}d", minus, seconds / week, (seconds - week * (seconds / week)) / day) + } else { + format!("{}{}w", minus, seconds / week) + }; + } else if seconds >= 60 * 60 * 24 { + return if with_remainder { + format!("{}{}d{}h", minus, seconds / day, (seconds - day * (seconds / day)) / hour) + } else { + format!("{}{}d", minus, seconds / day) + }; + } else if seconds >= 60 * 60 { + return if with_remainder { + format!("{}{}h{}min", minus, seconds / hour, (seconds - hour * (seconds / hour)) / minute) + } else { + format!("{}{}h", minus, seconds / hour) + }; + } else if seconds >= 60 { + return if with_remainder { + format!("{}{}min{}s", minus, seconds / minute, (seconds - minute * (seconds / minute))) + } else { + format!("{}{}min", minus, seconds / minute) + }; + } + format!("{}{}s", minus, seconds) +} + #[derive(Default)] pub struct TaskReport { pub config: Config, @@ -24,6 +96,14 @@ pub struct TaskReport { pub filter: String, pub current_context_filter: String, pub tasks: Vec, + pub rows: Vec>, + pub headers: Vec, + pub state: TableState, + pub columns: Vec, + pub labels: Vec, + pub date_time_vague_precise: bool, + pub virtual_tags: Vec, + pub description_width: usize, } impl TaskReport { @@ -48,6 +128,319 @@ impl TaskReport { Ok(()) } + pub fn export_headers(&mut self) -> Result<()> { + self.columns = vec![]; + self.labels = vec![]; + + let output = std::process::Command::new("task") + .arg("show") + .arg("rc.defaultwidth=0") + .arg(format!("report.{}.columns", &self.report)) + .output()?; + let data = String::from_utf8_lossy(&output.stdout).into_owned(); + + for line in data.split('\n') { + if line.starts_with(format!("report.{}.columns", &self.report).as_str()) { + let column_names = line.split_once(' ').unwrap().1; + for column in column_names.split(',') { + self.columns.push(column.to_string()); + } + } + } + + let output = std::process::Command::new("task") + .arg("show") + .arg("rc.defaultwidth=0") + .arg(format!("report.{}.labels", &self.report)) + .output()?; + let data = String::from_utf8_lossy(&output.stdout); + + for line in data.split('\n') { + if line.starts_with(format!("report.{}.labels", &self.report).as_str()) { + let label_names = line.split_once(' ').unwrap().1; + for label in label_names.split(',') { + self.labels.push(label.to_string()); + } + } + } + + if self.labels.is_empty() { + for label in &self.columns { + let label = label.split('.').collect::>()[0]; + let label = if label == "id" { "ID" } else { label }; + let mut c = label.chars(); + let label = match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + }; + if !label.is_empty() { + self.labels.push(label); + } + } + } + let num_labels = self.labels.len(); + let num_columns = self.columns.len(); + assert!(num_labels == num_columns, "Must have the same number of labels (currently {}) and columns (currently {}). Compare their values as shown by \"task show report.{}.\" and fix your taskwarrior config.", num_labels, num_columns, &self.report); + + Ok(()) + } + + pub fn generate_rows(&mut self) -> Result<()> { + self.rows = vec![]; + + // get all tasks as their string representation + for task in self.tasks.iter() { + if self.columns.is_empty() { + break; + } + let mut item = vec![]; + for name in &self.columns { + let s = self.get_string_attribute(name, &task, &self.tasks); + item.push(s); + } + self.rows.push(item); + } + Ok(()) + } + + pub fn get_string_attribute(&self, attribute: &str, task: &Task, tasks: &[Task]) -> String { + match attribute { + "id" => task.id().unwrap_or_default().to_string(), + "scheduled.relative" => { + match task.scheduled() { + Some(v) => { + vague_format_date_time( + Local::now().naive_utc(), + NaiveDateTime::new(v.date(), v.time()), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "due.relative" => { + match task.due() { + Some(v) => { + vague_format_date_time( + Local::now().naive_utc(), + NaiveDateTime::new(v.date(), v.time()), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "due" => { + match task.due() { + Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())), + None => "".to_string(), + } + }, + "until.remaining" => { + match task.until() { + Some(v) => { + vague_format_date_time( + Local::now().naive_utc(), + NaiveDateTime::new(v.date(), v.time()), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "until" => { + match task.until() { + Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())), + None => "".to_string(), + } + }, + "entry.age" => { + vague_format_date_time( + NaiveDateTime::new(task.entry().date(), task.entry().time()), + Local::now().naive_utc(), + self.date_time_vague_precise, + ) + }, + "entry" => format_date(NaiveDateTime::new(task.entry().date(), task.entry().time())), + "start.age" => { + match task.start() { + Some(v) => { + vague_format_date_time( + NaiveDateTime::new(v.date(), v.time()), + Local::now().naive_utc(), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "start" => { + match task.start() { + Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())), + None => "".to_string(), + } + }, + "end.age" => { + match task.end() { + Some(v) => { + vague_format_date_time( + NaiveDateTime::new(v.date(), v.time()), + Local::now().naive_utc(), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "end" => { + match task.end() { + Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())), + None => "".to_string(), + } + }, + "status.short" => task.status().to_string().chars().next().unwrap().to_string(), + "status" => task.status().to_string(), + "priority" => { + match task.priority() { + Some(p) => p.clone(), + None => "".to_string(), + } + }, + "project" => { + match task.project() { + Some(p) => p.to_string(), + None => "".to_string(), + } + }, + "depends.count" => { + match task.depends() { + Some(v) => { + if v.is_empty() { + "".to_string() + } else { + format!("{}", v.len()) + } + }, + None => "".to_string(), + } + }, + "depends" => { + match task.depends() { + Some(v) => { + if v.is_empty() { + "".to_string() + } else { + let mut dt = vec![]; + for u in v { + if let Some(t) = tasks.iter().find(|t| t.uuid() == u) { + dt.push(t.id().unwrap()); + } + } + dt.iter().map(ToString::to_string).join(" ") + } + }, + None => "".to_string(), + } + }, + "tags.count" => { + match task.tags() { + Some(v) => { + let t = v.iter().filter(|t| !self.virtual_tags.contains(t)).count(); + if t == 0 { + "".to_string() + } else { + t.to_string() + } + }, + None => "".to_string(), + } + }, + "tags" => { + match task.tags() { + Some(v) => v.iter().filter(|t| !self.virtual_tags.contains(t)).cloned().collect::>().join(","), + None => "".to_string(), + } + }, + "recur" => { + match task.recur() { + Some(v) => v.clone(), + None => "".to_string(), + } + }, + "wait" => { + match task.wait() { + Some(v) => { + vague_format_date_time( + NaiveDateTime::new(v.date(), v.time()), + Local::now().naive_utc(), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "wait.remaining" => { + match task.wait() { + Some(v) => { + vague_format_date_time( + Local::now().naive_utc(), + NaiveDateTime::new(v.date(), v.time()), + self.date_time_vague_precise, + ) + }, + None => "".to_string(), + } + }, + "description.count" => { + let c = if let Some(a) = task.annotations() { format!("[{}]", a.len()) } else { Default::default() }; + format!("{} {}", task.description(), c) + }, + "description.truncated_count" => { + let c = if let Some(a) = task.annotations() { format!("[{}]", a.len()) } else { Default::default() }; + let d = task.description().to_string(); + let mut available_width = self.description_width; + if self.description_width >= c.len() { + available_width = self.description_width - c.len(); + } + let (d, _) = d.unicode_truncate(available_width); + let mut d = d.to_string(); + if d != *task.description() { + d = format!("{}\u{2026}", d); + } + format!("{}{}", d, c) + }, + "description.truncated" => { + let d = task.description().to_string(); + let available_width = self.description_width; + let (d, _) = d.unicode_truncate(available_width); + let mut d = d.to_string(); + if d != *task.description() { + d = format!("{}\u{2026}", d); + } + d + }, + "description.desc" | "description" => task.description().to_string(), + "urgency" => { + match &task.urgency() { + Some(f) => format!("{:.2}", *f), + None => "0.00".to_string(), + } + }, + s => { + let u = &task.uda(); + let v = u.get(s); + if v.is_none() { + return "".to_string(); + } + match v.unwrap() { + UDAValue::Str(s) => s.to_string(), + UDAValue::F64(f) => f.to_string(), + UDAValue::U64(u) => u.to_string(), + } + }, + } + } + pub fn task_export(&mut self) -> Result<()> { let mut task = std::process::Command::new("task"); @@ -112,12 +505,24 @@ impl Component for TaskReport { fn update(&mut self, command: Command) -> Result> { match command { - _ => (), + Command::Tick => { + self.task_export()?; + self.export_headers()?; + self.generate_rows()?; + }, + _ => {}, } Ok(None) } fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> { + let mut constraints = vec![]; + for i in 0..self.rows[0].len() { + constraints.push(Constraint::Percentage(100 / self.rows[0].len() as u16)); + } + let rows = self.rows.iter().map(|row| Row::new(row.clone())); + let table = Table::new(rows).header(Row::new(self.headers.clone())).widths(&constraints); + f.render_stateful_widget(table, rect, &mut self.state); Ok(()) } } diff --git a/src/config.rs b/src/config.rs index 01d86b1..17d9590 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use serde_derive::Deserialize; use crate::{app::Mode, command::Command}; -const CONFIG: &'static str = include_str!("../.config/config.json5"); +const CONFIG: &str = include_str!("../.config/config.json5"); #[derive(Clone, Debug, Deserialize, Default)] pub struct AppConfig { @@ -31,7 +31,7 @@ pub struct Config { impl Config { pub fn new() -> Result { - let default_config: Config = json5::from_str(&CONFIG).unwrap(); + let default_config: Config = json5::from_str(CONFIG).unwrap(); let data_dir = crate::utils::get_data_dir(); let config_dir = crate::utils::get_config_dir(); let mut builder = config::Config::builder() @@ -177,7 +177,7 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String { KeyCode::Delete => "delete", KeyCode::Insert => "insert", KeyCode::F(c) => { - char = format!("f({})", c.to_string()); + char = format!("f({c})"); &char }, KeyCode::Char(c) if c == ' ' => "space", @@ -227,8 +227,8 @@ pub fn parse_key_sequence(raw: &str) -> Result, String> { return Err(format!("Unable to parse `{}`", raw)); } let raw = if !raw.contains("><") { - let raw = raw.strip_prefix("<").unwrap_or(raw); - let raw = raw.strip_prefix(">").unwrap_or(raw); + let raw = raw.strip_prefix('<').unwrap_or(raw); + let raw = raw.strip_prefix('>').unwrap_or(raw); raw } else { raw @@ -236,10 +236,10 @@ pub fn parse_key_sequence(raw: &str) -> Result, String> { let sequences = raw .split("><") .map(|seq| { - if seq.starts_with('<') { - &seq[1..] - } else if seq.ends_with('>') { - &seq[..seq.len() - 1] + if let Some(s) = seq.strip_prefix('<') { + s + } else if let Some(s) = seq.strip_suffix('>') { + s } else { seq } diff --git a/src/main.rs b/src/main.rs index 054f7a9..b3cac1c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ async fn tokio_main() -> Result<()> { initialize_panic_handler()?; let args = Cli::parse(); - let mut runner = App::new(args.tick_rate, args.frame_rate)?; + let mut runner = App::new(args.tick_rate, args.frame_rate, &args.report.unwrap_or("next".into()))?; runner.run().await?; Ok(())