Compare commits

...

4 Commits

Author SHA1 Message Date
xeruf b3d70ab0b7 feat(tasks): enable excluding tags from view 2024-08-11 12:28:08 +03:00
xeruf c83d8a2f55 feat(tasks): option to fully set sorting 2024-08-11 12:05:29 +03:00
xeruf a7d02e60b2 feat(tasks): make sorting by property customizable 2024-08-11 10:58:34 +03:00
xeruf 55792ca34f feat(tasks): sorting by property 2024-08-11 10:01:46 +03:00
3 changed files with 135 additions and 77 deletions

View File

@ -105,7 +105,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
Dots and slashes can be repeated to move to parent tasks. Dots and slashes can be repeated to move to parent tasks.
- `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (1-indexed) - `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (1-indexed)
- `::[PROP]` - Sort by property PROP - `::[PROP]` - Sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - insert timetracking with the specified offset in minutes (empty: list tracked times) - `([TIME]` - insert timetracking with the specified offset in minutes (empty: list tracked times)
- `)[TIME]` - stop timetracking with the specified offset in minutes - convenience helper to move to root (empty: stop now) - `)[TIME]` - stop timetracking with the specified offset in minutes - convenience helper to move to root (empty: stop now)
- `>[TEXT]` - complete active task and move to parent, with optional state description - `>[TEXT]` - complete active task and move to parent, with optional state description

View File

@ -1,5 +1,5 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::env::{args, var}; use std::env::{args, var};
use std::fs; use std::fs;
use std::fs::File; use std::fs::File;
@ -21,7 +21,7 @@ use xdg::BaseDirectories;
use crate::helpers::*; use crate::helpers::*;
use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND}; use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND};
use crate::task::State; use crate::task::State;
use crate::tasks::{StateFilter, Tasks}; use crate::tasks::{PropertyCollection, StateFilter, Tasks};
mod helpers; mod helpers;
mod task; mod task;
@ -277,21 +277,31 @@ async fn main() {
tasks.flush() tasks.flush()
} }
Some(':') => Some(':') => {
if let Some(digit) = iter.next().and_then(|s| s.to_digit(10)) { let next = iter.next();
if let Some(':') = next {
let str: String = iter.collect();
let result = str.split_whitespace().map(|s| s.to_string()).collect::<VecDeque<_>>();
if result.len() == 1 {
tasks.add_sorting_property(str.trim().to_string())
} else {
tasks.set_sorting(result)
}
} else if let Some(digit) = 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.get_columns().remove_at(index);
} else { } else {
tasks.add_or_remove_property_column_at_index(remaining, index); tasks.get_columns().add_or_remove_at(remaining, index);
} }
} else if let Some(arg) = arg { } else if let Some(arg) = arg {
tasks.add_or_remove_property_column(arg); tasks.get_columns().add_or_remove(arg.to_string());
} else { } else {
println!("{}", PROPERTY_COLUMNS); println!("{}", PROPERTY_COLUMNS);
continue; continue;
}, }
}
Some(',') => Some(',') =>
match arg { match arg {

View File

@ -1,4 +1,4 @@
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap, VecDeque};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::io::{Error, stdout, Write}; use std::io::{Error, stdout, Write};
use std::iter::once; use std::iter::once;
@ -30,6 +30,8 @@ pub(crate) struct Tasks {
history: HashMap<PublicKey, BTreeSet<Event>>, history: HashMap<PublicKey, BTreeSet<Event>>,
/// The task properties currently visible /// The task properties currently visible
properties: Vec<String>, properties: Vec<String>,
/// The task properties sorted by
sorting: VecDeque<String>,
/// Negative: Only Leaf nodes /// Negative: Only Leaf nodes
/// Zero: Only Active node /// Zero: Only Active node
/// Positive: Go down the respective level /// Positive: Go down the respective level
@ -39,6 +41,8 @@ pub(crate) struct Tasks {
position: Option<EventId>, position: Option<EventId>,
/// Currently active tags /// Currently active tags
tags: BTreeSet<Tag>, tags: BTreeSet<Tag>,
/// Tags filtered out
tags_excluded: BTreeSet<Tag>,
/// Current active state /// Current active state
state: StateFilter, state: StateFilter,
/// A filtered view of the current tasks /// A filtered view of the current tasks
@ -121,9 +125,14 @@ impl Tasks {
"rpath".into(), "rpath".into(),
"desc".into(), "desc".into(),
], ],
sorting: VecDeque::from([
"state".into(),
"name".into(),
]),
position: None, // TODO persist position position: None, // TODO persist position
view: Default::default(), view: Default::default(),
tags: Default::default(), tags: Default::default(),
tags_excluded: Default::default(),
state: Default::default(), state: Default::default(),
depth: 1, depth: 1,
sender, sender,
@ -259,9 +268,10 @@ impl Tasks {
} }
pub(crate) fn get_prompt_suffix(&self) -> String { pub(crate) fn get_prompt_suffix(&self) -> String {
self.tags self.tags.iter()
.iter()
.map(|t| format!(" #{}", t.content().unwrap())) .map(|t| format!(" #{}", t.content().unwrap()))
.chain(self.tags_excluded.iter()
.map(|t| format!(" -#{}", t.content().unwrap())))
.chain(once(self.state.indicator())) .chain(once(self.state.indicator()))
.join("") .join("")
} }
@ -358,12 +368,15 @@ impl Tasks {
self.resolve_tasks(self.children_of(self.position)).into_iter() self.resolve_tasks(self.children_of(self.position)).into_iter()
.filter(|t| { .filter(|t| {
// TODO apply filters in transit // TODO apply filters in transit
let state = t.pure_state(); self.state.matches(t) &&
self.state.matches(t) && (self.tags.is_empty() t.tags.as_ref().map_or(true, |tags| {
|| t.tags.as_ref().map_or(false, |tags| { tags.iter().find(|tag| self.tags_excluded.contains(tag)).is_none()
let mut iter = tags.iter(); }) &&
self.tags.iter().all(|tag| iter.any(|t| t == tag)) (self.tags.is_empty() ||
})) t.tags.as_ref().map_or(false, |tags| {
let mut iter = tags.iter();
self.tags.iter().all(|tag| iter.any(|t| t == tag))
}))
}) })
.collect() .collect()
} }
@ -399,62 +412,26 @@ impl Tasks {
writeln!(lock, "{}", t.descriptions().join("\n"))?; writeln!(lock, "{}", t.descriptions().join("\n"))?;
} }
// TODO proper column alignment // TODO proper column alignment
// TODO hide empty columns and sorting // TODO hide empty columns
writeln!(lock, "{}", self.properties.join("\t").bold())?; writeln!(lock, "{}", self.properties.join("\t").bold())?;
let mut total_time = 0; let mut total_time = 0;
for task in self.current_tasks() { let mut tasks = self.current_tasks();
let progress = tasks.sort_by_cached_key(|task| {
self self.sorting
.total_progress(task.get_id()) .iter()
.filter(|_| task.children.len() > 0); .map(|p| self.get_property(task, p.as_str()))
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0)); .collect_vec()
});
for task in tasks {
writeln!( writeln!(
lock, lock,
"{}", "{}",
self.properties self.properties
.iter() .iter()
.map(|p| match p.as_str() { .map(|p| self.get_property(task, p.as_str()))
"subtasks" => {
let mut total = 0;
let mut done = 0;
for subtask in task.children.iter().filter_map(|id| self.get_by_id(id))
{
let state = subtask.pure_state();
total += &(state != State::Closed).into();
done += &(state == State::Done).into();
}
if total > 0 {
format!("{done}/{total}")
} else {
"".to_string()
}
}
"state" => {
if let Some(task) = task.get_dependendees().iter().filter_map(|id| self.get_by_id(id)).find(|t| t.pure_state().is_open()) {
return format!("Blocked by \"{}\"", task.get_title()).bright_red().to_string()
}
let state = task.state_or_default();
if state.state.is_open() && progress.is_some_and(|p| p > 0.1) {
state.state.colorize(&prog_string)
} else {
state.get_colored_label()
}.to_string()
}
"progress" => prog_string.clone(),
"path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id),
// TODO format strings configurable
"time" => display_time("MMMm", self.time_tracked(*task.get_id())),
"rtime" => {
let time = self.total_time_tracked(*task.get_id());
total_time += time;
display_time("HH:MM", time)
}
prop => task.get(prop).unwrap_or(String::new()),
})
.collect::<Vec<String>>()
.join(" \t") .join(" \t")
)?; )?;
total_time += self.total_time_tracked(task.event.id)
} }
if total_time > 0 { if total_time > 0 {
writeln!(lock, "{}", display_time("Total time tracked on visible tasks: HHh MMm", total_time))?; writeln!(lock, "{}", display_time("Total time tracked on visible tasks: HHh MMm", total_time))?;
@ -462,6 +439,48 @@ impl Tasks {
Ok(()) Ok(())
} }
fn get_property(&self, task: &Task, str: &str) -> String {
let progress =
self
.total_progress(task.get_id())
.filter(|_| task.children.len() > 0);
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
match str {
"subtasks" => {
let mut total = 0;
let mut done = 0;
for subtask in task.children.iter().filter_map(|id| self.get_by_id(id)) {
let state = subtask.pure_state();
total += &(state != State::Closed).into();
done += &(state == State::Done).into();
}
if total > 0 {
format!("{done}/{total}")
} else {
"".to_string()
}
}
"state" => {
if let Some(task) = task.get_dependendees().iter().filter_map(|id| self.get_by_id(id)).find(|t| t.pure_state().is_open()) {
return format!("Blocked by \"{}\"", task.get_title()).bright_red().to_string();
}
let state = task.state_or_default();
if state.state.is_open() && progress.is_some_and(|p| p > 0.1) {
state.state.colorize(&prog_string)
} else {
state.get_colored_label()
}.to_string()
}
"progress" => prog_string.clone(),
"path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id),
// TODO format strings configurable
"time" => display_time("MMMm", self.time_tracked(*task.get_id())),
"rtime" => display_time("HH:MM", self.total_time_tracked(*task.get_id())),
prop => task.get(prop).unwrap_or(String::new()),
}
}
// Movement and Selection // Movement and Selection
pub(crate) fn set_filter(&mut self, view: Vec<EventId>) { pub(crate) fn set_filter(&mut self, view: Vec<EventId>) {
@ -471,10 +490,12 @@ impl Tasks {
pub(crate) fn clear_filter(&mut self) { pub(crate) fn clear_filter(&mut self) {
self.view.clear(); self.view.clear();
self.tags.clear(); self.tags.clear();
self.tags_excluded.clear();
info!("Removed all filters"); info!("Removed all filters");
} }
pub(crate) fn set_tag(&mut self, tag: String) { pub(crate) fn set_tag(&mut self, tag: String) {
self.tags_excluded.clear();
self.tags.clear(); self.tags.clear();
self.add_tag(tag); self.add_tag(tag);
} }
@ -482,7 +503,9 @@ impl Tasks {
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}"); info!("Added tag filter for #{tag}");
self.tags.insert(Hashtag(tag).into()); let tag: Tag = Hashtag(tag).into();
self.tags_excluded.remove(&tag);
self.tags.insert(tag);
} }
pub(crate) fn remove_tag(&mut self, tag: &str) { pub(crate) fn remove_tag(&mut self, tag: &str) {
@ -492,7 +515,8 @@ impl Tasks {
if self.tags.len() < len { if self.tags.len() < len {
info!("Removed tag filters starting with {tag}"); info!("Removed tag filters starting with {tag}");
} else { } else {
info!("Found no tag filters starting with {tag} to remove"); self.tags_excluded.insert(Hashtag(tag.to_string()).into());
info!("Excluding #{tag} from view");
} }
} }
@ -776,29 +800,53 @@ impl Tasks {
info!("Changed view depth to {depth}"); info!("Changed view depth to {depth}");
} }
pub(crate) fn remove_column(&mut self, index: usize) { pub(crate) fn get_columns(&mut self) -> &mut Vec<String> {
let col = self.properties.remove(index); &mut self.properties
}
pub(crate) fn set_sorting(&mut self, vec: VecDeque<String>) {
self.sorting = vec;
info!("Now sorting by {:?}", self.sorting);
}
pub(crate) fn add_sorting_property(&mut self, property: String) {
// TODO reverse order if already present
self.sorting.push_front(property);
self.sorting.truncate(4);
info!("Now sorting by {:?}", self.sorting);
}
}
pub trait PropertyCollection<T> {
fn remove_at(&mut self, index: usize);
fn add_or_remove(&mut self, value: T);
fn add_or_remove_at(&mut self, value: T, index: usize);
}
impl <T> PropertyCollection<T> for Vec<T>
where T: Display, T: Eq, T: Clone {
fn remove_at(&mut self, index: usize) {
let col = self.remove(index);
info!("Removed property column \"{col}\""); info!("Removed property column \"{col}\"");
} }
pub(crate) fn add_or_remove_property_column(&mut self, property: &str) { fn add_or_remove(&mut self, property: T) {
match self.properties.iter().position(|s| s == property) { match self.iter().position(|s| s == &property) {
None => { None => {
self.properties.push(property.to_string());
info!("Added property column \"{property}\""); info!("Added property column \"{property}\"");
self.push(property);
} }
Some(index) => { Some(index) => {
self.properties.remove(index); self.remove_at(index);
} }
} }
} }
pub(crate) fn add_or_remove_property_column_at_index(&mut self, property: String, index: usize) { fn add_or_remove_at(&mut self, property: T, index: usize) {
if self.properties.get(index) == Some(&property) { if self.get(index) == Some(&property) {
self.properties.remove(index); self.remove_at(index);
} else { } else {
info!("Added property column \"{property}\" at position {}", index + 1); info!("Added property column \"{property}\" at position {}", index + 1);
self.properties.insert(index, property); self.insert(index, property);
} }
} }
} }