Compare commits
9 commits
0ee4daea1b
...
bf802e3195
Author | SHA1 | Date | |
---|---|---|---|
|
bf802e3195 | ||
|
03f9e60c6f | ||
|
5b05c53947 | ||
|
7f34a888f3 | ||
|
256c86e06f | ||
|
a9509fd4f2 | ||
|
36fe58d3f3 | ||
|
14dcc8f0ff | ||
|
486cbb1ab4 |
3 changed files with 156 additions and 73 deletions
14
README.md
14
README.md
|
@ -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.
|
||||||
|
|
104
src/main.rs
104
src/main.rs
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
107
src/tasks.rs
107
src/tasks.rs
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue