Compare commits

...

9 Commits

Author SHA1 Message Date
xeruf bdb8b6e814 fix(main): show correct relay url on relay change 2024-10-15 03:02:46 +02:00
xeruf b0c92e64fa feat(tasks): expand Bookmarks display to Quick Access
Now also including recently created tasks
2024-10-15 03:01:57 +02:00
xeruf 4e4ad7099f fix(tasks): do not find children of closed tasks globally 2024-10-15 03:00:12 +02:00
xeruf 613a8b3822 feat(tasks): display bookmarks and time summary 2024-10-14 16:44:35 +02:00
xeruf 1533676bff fix: do not show all tasks when filter has no matches 2024-10-14 16:39:44 +02:00
xeruf 52be8c53eb feat: revamp task printing through recursive sorting
Still to be fixed: Bookmarks, Time Summary
2024-10-14 16:10:56 +02:00
xeruf 5f25e116a1 feat: allow filtering tasks by author name 2024-10-13 17:15:43 +02:00
xeruf d1720f89ae fix(tasks): do not show progress for activities 2024-10-13 16:01:55 +02:00
xeruf f6082f12f2 fix(tasks): prevent crashes at zero depth 2024-10-12 21:55:32 +02:00
3 changed files with 154 additions and 90 deletions

View File

@ -299,7 +299,7 @@ async fn main() -> Result<()> {
queue_events.append(&mut events);
queue = Some((queue_url, queue_events));
} else {
info!("Sending {} events to {url} due to relay change", queue_events.len());
info!("Sending {} events to {queue_url} due to relay change", queue_events.len());
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
queue = None;
}
@ -386,7 +386,7 @@ async fn main() -> Result<()> {
None => {
debug!("Flushing Tasks because of empty command");
tasks.flush();
or_warn!(tasks.print_tasks());
println!("{}", tasks);
continue 'repl;
}
Some('@') => {}
@ -500,9 +500,12 @@ async fn main() -> Result<()> {
info!("Filtering for own tasks");
tasks.set_filter_author(keys.public_key())
} else if let Ok(key) = PublicKey::from_str(arg) {
let author = tasks.get_author(&key);
let author = tasks.get_username(&key);
info!("Filtering for tasks by {author}");
tasks.set_filter_author(key)
} else if let Some((key, meta)) = tasks.find_user(arg) {
info!("Filtering for tasks by {}", meta.display_name.as_ref().unwrap_or(meta.name.as_ref().unwrap_or(&key.to_string())));
tasks.set_filter_author(key.clone())
} else {
parse_hour(arg, 1)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
@ -727,7 +730,9 @@ async fn main() -> Result<()> {
tasks.move_to(filtered.into_iter().next());
} else {
tasks.move_to(pos.cloned());
tasks.set_view(filtered);
if !tasks.set_view(filtered) {
continue 'repl;
}
}
}
}
@ -737,7 +742,7 @@ async fn main() -> Result<()> {
tasks.move_to(None);
if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_ref().is_some_and(|url| url.as_str().starts_with(&command))) {
selected_relay.clone_from(url);
or_warn!(tasks.print_tasks());
println!("{}", tasks);
continue 'repl;
}
or_warn!(Url::parse(&command), "Failed to parse url {}", command).map(|url| {
@ -762,7 +767,7 @@ async fn main() -> Result<()> {
}
}
tasks.custom_time = None;
or_warn!(tasks.print_tasks());
println!("{}", tasks);
}
Err(ReadlineError::Eof) => break 'repl,
Err(ReadlineError::Interrupted) => break 'repl, // TODO exit if prompt was empty, or clear

View File

@ -2,6 +2,7 @@ use fmt::Display;
use std::cmp::Ordering;
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::string::ToString;
use colored::{ColoredString, Colorize};
@ -40,6 +41,12 @@ impl Ord for Task {
}
}
impl Hash for Task {
fn hash<H: Hasher>(&self, state: &mut H) {
self.event.id.hash(state);
}
}
impl Task {
pub(crate) fn new(event: Event) -> Task {
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {

View File

@ -60,7 +60,7 @@ pub(crate) struct TasksRelay {
/// The task properties currently visible
properties: Vec<String>,
/// The task properties sorted by
sorting: VecDeque<String>,
sorting: VecDeque<String>, // TODO track boolean for reversal?
/// A filtered view of the current tasks.
/// Would like this to be Task references
@ -210,6 +210,13 @@ impl TasksRelay {
self.get_position_at(now()).1
}
fn sorting_key(&self, task: &Task) -> impl Ord {
self.sorting
.iter()
.map(|p| self.get_property(task, p.as_str()))
.collect_vec()
}
// TODO binary search
/// Gets last position change before the given timestamp
fn get_position_at(&self, timestamp: Timestamp) -> (Timestamp, Option<&EventId>) {
@ -270,11 +277,11 @@ impl TasksRelay {
vec.push(format!("{} - {} by {}",
format_timestamp_local(start),
format_timestamp_relative_to(end, start),
self.get_author(key)))
self.get_username(key)))
}
iter.into_buffer()
.for_each(|(stamp, _)|
vec.push(format!("{} started by {}", format_timestamp_local(stamp), self.get_author(key))));
vec.push(format!("{} started by {}", format_timestamp_local(stamp), self.get_username(key))));
vec
}).sorted_unstable(); // TODO sorting depends on timestamp format - needed to interleave different people
(format!("Times Tracked on {:?}", self.get_task_title(id)), Box::from(history))
@ -370,26 +377,19 @@ impl TasksRelay {
// Helpers
fn resolve_tasks<'a>(
&'a self,
iter: impl Iterator<Item=&'a Task>,
sparse: bool,
) -> Vec<&'a Task> {
self.resolve_tasks_rec(iter, sparse, self.search_depth + self.view_depth)
}
fn resolve_tasks_rec<'a>(
&'a self,
iter: impl Iterator<Item=&'a Task>,
sparse: bool,
depth: usize,
) -> Vec<&'a Task> {
iter.flat_map(move |task| {
iter.sorted_by_cached_key(|task| self.sorting_key(task))
.flat_map(move |task| {
if !self.state.matches(task) {
return vec![]
return vec![];
}
let mut new_depth = depth;
if !self.recurse_activities || task.is_task() {
if depth > 0 && (!self.recurse_activities || task.is_task()) {
new_depth = depth - 1;
if sparse && new_depth > self.view_depth && self.filter(task) {
new_depth = self.view_depth;
@ -443,7 +443,8 @@ impl TasksRelay {
}
pub(crate) fn filtered_tasks<'a>(&'a self, position: Option<&'a EventId>, sparse: bool) -> Vec<&'a Task> {
let mut current = self.resolve_tasks(self.tasks.children_for(position), sparse);
let roots = self.tasks.children_for(position);
let mut current = self.resolve_tasks_rec(roots, sparse, self.search_depth + self.view_depth);
if current.is_empty() {
if !self.tags.is_empty() {
let mut children = self.tasks.children_for(self.get_position_ref()).peekable();
@ -487,75 +488,13 @@ impl TasksRelay {
self.filtered_tasks(self.get_position_ref(), true)
}
pub(crate) fn print_tasks(&self) -> Result<(), Error> {
let mut lock = stdout().lock();
if let Some(t) = self.get_current_task() {
let state = t.state_or_default();
let now = &now();
let mut tracking_stamp: Option<Timestamp> = None;
for elem in
timestamps(self.get_own_events_history(), &[t.get_id()])
.map(|(e, _)| e) {
if tracking_stamp.is_some() && elem > now {
break;
}
tracking_stamp = Some(*elem)
}
writeln!(
lock,
"Tracking since {} (total tracked time {}m) - {} since {}",
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
self.time_tracked(*t.get_id()) / 60,
state.get_label(),
format_timestamp_relative(&state.time)
)?;
writeln!(lock, "{}", t.descriptions().join("\n"))?;
}
let mut tasks = self.visible_tasks();
if tasks.is_empty() {
let (label, times) = self.times_tracked();
let mut times_recent = times.rev().take(6).collect_vec();
times_recent.reverse();
// TODO Add recent prefix
writeln!(lock, "{}\n{}", label.italic(), times_recent.join("\n"))?;
return Ok(());
}
// TODO proper column alignment
// TODO hide empty columns
writeln!(lock, "{}", self.properties.join("\t").bold())?;
let mut total_time = 0;
let count = tasks.len();
tasks.sort_by_cached_key(|task| {
self.sorting
.iter()
.map(|p| self.get_property(task, p.as_str()))
.collect_vec()
});
for task in tasks {
writeln!(
lock,
"{}",
self.properties.iter()
.map(|p| self.get_property(task, p.as_str()))
.join(" \t")
)?;
if self.view_depth < 2 || task.parent_id() == self.get_position_ref() {
total_time += self.total_time_tracked(task.event.id)
}
}
if total_time > 0 {
writeln!(lock, "{} visible tasks{}", count, display_time(" tracked a total of HHhMMm", total_time))?;
}
Ok(())
}
fn get_property(&self, task: &Task, str: &str) -> String {
let mut children = self.tasks.children_of(task).peekable();
// Only show progress for non-activities with children
let progress =
self.total_progress(task.get_id())
.filter(|_| children.peek().is_some());
children.peek()
.filter(|_| task.is_task())
.and_then(|_| self.total_progress(task.get_id()));
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
match str {
"subtasks" => {
@ -585,7 +524,7 @@ impl TasksRelay {
}
"progress" => prog_string.clone(),
"author" => format!("{:.6}", self.get_author(&task.event.pubkey)), // FIXME temporary until proper column alignment
"author" => format!("{:.6}", self.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment
"path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id),
// TODO format strings configurable
@ -595,7 +534,14 @@ impl TasksRelay {
}
}
pub(crate) fn get_author(&self, pubkey: &PublicKey) -> String {
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
self.users.iter().find(|(_, v)|
// TODO regex word boundary
v.name.as_ref().is_some_and(|n| n.starts_with(term)) ||
v.display_name.as_ref().is_some_and(|n| n.starts_with(term)))
}
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.name.clone())
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
@ -748,7 +694,7 @@ impl TasksRelay {
for task in self.tasks.values() {
if task.get_filter_title().to_ascii_lowercase() == lowercase_arg &&
// exclude closed tasks and their subtasks
!self.traverse_up_from(Some(*task.get_id())).any(|t| t.pure_state() == State::Closed) {
!self.traverse_up_from(Some(*task.get_id())).any(|t| !t.pure_state().is_open()) {
return vec![task.event.id];
}
}
@ -1134,6 +1080,109 @@ impl TasksRelay {
}
}
impl Display for TasksRelay {
fn fmt(&self, lock: &mut Formatter<'_>) -> std::fmt::Result {
if let Some(t) = self.get_current_task() {
let state = t.state_or_default();
let now = &now();
let mut tracking_stamp: Option<Timestamp> = None;
for elem in
timestamps(self.get_own_events_history(), &[t.get_id()])
.map(|(e, _)| e) {
if tracking_stamp.is_some() && elem > now {
break;
}
tracking_stamp = Some(*elem)
}
writeln!(
lock,
"Active from {} (total tracked time {}m) - {} since {}",
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
self.time_tracked(*t.get_id()) / 60,
state.get_label(),
format_timestamp_relative(&state.time)
)?;
writeln!(lock, "{}", t.descriptions().join("\n"))?;
}
let position = self.get_position_ref();
let mut current = vec![];
let mut roots = self.view.iter().flat_map(|id| self.get_by_id(id)).collect_vec();
if self.search_depth > 0 && roots.is_empty() {
current = self.resolve_tasks_rec(self.tasks.children_for(position), true, self.search_depth + self.view_depth);
if current.is_empty() {
if !self.tags.is_empty() {
let mut children = self.tasks.children_for(position).peekable();
if children.peek().is_some() {
current = self.resolve_tasks_rec(children, true, 9);
if current.is_empty() {
writeln!(lock, "No tasks here matching{}", self.get_prompt_suffix())?;
} else {
writeln!(lock, "Found matching tasks beyond specified search depth:")?;
}
}
}
}
} else {
current = self.resolve_tasks_rec(roots.iter().cloned(), true, self.view_depth);
}
if current.is_empty() {
let (label, times) = self.times_tracked();
let mut times_recent = times.rev().take(6).collect_vec();
times_recent.reverse();
// TODO Add recent prefix
writeln!(lock, "{}\n{}", label.italic(), times_recent.join("\n"))?;
return Ok(());
}
let tree = current.iter().flat_map(|task| self.traverse_up_from(Some(task.event.id))).unique();
let ids: HashSet<&EventId> = tree.map(|t| t.get_id()).chain(position).collect();
let mut bookmarks =
// TODO add recent tasks (most time tracked + recently created)
self.bookmarks.iter()
.chain(self.tasks.values().sorted_unstable().take(3).map(|t| t.get_id()))
.filter(|id| !ids.contains(id))
.filter_map(|id| self.get_by_id(id))
.filter(|t| self.filter(t))
.sorted_by_cached_key(|t| self.sorting_key(t))
.dedup()
.peekable();
if bookmarks.peek().is_some() {
writeln!(lock, "{}", Colorize::bold("Quick Access"))?;
for task in bookmarks {
writeln!(
lock,
"{}",
self.properties.iter()
.map(|p| self.get_property(task, p.as_str()))
.join(" \t")
)?;
}
}
// TODO proper column alignment
// TODO hide empty columns
writeln!(lock, "{}", self.properties.join(" \t").bold())?;
let count = current.len();
let mut total_time = 0;
for task in current {
writeln!(
lock,
"{}",
self.properties.iter()
.map(|p| self.get_property(task, p.as_str()))
.join(" \t")
)?;
total_time += self.total_time_tracked(task.event.id) // TODO include parent if it matches
}
writeln!(lock, "{} visible tasks{}", count, display_time(" tracked a total of HHhMMm", total_time))?;
Ok(())
}
}
pub trait PropertyCollection<T> {
fn remove_at(&mut self, index: usize);
fn add_or_remove(&mut self, value: T);
@ -1646,6 +1695,8 @@ mod tasks_test {
assert_eq!(tasks.visible_tasks().len(), 1);
tasks.search_depth = 0;
assert_eq!(tasks.visible_tasks().len(), 0);
tasks.recurse_activities = false;
assert_eq!(tasks.filtered_tasks(None, false).len(), 1);
tasks.move_to(Some(t1));
assert_position!(tasks, t1);
@ -1690,6 +1741,7 @@ mod tasks_test {
assert_tasks!(tasks, [t11, t12]);
tasks.move_to(None);
tasks.recurse_activities = true;
assert_tasks!(tasks, [t11, t12]);
tasks.recurse_activities = false;
assert_tasks!(tasks, [t1]);