From 08b0ba48a33c69999520f63572e585c5d0a86311 Mon Sep 17 00:00:00 2001 From: xeruf <27jf@pm.me> Date: Thu, 8 Aug 2024 00:18:34 +0300 Subject: [PATCH] feat: properly handle commands without argument --- src/kinds.rs | 16 +++++++ src/main.rs | 132 +++++++++++++++++++++++++-------------------------- src/task.rs | 12 +++-- src/tasks.rs | 115 ++++++++++++++++++++++++++------------------ 4 files changed, 157 insertions(+), 118 deletions(-) diff --git a/src/kinds.rs b/src/kinds.rs index 28f5655..3c313e1 100644 --- a/src/kinds.rs +++ b/src/kinds.rs @@ -6,6 +6,22 @@ pub const TASK_KIND: u16 = 1621; pub const TRACKING_KIND: u16 = 1650; pub const KINDS: [u16; 7] = [1, TASK_KIND, TRACKING_KIND, 1630, 1631, 1632, 1633]; +pub const PROPERTY_COLUMNS: &str = "Available properties: +- `id` +- `parentid` +- `name` +- `state` +- `hashtags` +- `tags` - values of all nostr tags associated with the event, except event tags +- `desc` - last note on the task +- `description` - accumulated notes on the task +- `path` - name including parent tasks +- `rpath` - name including parent tasks up to active task +- `time` - time tracked on this task by you +- `rtime` - time tracked on this tasks and its subtree by everyone +- `progress` - recursive subtask completion in percent +- `subtasks` - how many direct subtasks are complete"; + pub(crate) fn build_tracking(id: I) -> EventBuilder where I: IntoIterator, diff --git a/src/main.rs b/src/main.rs index 372f38c..9611b8a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,17 +8,17 @@ use std::ops::Sub; use std::path::PathBuf; use std::str::FromStr; use std::sync::mpsc; -use std::sync::mpsc::Sender; - -use chrono::DateTime; +use std::sync::mpsc::{Sender}; +use chrono::{DateTime}; use colored::Colorize; +use itertools::Itertools; use log::{debug, error, info, trace, warn}; use nostr_sdk::prelude::*; use regex::Regex; use xdg::BaseDirectories; use crate::helpers::*; -use crate::kinds::{KINDS, TRACKING_KIND}; +use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND}; use crate::task::State; use crate::tasks::Tasks; @@ -224,12 +224,10 @@ async fn main() { } } - println!(); let mut lines = stdin().lines(); loop { + println!(); selected_relay.as_ref().and_then(|url| relays.get(url)).inspect(|tasks| { - or_print(tasks.print_tasks()); - print!( "{}", format!( @@ -266,10 +264,11 @@ async fn main() { let mut iter = input.chars(); let op = iter.next(); let arg = if input.len() > 1 { - input[1..].trim() + Some(input[1..].trim()) } else { - "" + None }; + let arg_default = arg.unwrap_or(""); let tasks = selected_relay.as_ref().and_then(|url| relays.get_mut(&url)).unwrap_or_else(|| &mut local_tasks); match op { None => { @@ -277,49 +276,43 @@ async fn main() { tasks.flush() } - Some(':') => match iter.next().and_then(|s| s.to_digit(10)) { - Some(digit) => { + Some(':') => + if let Some(digit) = iter.next().and_then(|s| s.to_digit(10)) { let index = (digit as usize).saturating_sub(1); let remaining = iter.collect::().trim().to_string(); if remaining.is_empty() { tasks.remove_column(index); - continue; - } - let value = input[2..].trim().to_string(); - tasks.add_or_remove_property_column_at_index(value, index); - } - None => { - if arg.is_empty() { - println!("Available properties: -- `id` -- `parentid` -- `name` -- `state` -- `hashtags` -- `tags` - values of all nostr tags associated with the event, except event tags -- `desc` - last note on the task -- `description` - accumulated notes on the task -- `path` - name including parent tasks -- `rpath` - name including parent tasks up to active task -- `time` - time tracked on this task -- `rtime` - time tracked on this tasks and all recursive subtasks -- `progress` - recursive subtask completion in percent -- `subtasks` - how many direct subtasks are complete"); - continue; + } else { + let value = input[2..].trim().to_string(); + tasks.add_or_remove_property_column_at_index(value, index); } + } else if let Some(arg) = arg { tasks.add_or_remove_property_column(arg); - } - }, + } else { + println!("{}", PROPERTY_COLUMNS); + continue + }, - Some(',') => tasks.make_note(arg), + Some(',') => { + match arg { + None => { + tasks.get_current_task().map_or_else( + || info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes"), + |task| println!("{}", task.description_events().map(|e| format!("{} {}", e.created_at.to_human_datetime(), e.content)).join("\n")), + ); + continue + }, + Some(arg) => tasks.make_note(arg), + } + } Some('>') => { - tasks.update_state(arg, State::Done); + tasks.update_state(&arg_default, State::Done); tasks.move_up(); } Some('<') => { - tasks.update_state(arg, State::Closed); + tasks.update_state(&arg_default, State::Closed); tasks.move_up(); } @@ -328,39 +321,43 @@ async fn main() { } Some('?') => { - tasks.set_state_filter(some_non_empty(arg).filter(|s| !s.is_empty())); + tasks.set_state_filter(arg.map(|s| s.to_string())); } Some('!') => match tasks.get_position() { - None => { - warn!("First select a task to set its state!"); - } + None => warn!("First select a task to set its state!"), Some(id) => { - tasks.set_state_for(id, arg, match arg { - "Closed" => State::Closed, - "Done" => State::Done, - _ => State::Open, - }); + tasks.set_state_for_with(id, arg_default); } }, Some('#') | Some('+') => { - tasks.add_tag(arg.to_string()); - info!("Added tag filter for #{arg}") + match arg { + Some(arg) => tasks.add_tag(arg.to_string()), + None => tasks.clear_filter() + } } Some('-') => { - tasks.remove_tag(arg.to_string()); - info!("Removed tag filter for #{arg}") + match arg { + Some(arg) => tasks.remove_tag(arg), + None => tasks.clear_filter() + } } - Some('*') => { - if let Ok(num) = arg.parse::() { - tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num))); - } else if let Ok(date) = DateTime::parse_from_rfc3339(arg) { - tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64)); - } else { - warn!("Cannot parse {arg}"); + Some('*') => match arg { + Some(arg) => { + if let Ok(num) = arg.parse::() { + tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num))); + } else if let Ok(date) = DateTime::parse_from_rfc3339(arg) { + tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64)); + } else { + warn!("Cannot parse {arg}"); + } + } + None => { + // TODO time tracked list + // continue } } @@ -372,12 +369,12 @@ async fn main() { pos = tasks.get_parent(pos).cloned(); } let slice = &input[dots..]; + tasks.move_to(pos); if slice.is_empty() { - tasks.move_to(pos); - continue; - } - if let Ok(depth) = slice.parse::() { - tasks.move_to(pos); + if dots > 1 { + info!("Moving up {} tasks", dots - 1) + } + } else if let Ok(depth) = slice.parse::() { tasks.set_depth(depth); } else { tasks.filter_or_create(slice).map(|id| tasks.move_to(Some(id))); @@ -394,9 +391,7 @@ async fn main() { let slice = &input[dots..].to_ascii_lowercase(); if slice.is_empty() { tasks.move_to(pos); - continue; - } - if let Ok(depth) = slice.parse::() { + } else if let Ok(depth) = slice.parse::() { tasks.move_to(pos); tasks.set_depth(depth); } else { @@ -434,11 +429,14 @@ async fn main() { if new_relay.is_some() { selected_relay = new_relay; } + //or_print(tasks.print_tasks()); + continue } else { tasks.filter_or_create(&input); } } } + or_print(tasks.print_tasks()); } Some(Err(e)) => warn!("{}", e), None => break, diff --git a/src/task.rs b/src/task.rs index 44287ea..5da68a1 100644 --- a/src/task.rs +++ b/src/task.rs @@ -49,15 +49,19 @@ impl Task { .unwrap_or_else(|| self.get_id().to_string()) } - pub(crate) fn descriptions(&self) -> impl Iterator + '_ { + pub(crate) fn description_events(&self) -> impl Iterator + '_ { self.props.iter().filter_map(|event| { if event.kind == Kind::TextNote { - Some(&event.content) + Some(event) } else { None } }) } + + pub(crate) fn descriptions(&self) -> impl Iterator + '_ { + self.description_events().map(|e| &e.content) + } fn states(&self) -> impl Iterator + '_ { self.props.iter().filter_map(|event| { @@ -118,11 +122,11 @@ impl Task { self.props .iter() .map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content)) - .collect::>() + .collect_vec() )), "descriptions" => Some(format!( "{:?}", - self.descriptions().collect::>() + self.descriptions().collect_vec() )), _ => { warn!("Unknown task property {}", property); diff --git a/src/tasks.rs b/src/tasks.rs index 29ac05a..84b3986 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -1,10 +1,10 @@ use std::collections::{BTreeSet, HashMap}; use std::io::{Error, stdout, Write}; -use std::iter::once; +use std::iter::{once, Sum}; use std::ops::{Div, Rem}; use std::sync::mpsc::Sender; - -use chrono::{Local, TimeZone}; +use std::time::Duration; +use chrono::{DateTime, Local, TimeZone}; use chrono::LocalResult::Single; use colored::Colorize; use itertools::Itertools; @@ -103,45 +103,18 @@ impl Tasks { children } - /// Total time tracked on this task by the current user. + /// Total time in seconds tracked on this task by the current user. pub(crate) fn time_tracked(&self, id: EventId) -> u64 { - Self::time_tracked_for(self.history.get(&self.sender.pubkey()).into_iter().flatten(), &vec![id]) + TimesTracked::from(self.history.get(&self.sender.pubkey()).into_iter().flatten(), &vec![id]).sum::().as_secs() } - /// Total time tracked on this task and its subtasks by all users. - /// TODO needs testing! + /// Total time in seconds tracked on this task and its subtasks by all users. fn total_time_tracked(&self, id: EventId) -> u64 { let mut total = 0; let children = self.get_subtasks(id); for user in self.history.values() { - total += Self::time_tracked_for(user, &children); - } - total - } - - fn time_tracked_for<'a, E>(events: E, ids: &Vec) -> u64 - where - E: IntoIterator, - { - let mut total = 0; - let mut start: Option = None; - for event in events { - match event.tags.first().and_then(|tag| tag.as_standardized()) { - Some(TagStandard::Event { - event_id, - .. - }) if ids.contains(event_id) => { - start = start.or(Some(event.created_at)) - } - _ => if let Some(stamp) = start { - total += (event.created_at - stamp).as_u64(); - start = None; - } - } - } - if let Some(start) = start { - total += (Timestamp::now() - start).as_u64(); + total += TimesTracked::from(user, &children).into_iter().sum::().as_secs(); } total } @@ -253,7 +226,7 @@ impl Tasks { } #[inline] - fn current_task(&self) -> Option<&Task> { + pub(crate) fn get_current_task(&self) -> Option<&Task> { self.position.and_then(|id| self.get_by_id(&id)) } @@ -266,7 +239,7 @@ impl Tasks { pub(crate) fn current_tasks(&self) -> Vec<&Task> { if self.depth == 0 { - return self.current_task().into_iter().collect(); + return self.get_current_task().into_iter().collect(); } let res: Vec<&Task> = self.resolve_tasks(self.view.iter()); if res.len() > 0 { @@ -295,7 +268,7 @@ impl Tasks { pub(crate) fn print_tasks(&self) -> Result<(), Error> { let mut lock = stdout().lock(); - if let Some(t) = self.current_task() { + if let Some(t) = self.get_current_task() { let state = t.state_or_default(); writeln!( lock, @@ -362,7 +335,6 @@ impl Tasks { .join(" \t") )?; } - writeln!(lock)?; Ok(()) } @@ -372,23 +344,37 @@ impl Tasks { self.view = view; } + pub(crate) fn clear_filter(&mut self) { + self.view.clear(); + self.tags.clear(); + info!("Removed all filters"); + } + pub(crate) fn add_tag(&mut self, tag: String) { self.view.clear(); + info!("Added tag filter for #{tag}"); self.tags.insert(Hashtag(tag).into()); } - pub(crate) fn remove_tag(&mut self, tag: String) { + pub(crate) fn remove_tag(&mut self, tag: &str) { self.view.clear(); - self.tags.retain(|t| !t.content().is_some_and(|value| value.to_string().starts_with(&tag))); + let len = self.tags.len(); + self.tags.retain(|t| !t.content().is_some_and(|value| value.to_string().starts_with(tag))); + if self.tags.len() < len { + info!("Removed tag filters starting with {tag}"); + } else { + info!("Found no tag filters starting with {tag} to remove"); + } } pub(crate) fn set_state_filter(&mut self, state: Option) { self.view.clear(); + info!("Filtering for {}", state.as_ref().map_or("open tasks".to_string(), |s| format!("state {s}"))); self.state = state; } pub(crate) fn move_up(&mut self) { - self.move_to(self.current_task().and_then(|t| t.parent_id()).cloned()); + self.move_to(self.get_current_task().and_then(|t| t.parent_id()).cloned()); } pub(crate) fn flush(&self) { @@ -613,19 +599,19 @@ impl Tasks { } } } - + // Properties pub(crate) fn set_depth(&mut self, depth: i8) { self.depth = depth; info!("Changed view depth to {depth}"); } - + pub(crate) fn remove_column(&mut self, index: usize) { let col = self.properties.remove(index); info!("Removed property column \"{col}\""); } - + pub(crate) fn add_or_remove_property_column(&mut self, property: &str) { match self.properties.iter().position(|s| s == property) { None => { @@ -646,7 +632,6 @@ impl Tasks { self.properties.insert(index, property); } } - } /// Formats the given seconds according to the given format. @@ -686,6 +671,43 @@ pub(crate) fn join_tasks<'a>( }) } + +struct TimesTracked<'a> { + events: Box + 'a>, + ids: &'a Vec, +} +impl TimesTracked<'_> { + fn from<'b>(events: impl IntoIterator + 'b, ids: &'b Vec) -> TimesTracked<'b> { + TimesTracked { + events: Box::new(events.into_iter()), + ids, + } + } +} + +impl Iterator for TimesTracked<'_> { + type Item = Duration; + + fn next(&mut self) -> Option { + let mut start: Option = None; + while let Some(event) = self.events.next() { + match event.tags.first().and_then(|tag| tag.as_standardized()) { + Some(TagStandard::Event { + event_id, + .. + }) if self.ids.contains(event_id) => { + start = start.or(Some(event.created_at.as_u64())) + } + _ => if let Some(stamp) = start { + return Some(Duration::from_secs(event.created_at.as_u64() - stamp)) + } + } + } + return start.map(|stamp| Duration::from_secs(Timestamp::now().as_u64() - stamp)) + } +} + + struct ParentIterator<'a> { tasks: &'a TaskMap, current: Option, @@ -740,7 +762,7 @@ mod tasks_test { tasks.track_at(Timestamp::from(2)); assert_eq!(tasks.get_own_history().unwrap().len(), 3); assert_eq!(tasks.time_tracked(zero), 1); - + // TODO test received events } @@ -832,7 +854,6 @@ mod tasks_test { "0000000000000000000000000000000000000000000000000000000000000000>test" ); assert_eq!(tasks.relative_path(dangling), "test"); - } #[allow(dead_code)]