Compare commits

..

9 commits

Author SHA1 Message Date
xeruf
bf802e3195 feat: filter with slash 2024-08-01 21:40:15 +03:00
xeruf
03f9e60c6f feat: activate perfect match and filter without dot 2024-08-01 21:11:33 +03:00
xeruf
5b05c53947 fix: task progress percentage 2024-08-01 20:40:55 +03:00
xeruf
7f34a888f3 feat: make tags sticky and allow manual removal 2024-08-01 20:12:04 +03:00
xeruf
256c86e06f fix: move text notes to comma key 2024-08-01 20:04:56 +03:00
xeruf
a9509fd4f2 feat: fold repeated time tracking events 2024-08-01 20:00:45 +03:00
xeruf
36fe58d3f3 fix(tasks): time tracking within current session 2024-08-01 19:48:05 +03:00
xeruf
14dcc8f0ff fix(tasks): comment persistence 2024-08-01 19:18:46 +03:00
xeruf
486cbb1ab4 feat: undo function with @ 2024-08-01 19:10:58 +03:00
3 changed files with 156 additions and 73 deletions

View file

@ -37,7 +37,7 @@ To exit the application, press `Ctrl-D`.
### Navigation and Nesting ### Navigation and Nesting
Create tasks and navigate using the shortcuts below. Create tasks and navigate using the shortcuts below.
Whichever task is selected / "active" Whichever task is active (selected)
will be the parent task for newly created tasks will be the parent task for newly created tasks
and automatically has time-tracking running. and automatically has time-tracking running.
To track task progress, To track task progress,
@ -48,7 +48,7 @@ Generally a flat hierarchy is recommended
with tags for filtering, with tags for filtering,
since hierarchies cannot be changed. since hierarchies cannot be changed.
Filtering by a tag is just as easy Filtering by a tag is just as easy
as selecting a task and more flexible. as activating a task and more flexible.
Using subtasks has two main advantages: Using subtasks has two main advantages:
- ability to accumulate time tracked - ability to accumulate time tracked
@ -91,10 +91,11 @@ when the application is terminated regularly.
- `TASK` - create task - `TASK` - create task
- `.` - clear filters and reload - `.` - clear filters and reload
- `.TASK` - `.TASK`
+ select task by id + activate task by id
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive) + match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
+ no match: create & activate task + no match: create & activate task
- `.2` - set view depth to `2`, which can be substituted for any number (how many subtask levels to show, default 1) - `.2` - set view depth to `2`, which can be substituted for any number (how many subtask levels to show, default 1)
- `/[TEXT]` - like `.`, but never creates a task
Dots can be repeated to move to parent tasks. Dots can be repeated to move to parent tasks.
@ -102,12 +103,13 @@ Dots can be repeated to move to parent tasks.
- `>[TEXT]` - Complete active task and move to parent, with optional state description - `>[TEXT]` - Complete active task and move to parent, with optional state description
- `<[TEXT]` - Close active task and move to parent, with optional state description - `<[TEXT]` - Close active task and move to parent, with optional state description
- `!TEXT` - Set state for current task from text - `!TEXT` - Set state for current task from text
- `-TEXT` - add text note (comment / description) - `,TEXT` - add text note (comment / description)
- `@` - undoes last action (moving in place or upwards confirms pending actions) - `@` - undoes last action (moving in place or upwards or waiting a minute confirms pending actions)
Property Filters: Property Filters:
- `#TAG` - filter by tag - `+TAG` - filter by tag
- `-TAG` - remove filter by tag
- `?STATE` - filter by state (type or description) - plain `?` to reset - `?STATE` - filter by state (type or description) - plain `?` to reset
State descriptions can be used for example for Kanban columns or review flows. State descriptions can be used for example for Kanban columns or review flows.

View file

@ -4,6 +4,7 @@ use std::fmt::Display;
use std::fs; use std::fs;
use std::fs::File; use std::fs::File;
use std::io::{BufRead, BufReader, stdin, stdout, Write}; use std::io::{BufRead, BufReader, stdin, stdout, Write};
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;
@ -32,16 +33,32 @@ struct EventSender {
queue: RefCell<Events>, queue: RefCell<Events>,
} }
impl EventSender { impl EventSender {
fn submit(&self, event_builder: EventBuilder) -> Event { fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
event_builder.to_event(&self.keys) if let Some(event) = self.queue.borrow().first() {
.inspect(|e| self.queue.borrow_mut().push(e.clone())) // Flush if oldest event older than a minute
.unwrap() if event.created_at < Timestamp::now().sub(60u64) {
debug!("Flushing Event Queue because it is older than a minute");
self.flush();
}
}
let mut queue = self.queue.borrow_mut();
Ok(event_builder.to_event(&self.keys).inspect(|event| {
if event.kind.as_u64() == TRACKING_KIND {
queue.retain(|e| {
e.kind.as_u64() != TRACKING_KIND
});
}
queue.push(event.clone());
})?)
} }
fn flush(&self) { fn flush(&self) {
debug!("Flushing {} events from queue", self.queue.borrow().len());
if self.queue.borrow().len() > 0 {
or_print(self.tx.send(self.clear())); or_print(self.tx.send(self.clear()));
} }
}
fn clear(&self) -> Events { fn clear(&self) -> Events {
debug!("Cleared queue {:?}", self.queue.borrow()); trace!("Cleared queue: {:?}", self.queue.borrow());
self.queue.replace(Vec::with_capacity(3)) self.queue.replace(Vec::with_capacity(3))
} }
pub(crate) fn pubkey(&self) -> PublicKey { pub(crate) fn pubkey(&self) -> PublicKey {
@ -287,7 +304,7 @@ async fn main() {
} }
}, },
Some('-') => tasks.add_note(arg), Some(',') => tasks.add_note(arg),
Some('>') => { Some('>') => {
tasks.update_state(arg, State::Done); tasks.update_state(arg, State::Done);
@ -320,10 +337,14 @@ async fn main() {
} }
}, },
Some('#') => { Some('#') | Some('+') => {
tasks.add_tag(arg.to_string()); tasks.add_tag(arg.to_string());
} }
Some('-') => {
tasks.remove_tag(arg.to_string())
}
Some('.') => { Some('.') => {
let mut dots = 1; let mut dots = 1;
let mut pos = tasks.get_position(); let mut pos = tasks.get_position();
@ -339,50 +360,45 @@ async fn main() {
if let Ok(depth) = slice.parse::<i8>() { if let Ok(depth) = slice.parse::<i8>() {
tasks.move_to(pos); tasks.move_to(pos);
tasks.depth = depth; tasks.depth = depth;
} else {
tasks.filter_or_create(slice).map(|id| tasks.move_to(Some(id)));
}
}
Some('/') => {
let mut dots = 1;
let mut pos = tasks.get_position();
for _ in iter.take_while(|c| c == &'/') {
dots += 1;
pos = tasks.get_parent(pos).cloned();
}
let slice = &input[dots..].to_ascii_lowercase();
if slice.is_empty() {
tasks.move_to(pos);
continue; continue;
} }
pos = EventId::parse(slice).ok().or_else(|| { if let Ok(depth) = slice.parse::<i8>() {
// TODO rebuild and use for plaintext too
let mut filtered: Vec<EventId> = tasks
.current_tasks()
.into_iter()
.filter(|t| t.event.content.starts_with(slice))
.map(|t| t.event.id)
.collect();
if filtered.is_empty() {
let lowercase = slice.to_ascii_lowercase();
filtered = tasks
.current_tasks()
.into_iter()
.filter(|t| {
t.event.content.to_ascii_lowercase().starts_with(&lowercase)
})
.map(|t| t.event.id)
.collect();
}
match filtered.len() {
0 => {
// No match, new task
Some(tasks.make_task(slice))
}
1 => {
// One match, activate
Some(filtered.first().unwrap().clone())
}
_ => {
// Multiple match, filter
tasks.set_filter(filtered);
None
}
}
});
if pos != None {
tasks.move_to(pos); tasks.move_to(pos);
tasks.depth = depth;
} else {
let filtered = tasks
.children_of(pos)
.into_iter()
.filter_map(|child| tasks.get_by_id(&child))
.filter(|t| t.event.content.to_ascii_lowercase().starts_with(slice))
.map(|t| t.event.id)
.collect::<Vec<_>>();
if filtered.len() == 1 {
tasks.move_to(filtered.into_iter().nth(0));
} else {
tasks.move_to(pos);
tasks.set_filter(filtered);
}
} }
} }
_ => { _ => {
tasks.make_task(&input); tasks.filter_or_create(&input);
} }
} }
} }

View file

@ -150,12 +150,18 @@ impl Tasks {
State::Closed => None, State::Closed => None,
State::Done => Some(1.0), State::Done => Some(1.0),
_ => { _ => {
let count = t.children.len() as f32; let mut sum = 0f32;
let mut count = 0;
for prog in t.children.iter().filter_map(|e| self.total_progress(e)) {
sum += prog;
count += 1;
}
Some( Some(
t.children if count > 0 {
.iter() sum / (count as f32)
.filter_map(|e| self.total_progress(e).map(|p| p / count)) } else {
.sum(), 0.0
}
) )
} }
}) })
@ -250,6 +256,13 @@ impl Tasks {
self.position.and_then(|id| self.get_by_id(&id)) self.position.and_then(|id| self.get_by_id(&id))
} }
pub(crate) fn children_of(&self, id: Option<EventId>) -> impl IntoIterator<Item=&EventId> + '_ {
self.tasks
.values()
.filter(move |t| t.parent_id() == id.as_ref())
.map(|t| t.get_id())
}
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.current_task().into_iter().collect();
@ -259,13 +272,9 @@ impl Tasks {
// Currently ignores filter when it matches nothing // Currently ignores filter when it matches nothing
return res; return res;
} }
self.resolve_tasks( self.resolve_tasks(self.children_of(self.position)).into_iter()
self.tasks
.values()
.filter(|t| t.parent_id() == self.position.as_ref())
.map(|t| t.get_id()),
).into_iter()
.filter(|t| { .filter(|t| {
// TODO apply filters in transit
let state = t.pure_state(); let state = t.pure_state();
self.state.as_ref().map_or_else(|| { self.state.as_ref().map_or_else(|| {
state == State::Open || ( state == State::Open || (
@ -338,6 +347,7 @@ impl Tasks {
} }
"progress" => self "progress" => self
.total_progress(task.get_id()) .total_progress(task.get_id())
.filter(|_| task.children.len() > 0)
.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0)), .map_or(String::new(), |p| format!("{:2.0}%", p * 100.0)),
"path" => self.get_task_path(Some(task.event.id)), "path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id), "rpath" => self.relative_path(task.event.id),
@ -364,6 +374,11 @@ impl Tasks {
self.tags.insert(Hashtag(tag)); self.tags.insert(Hashtag(tag));
} }
pub(crate) fn remove_tag(&mut self, tag: String) {
self.view.clear();
self.tags.retain(|t| !t.content().is_some_and(|value| value.to_string().starts_with(&tag)));
}
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();
self.state = state; self.state = state;
@ -377,15 +392,62 @@ impl Tasks {
self.sender.flush(); self.sender.flush();
} }
/// Returns ids of tasks matching the filter.
pub(crate) fn get_filtered(&self, arg: &str) -> Vec<EventId> {
if let Ok(id) = EventId::parse(arg) {
return vec![id];
}
let tasks = self.current_tasks();
let mut filtered: Vec<EventId> = Vec::with_capacity(tasks.len());
let lowercase_arg = arg.to_ascii_lowercase();
let mut filtered_more: Vec<EventId> = Vec::with_capacity(tasks.len());
for task in tasks {
let lowercase = task.event.content.to_ascii_lowercase();
if lowercase == lowercase_arg {
return vec![task.event.id]
} else if task.event.content.starts_with(arg) {
filtered.push(task.event.id)
} else if lowercase.starts_with(&lowercase_arg) {
filtered_more.push(task.event.id)
}
}
if filtered.len() == 0 {
return filtered_more
}
return filtered
}
/// Finds out what to do with the given string.
/// Returns an EventId if a new Task was created.
pub(crate) fn filter_or_create(&mut self, arg: &str) -> Option<EventId> {
let filtered = self.get_filtered(arg);
match filtered.len() {
0 => {
// No match, new task
self.view.clear();
Some(self.make_task(arg))
}
1 => {
// One match, activate
self.move_to(filtered.into_iter().nth(0));
None
}
_ => {
// Multiple match, filter
self.set_filter(filtered);
None
}
}
}
pub(crate) fn move_to(&mut self, id: Option<EventId>) { pub(crate) fn move_to(&mut self, id: Option<EventId>) {
self.view.clear(); self.view.clear();
self.tags.clear(); // TODO unsure if this is needed, needs alternative way to clear
if id == self.position { if id == self.position {
debug!("Flushing Tasks because of move in place");
self.flush(); self.flush();
return; return;
} }
self.position = id; self.submit(
self.sender.submit(
EventBuilder::new( EventBuilder::new(
Kind::from(TRACKING_KIND), Kind::from(TRACKING_KIND),
"", "",
@ -393,8 +455,10 @@ impl Tasks {
) )
); );
if !id.and_then(|id| self.tasks.get(&id)).is_some_and(|t| t.parent_id() == self.position.as_ref()) { if !id.and_then(|id| self.tasks.get(&id)).is_some_and(|t| t.parent_id() == self.position.as_ref()) {
debug!("Flushing Tasks because of move");
self.flush(); self.flush();
} }
self.position = id;
} }
// Updates // Updates
@ -437,7 +501,7 @@ impl Tasks {
} }
fn submit(&mut self, builder: EventBuilder) -> EventId { fn submit(&mut self, builder: EventBuilder) -> EventId {
let event = self.sender.submit(builder); let event = self.sender.submit(builder).unwrap();
let id = event.id; let id = event.id;
self.add(event); self.add(event);
id id
@ -474,16 +538,16 @@ impl Tasks {
pub(crate) fn undo(&mut self) { pub(crate) fn undo(&mut self) {
self.sender.clear().into_iter().rev().for_each(|event| { self.sender.clear().into_iter().rev().for_each(|event| {
if let Some(pos) = self.position {
if pos == event.id {
self.move_up()
}
}
self.remove(&event) self.remove(&event)
}); });
} }
fn remove(&mut self, event: &Event) { fn remove(&mut self, event: &Event) {
if let Some(pos) = self.position {
if pos == event.id {
self.move_up()
}
}
self.tasks.remove(&event.id); self.tasks.remove(&event.id);
self.history.get_mut(&self.sender.pubkey()).map(|t| t.remove(event)); self.history.get_mut(&self.sender.pubkey()).map(|t| t.remove(event));
self.referenced_tasks(event, |t| { t.props.remove(event); }); self.referenced_tasks(event, |t| { t.props.remove(event); });
@ -508,7 +572,8 @@ impl Tasks {
match self.position { match self.position {
None => warn!("Cannot add note '{}' without active task", note), None => warn!("Cannot add note '{}' without active task", note),
Some(id) => { Some(id) => {
self.submit(EventBuilder::text_note(note, vec![])); let prop = self.build_prop(Kind::TextNote, note, id);
self.submit(prop);
} }
} }
} }