feat: properly handle commands without argument

This commit is contained in:
xeruf 2024-08-08 00:18:34 +03:00
parent 4180533844
commit 08b0ba48a3
4 changed files with 157 additions and 118 deletions

View File

@ -6,6 +6,22 @@ pub const TASK_KIND: u16 = 1621;
pub const TRACKING_KIND: u16 = 1650; pub const TRACKING_KIND: u16 = 1650;
pub const KINDS: [u16; 7] = [1, TASK_KIND, TRACKING_KIND, 1630, 1631, 1632, 1633]; 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<I>(id: I) -> EventBuilder pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
where where
I: IntoIterator<Item=EventId>, I: IntoIterator<Item=EventId>,

View File

@ -8,17 +8,17 @@ use std::ops::Sub;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::mpsc::Sender; use std::sync::mpsc::{Sender};
use chrono::{DateTime};
use chrono::DateTime;
use colored::Colorize; use colored::Colorize;
use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use regex::Regex; use regex::Regex;
use xdg::BaseDirectories; use xdg::BaseDirectories;
use crate::helpers::*; use crate::helpers::*;
use crate::kinds::{KINDS, TRACKING_KIND}; use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND};
use crate::task::State; use crate::task::State;
use crate::tasks::Tasks; use crate::tasks::Tasks;
@ -224,12 +224,10 @@ async fn main() {
} }
} }
println!();
let mut lines = stdin().lines(); let mut lines = stdin().lines();
loop { loop {
println!();
selected_relay.as_ref().and_then(|url| relays.get(url)).inspect(|tasks| { selected_relay.as_ref().and_then(|url| relays.get(url)).inspect(|tasks| {
or_print(tasks.print_tasks());
print!( print!(
"{}", "{}",
format!( format!(
@ -266,10 +264,11 @@ async fn main() {
let mut iter = input.chars(); let mut iter = input.chars();
let op = iter.next(); let op = iter.next();
let arg = if input.len() > 1 { let arg = if input.len() > 1 {
input[1..].trim() Some(input[1..].trim())
} else { } 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); let tasks = selected_relay.as_ref().and_then(|url| relays.get_mut(&url)).unwrap_or_else(|| &mut local_tasks);
match op { match op {
None => { None => {
@ -277,49 +276,43 @@ async fn main() {
tasks.flush() tasks.flush()
} }
Some(':') => match iter.next().and_then(|s| s.to_digit(10)) { Some(':') =>
Some(digit) => { if let Some(digit) = iter.next().and_then(|s| s.to_digit(10)) {
let index = (digit as usize).saturating_sub(1); let index = (digit as usize).saturating_sub(1);
let remaining = iter.collect::<String>().trim().to_string(); let remaining = iter.collect::<String>().trim().to_string();
if remaining.is_empty() { if remaining.is_empty() {
tasks.remove_column(index); tasks.remove_column(index);
continue; } else {
} let value = input[2..].trim().to_string();
let value = input[2..].trim().to_string(); tasks.add_or_remove_property_column_at_index(value, index);
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 if let Some(arg) = arg {
tasks.add_or_remove_property_column(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('>') => { Some('>') => {
tasks.update_state(arg, State::Done); tasks.update_state(&arg_default, State::Done);
tasks.move_up(); tasks.move_up();
} }
Some('<') => { Some('<') => {
tasks.update_state(arg, State::Closed); tasks.update_state(&arg_default, State::Closed);
tasks.move_up(); tasks.move_up();
} }
@ -328,39 +321,43 @@ async fn main() {
} }
Some('?') => { 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() { Some('!') => match tasks.get_position() {
None => { None => warn!("First select a task to set its state!"),
warn!("First select a task to set its state!");
}
Some(id) => { Some(id) => {
tasks.set_state_for(id, arg, match arg { tasks.set_state_for_with(id, arg_default);
"Closed" => State::Closed,
"Done" => State::Done,
_ => State::Open,
});
} }
}, },
Some('#') | Some('+') => { Some('#') | Some('+') => {
tasks.add_tag(arg.to_string()); match arg {
info!("Added tag filter for #{arg}") Some(arg) => tasks.add_tag(arg.to_string()),
None => tasks.clear_filter()
}
} }
Some('-') => { Some('-') => {
tasks.remove_tag(arg.to_string()); match arg {
info!("Removed tag filter for #{arg}") Some(arg) => tasks.remove_tag(arg),
None => tasks.clear_filter()
}
} }
Some('*') => { Some('*') => match arg {
if let Ok(num) = arg.parse::<i64>() { Some(arg) => {
tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num))); if let Ok(num) = arg.parse::<i64>() {
} else if let Ok(date) = DateTime::parse_from_rfc3339(arg) { tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num)));
tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64)); } else if let Ok(date) = DateTime::parse_from_rfc3339(arg) {
} else { tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64));
warn!("Cannot parse {arg}"); } else {
warn!("Cannot parse {arg}");
}
}
None => {
// TODO time tracked list
// continue
} }
} }
@ -372,12 +369,12 @@ async fn main() {
pos = tasks.get_parent(pos).cloned(); pos = tasks.get_parent(pos).cloned();
} }
let slice = &input[dots..]; let slice = &input[dots..];
tasks.move_to(pos);
if slice.is_empty() { if slice.is_empty() {
tasks.move_to(pos); if dots > 1 {
continue; info!("Moving up {} tasks", dots - 1)
} }
if let Ok(depth) = slice.parse::<i8>() { } else if let Ok(depth) = slice.parse::<i8>() {
tasks.move_to(pos);
tasks.set_depth(depth); tasks.set_depth(depth);
} else { } else {
tasks.filter_or_create(slice).map(|id| tasks.move_to(Some(id))); 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(); let slice = &input[dots..].to_ascii_lowercase();
if slice.is_empty() { if slice.is_empty() {
tasks.move_to(pos); tasks.move_to(pos);
continue; } else if let Ok(depth) = slice.parse::<i8>() {
}
if let Ok(depth) = slice.parse::<i8>() {
tasks.move_to(pos); tasks.move_to(pos);
tasks.set_depth(depth); tasks.set_depth(depth);
} else { } else {
@ -434,11 +429,14 @@ async fn main() {
if new_relay.is_some() { if new_relay.is_some() {
selected_relay = new_relay; selected_relay = new_relay;
} }
//or_print(tasks.print_tasks());
continue
} else { } else {
tasks.filter_or_create(&input); tasks.filter_or_create(&input);
} }
} }
} }
or_print(tasks.print_tasks());
} }
Some(Err(e)) => warn!("{}", e), Some(Err(e)) => warn!("{}", e),
None => break, None => break,

View File

@ -49,15 +49,19 @@ impl Task {
.unwrap_or_else(|| self.get_id().to_string()) .unwrap_or_else(|| self.get_id().to_string())
} }
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ { pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
self.props.iter().filter_map(|event| { self.props.iter().filter_map(|event| {
if event.kind == Kind::TextNote { if event.kind == Kind::TextNote {
Some(&event.content) Some(event)
} else { } else {
None None
} }
}) })
} }
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content)
}
fn states(&self) -> impl Iterator<Item=TaskState> + '_ { fn states(&self) -> impl Iterator<Item=TaskState> + '_ {
self.props.iter().filter_map(|event| { self.props.iter().filter_map(|event| {
@ -118,11 +122,11 @@ impl Task {
self.props self.props
.iter() .iter()
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content)) .map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content))
.collect::<Vec<String>>() .collect_vec()
)), )),
"descriptions" => Some(format!( "descriptions" => Some(format!(
"{:?}", "{:?}",
self.descriptions().collect::<Vec<&String>>() self.descriptions().collect_vec()
)), )),
_ => { _ => {
warn!("Unknown task property {}", property); warn!("Unknown task property {}", property);

View File

@ -1,10 +1,10 @@
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
use std::io::{Error, stdout, Write}; use std::io::{Error, stdout, Write};
use std::iter::once; use std::iter::{once, Sum};
use std::ops::{Div, Rem}; use std::ops::{Div, Rem};
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::time::Duration;
use chrono::{Local, TimeZone}; use chrono::{DateTime, Local, TimeZone};
use chrono::LocalResult::Single; use chrono::LocalResult::Single;
use colored::Colorize; use colored::Colorize;
use itertools::Itertools; use itertools::Itertools;
@ -103,45 +103,18 @@ impl Tasks {
children 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 { 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::<Duration>().as_secs()
} }
/// Total time tracked on this task and its subtasks by all users. /// Total time in seconds tracked on this task and its subtasks by all users.
/// TODO needs testing!
fn total_time_tracked(&self, id: EventId) -> u64 { fn total_time_tracked(&self, id: EventId) -> u64 {
let mut total = 0; let mut total = 0;
let children = self.get_subtasks(id); let children = self.get_subtasks(id);
for user in self.history.values() { for user in self.history.values() {
total += Self::time_tracked_for(user, &children); total += TimesTracked::from(user, &children).into_iter().sum::<Duration>().as_secs();
}
total
}
fn time_tracked_for<'a, E>(events: E, ids: &Vec<EventId>) -> u64
where
E: IntoIterator<Item=&'a Event>,
{
let mut total = 0;
let mut start: Option<Timestamp> = 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 total
} }
@ -253,7 +226,7 @@ impl Tasks {
} }
#[inline] #[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)) self.position.and_then(|id| self.get_by_id(&id))
} }
@ -266,7 +239,7 @@ impl Tasks {
pub(crate) fn current_tasks(&self) -> Vec<&Task> { pub(crate) fn current_tasks(&self) -> Vec<&Task> {
if self.depth == 0 { 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()); let res: Vec<&Task> = self.resolve_tasks(self.view.iter());
if res.len() > 0 { if res.len() > 0 {
@ -295,7 +268,7 @@ impl Tasks {
pub(crate) fn print_tasks(&self) -> Result<(), Error> { pub(crate) fn print_tasks(&self) -> Result<(), Error> {
let mut lock = stdout().lock(); 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(); let state = t.state_or_default();
writeln!( writeln!(
lock, lock,
@ -362,7 +335,6 @@ impl Tasks {
.join(" \t") .join(" \t")
)?; )?;
} }
writeln!(lock)?;
Ok(()) Ok(())
} }
@ -372,23 +344,37 @@ impl Tasks {
self.view = view; 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) { pub(crate) fn add_tag(&mut self, tag: String) {
self.view.clear(); self.view.clear();
info!("Added tag filter for #{tag}");
self.tags.insert(Hashtag(tag).into()); 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.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<String>) { pub(crate) fn set_state_filter(&mut self, state: Option<String>) {
self.view.clear(); self.view.clear();
info!("Filtering for {}", state.as_ref().map_or("open tasks".to_string(), |s| format!("state {s}")));
self.state = state; self.state = state;
} }
pub(crate) fn move_up(&mut self) { 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) { pub(crate) fn flush(&self) {
@ -613,19 +599,19 @@ impl Tasks {
} }
} }
} }
// Properties // Properties
pub(crate) fn set_depth(&mut self, depth: i8) { pub(crate) fn set_depth(&mut self, depth: i8) {
self.depth = depth; self.depth = depth;
info!("Changed view depth to {depth}"); info!("Changed view depth to {depth}");
} }
pub(crate) fn remove_column(&mut self, index: usize) { pub(crate) fn remove_column(&mut self, index: usize) {
let col = self.properties.remove(index); let col = self.properties.remove(index);
info!("Removed property column \"{col}\""); info!("Removed property column \"{col}\"");
} }
pub(crate) fn add_or_remove_property_column(&mut self, property: &str) { pub(crate) fn add_or_remove_property_column(&mut self, property: &str) {
match self.properties.iter().position(|s| s == property) { match self.properties.iter().position(|s| s == property) {
None => { None => {
@ -646,7 +632,6 @@ impl Tasks {
self.properties.insert(index, property); self.properties.insert(index, property);
} }
} }
} }
/// Formats the given seconds according to the given format. /// Formats the given seconds according to the given format.
@ -686,6 +671,43 @@ pub(crate) fn join_tasks<'a>(
}) })
} }
struct TimesTracked<'a> {
events: Box<dyn Iterator<Item=&'a Event> + 'a>,
ids: &'a Vec<EventId>,
}
impl TimesTracked<'_> {
fn from<'b>(events: impl IntoIterator<Item=&'b Event> + 'b, ids: &'b Vec<EventId>) -> TimesTracked<'b> {
TimesTracked {
events: Box::new(events.into_iter()),
ids,
}
}
}
impl Iterator for TimesTracked<'_> {
type Item = Duration;
fn next(&mut self) -> Option<Self::Item> {
let mut start: Option<u64> = 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> { struct ParentIterator<'a> {
tasks: &'a TaskMap, tasks: &'a TaskMap,
current: Option<EventId>, current: Option<EventId>,
@ -740,7 +762,7 @@ mod tasks_test {
tasks.track_at(Timestamp::from(2)); tasks.track_at(Timestamp::from(2));
assert_eq!(tasks.get_own_history().unwrap().len(), 3); assert_eq!(tasks.get_own_history().unwrap().len(), 3);
assert_eq!(tasks.time_tracked(zero), 1); assert_eq!(tasks.time_tracked(zero), 1);
// TODO test received events // TODO test received events
} }
@ -832,7 +854,6 @@ mod tasks_test {
"0000000000000000000000000000000000000000000000000000000000000000>test" "0000000000000000000000000000000000000000000000000000000000000000>test"
); );
assert_eq!(tasks.relative_path(dangling), "test"); assert_eq!(tasks.relative_path(dangling), "test");
} }
#[allow(dead_code)] #[allow(dead_code)]