Compare commits

...

7 Commits

4 changed files with 153 additions and 91 deletions

View File

@ -99,7 +99,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
+ 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)
- `/[TEXT]` - like `.`, but never creates a task
- `/[TEXT]` - like `.`, but never creates a task and filters beyond currently visible tasks
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - (un)mark current task as procedure or create a sibling task depending on the current one and move up
@ -109,12 +109,13 @@ Dots and slashes can be repeated to move to parent tasks.
- `::[PROP]` - Sort by property PROP (multiple space-separated values allowed)
- `([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)
- `>[TEXT]` - complete active task and move up, with optional state description
- `<[TEXT]` - close active task and move up, with optional state description
- `!TEXT` - set state for current task from text and move up
- `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set status for current task from text and move up (empty to open)
- `,TEXT` - add text note (comment / description)
- TBI: `*[INT]` - set priority - can also be used in task, with any digit
- `@` - undoes last action (moving in place or upwards or waiting a minute confirms pending actions)
- TBI: `*[INT]` - set priority - can also be used in task creation, with any digit
- TBI: status history and creation with attribution
- `&` - undo last action (moving in place or upwards confirms pending actions)
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters:
@ -123,6 +124,8 @@ Property Filters:
- `+TAG` - add tag filter
- `-TAG` - remove tag filters
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
- `@AUTHOR` - filter by author (`@` for self, id prefix, name prefix)
- TBI: Filter by time
Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks.
@ -148,6 +151,11 @@ An active tag or status filter will also set that attribute for newly created ta
For debugging: `props`, `alltags`, `descriptions`
### Notes
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one
## Nostr reference
Mostr mainly uses the following NIPs:
@ -164,17 +172,26 @@ Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/
## Plans
- Remove state filter when moving up?
- Local Database Cache, Negentropy Reconciliation
-> Offline Use!
- Scheduling
- Remove status filter when moving up?
- Task markdown support? - colored
- Time tracking: Ability to postpone task and add planned timestamps (calendar entry)
- Parse Hashtag tags from task name
- Unified Filter object
-> include subtasks of matched tasks
- Relay Switching
- Speedup: Offline caching & Expiry (no need to fetch potential years of history)
+ Fetch most recent tasks first
+ Relay: compress tracked time for old tasks, filter closed tasks
+ Relay: filter out task state updates within few seconds, also on client side
+ Relay: filter out task status updates within few seconds, also on client side
### Command
- Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
- reassign undo to `&` and use `@` for people
- maybe use `;` for sorting instead of `::`
### Conceptual
@ -182,9 +199,10 @@ The following features are not ready to be implemented
because they need conceptualization.
Suggestions welcome!
- Special commands: help, exit, tutorial, change log level
- Duplicate task (subtasks? timetracking?)
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
- Priorities
- Dependencies (change from tags to properties so they can be added later? or maybe as a state?)
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Templates
- Ownership
- Combined formatting and recursion specifiers
@ -194,11 +212,45 @@ Suggestions welcome!
### Interfaces
- TUI: Clear terminal? Refresh on empty prompt after timeout?
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar
- Web Interface, Messenger integrations
## Notes
## Exemplary Workflows
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one
- Freelancer
- Family Chore management
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
+ Permissions via status or assignment (reassignment?)
+ Tasks can be blocked while having a status (e.g. kanban column)
+ A meeting can be worked on (tracked) before it starts
+ Schedule for multiple people
- Tracking Daily Routines / Habits
### Contexts
A context is a custom set of filters such as status, tags, assignee
so that the visible tasks are always relevant
and newly created tasks are less of a hassle to type out
since they will automatically take on that context.
By automating these contexts based on triggers, scripts or time,
relevant tasks can be surfaced automatically.
#### Example
In the morning, your groggy brain is good at divergent thinking,
and you like to do sports in the morning.
So for that time, mostr can show you tasks tagged for divergent thinking,
since you are easily distracted filter out those that require the internet,
as well as anything sportsy.
After you come back from sports and had breakfast,
for example detected through a period of inactivity on your device,
you are ready for work, so the different work projects are shown and you delve into one.
After 90 minutes you reach a natural low in your focus,
so mostr surfaces break activities -
such as a short walk, a small workout, some instrument practice
or simply grabbing a snack and drink.
After lunch you like to take an extended afternoon break,
so your call list pops up -
you can give a few people a call as you make a market run,
before going for siesta.

View File

@ -7,6 +7,7 @@ pub fn some_non_empty(str: &str) -> Option<String> {
if str.is_empty() { None } else { Some(str.to_string()) }
}
// TODO as macro so that log comes from appropriate module
pub fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> {
match result {
Ok(value) => Some(value),

View File

@ -13,7 +13,7 @@ use std::sync::mpsc::RecvTimeoutError;
use std::sync::mpsc::Sender;
use std::time::Duration;
use colored::Colorize;
use colored::{ColoredString, Colorize};
use env_logger::Builder;
use itertools::Itertools;
use log::{debug, error, info, LevelFilter, trace, warn};
@ -23,7 +23,6 @@ use xdg::BaseDirectories;
use crate::helpers::*;
use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND};
use crate::MostrMessage::AddTasks;
use crate::task::{MARKER_DEPENDS, MARKER_PARENT, State};
use crate::tasks::{PropertyCollection, StateFilter, Tasks};
@ -80,7 +79,9 @@ impl EventSender {
debug!("Flushing {} events from queue", self.queue.borrow().len());
let values = self.clear();
self.url.as_ref().map(|url| {
or_print(self.tx.send(AddTasks(url.clone(), values)));
self.tx.send(MostrMessage::AddTasks(url.clone(), values)).inspect_err(|e| {
error!("Nostr communication thread failure, changes will not be persisted: {}", e)
})
});
}
/// Sends all pending events if there is a non-tracking event
@ -149,6 +150,7 @@ async fn main() {
let client = Client::new(&keys);
info!("My public key: {}", keys.public_key());
// TODO use NewRelay message for all relays
match var("MOSTR_RELAY") {
Ok(relay) => {
or_print(client.add_relay(relay).await);
@ -237,19 +239,26 @@ async fn main() {
let mut queue: Option<(Url, Vec<Event>)> = None;
loop {
// TODO invalid acknowledgement from bucket relay slows sending down
match rx.recv_timeout(Duration::from_secs(INACTVITY_DELAY)) {
Ok(AddTasks(url, mut events)) => {
if 1 == 2 {
client.connect_relay("").await;
let result = rx.recv_timeout(Duration::from_secs(INACTVITY_DELAY));
match result {
Ok(MostrMessage::NewRelay(url)) => {
if client.add_relay(&url).await.unwrap() {
match client.connect_relay(&url).await {
Ok(()) => info!("Connected to {url}"),
Err(e) => warn!("Unable to connect to relay {url}: {e}")
}
} else {
warn!("Relay {url} already added");
}
debug!("Queueing {:?}", &events);
}
Ok(MostrMessage::AddTasks(url, mut events)) => {
trace!("Queueing {:?}", &events);
if let Some((queue_url, mut queue_events)) = queue {
if queue_url == url {
queue_events.append(&mut events);
queue = Some((queue_url, queue_events));
} else {
info!("Sending {} events due to relay change", queue_events.len());
info!("Sending {} events to {url} due to relay change", queue_events.len());
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
queue = None;
}
@ -259,19 +268,19 @@ async fn main() {
queue = Some((url, events))
}
}
Err(RecvTimeoutError::Timeout) => if let Some((url, events)) = queue {
info!("Sending {} events due to inactivity", events.len());
Ok(MostrMessage::Flush) | Err(RecvTimeoutError::Timeout) => if let Some((url, events)) = queue {
info!("Sending {} events to {url} due to {:?}", events.len(), result);
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
queue = None;
}
arg => {
debug!("Finalizing nostr communication thread because of {:?}", arg);
break
Err(err) => {
debug!("Finalizing nostr communication thread because of {:?}", err);
break;
}
}
}
if let Some((url, events)) = queue {
info!("Sending {} events before exiting", events.len());
info!("Sending {} events to {url} before exiting", events.len());
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
}
info!("Shutting down nostr communication thread");
@ -289,18 +298,19 @@ async fn main() {
let mut lines = stdin().lines();
loop {
trace!("All Root Tasks:\n{}", relays.iter().map(|(url, tasks)|
format!("{}: [{}]", url, tasks.children_of(None).map(|id| tasks.get_task_title(id)).join("; "))).join("\n"));
println!();
selected_relay.as_ref().and_then(|url| relays.get(url)).inspect(|tasks| {
print!(
"{}",
format!(
"{} {}{}) ",
selected_relay.as_ref().map_or("local".to_string(), |url| url.to_string()),
tasks.get_task_path(tasks.get_position()),
tasks.get_prompt_suffix()
).italic()
);
});
let tasks = selected_relay.as_ref().and_then(|url| relays.get(url)).unwrap_or(&local_tasks);
print!(
"{} {}) ",
selected_relay.as_ref().map_or("TEMP".to_string(), |url| url.to_string()).bright_black().italic(),
format!(
"{}{}",
tasks.get_task_path(tasks.get_position()),
tasks.get_prompt_suffix()
).bold()
);
stdout().flush().unwrap();
match lines.next() {
Some(Ok(input)) => {
@ -312,10 +322,13 @@ async fn main() {
..
} = notification
{
print_event(&event);
debug!(
"At {} found {} kind {} content \"{}\" tags {:?}",
event.created_at, event.id, event.kind, event.content, event.tags.iter().map(|tag| tag.as_vec()).collect_vec()
);
match relays.get_mut(&relay_url) {
Some(tasks) => tasks.add(*event),
None => warn!("Event received from unknown relay {relay_url}: {:?}", event)
None => warn!("Event received from unknown relay {relay_url}: {:?}", *event)
}
count += 1;
}
@ -387,7 +400,7 @@ async fn main() {
tasks.move_up();
}
Some('@') => {
Some('@') | Some('&') => {
tasks.undo();
}
@ -462,18 +475,15 @@ async fn main() {
None => tasks.clear_filter()
}
Some('(') =>
match arg {
Some(arg) =>
if !tasks.track_from(arg) {
continue;
}
None => {
println!("{}", tasks.times_tracked());
Some('(') => {
if let Some(arg) = arg {
if !tasks.track_from(arg) {
continue;
}
}
println!("{}", tasks.times_tracked());
continue;
}
Some(')') => {
tasks.move_to(None);
@ -541,22 +551,22 @@ async fn main() {
_ =>
if Regex::new("^wss?://").unwrap().is_match(&input.trim()) {
tasks.move_to(None);
let mut new_relay = relays.keys().find(|key| key.as_str().starts_with(&input)).cloned();
if new_relay.is_none() {
if let Some(url) = or_print(Url::parse(&input)) {
warn!("Connecting to {url} while running not yet supported");
//new_relay = Some(url.clone());
//relays.insert(url.clone(), tasks_for_url(Some(url.clone())));
//if client.add_relay(url).await.unwrap() {
// relays.insert(url.clone(), tasks_for_url(Some(url.clone())));
// client.connect().await;
//}
if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_str().starts_with(&input)) {
selected_relay = Some(url.clone());
or_print(tasks.print_tasks());
continue;
}
match Url::parse(&input) {
Err(e) => warn!("Failed to parse url \"{input}\": {}", e),
Ok(url) => match tx.send(MostrMessage::NewRelay(url.clone())) {
Err(e) => error!("Nostr communication thread failure, cannot add relay \"{url}\": {e}"),
Ok(_) => {
info!("Connecting to {url}");
selected_relay = Some(url.clone());
relays.insert(url.clone(), tasks_for_url(Some(url)));
}
}
}
if new_relay.is_some() {
selected_relay = new_relay;
}
//or_print(tasks.print_tasks());
continue;
} else {
tasks.filter_or_create(&input);
@ -577,10 +587,3 @@ async fn main() {
info!("Submitting pending updates...");
or_print(sender.await);
}
fn print_event(event: &Event) {
debug!(
"At {} found {} kind {} \"{}\" {:?}",
event.created_at, event.id, event.kind, event.content, event.tags
);
}

View File

@ -28,6 +28,7 @@ pub(crate) struct Tasks {
tasks: TaskMap,
/// History of active tasks by PubKey
history: HashMap<PublicKey, BTreeSet<Event>>,
/// The task properties currently visible
properties: Vec<String>,
/// The task properties sorted by
@ -336,7 +337,7 @@ impl Tasks {
self.position.and_then(|id| self.get_by_id(&id))
}
pub(crate) fn children_of(&self, id: Option<EventId>) -> impl IntoIterator<Item=&EventId> + '_ {
pub(crate) fn children_of(&self, id: Option<EventId>) -> impl Iterator<Item=&EventId> + '_ {
self.tasks
.values()
.filter(move |t| t.parent_id() == id.as_ref())
@ -349,9 +350,10 @@ impl Tasks {
}
let res: Vec<&Task> = self.resolve_tasks(self.view.iter());
if res.len() > 0 {
// Currently ignores filter when it matches nothing
// Currently ignores filtered view when it matches nothing
return res;
}
// TODO use ChildrenIterator
self.resolve_tasks(self.children_of(self.position)).into_iter()
.filter(|t| {
// TODO apply filters in transit
@ -686,7 +688,7 @@ impl Tasks {
)
}
fn get_task_title(&self, id: &EventId) -> String {
pub(crate) fn get_task_title(&self, id: &EventId) -> String {
self.tasks.get(id).map_or(id.to_string(), |t| t.get_title())
}
@ -698,14 +700,14 @@ impl Tasks {
} else if let Ok(date) = DateTime::parse_from_rfc3339(str) {
self.track_at(Timestamp::from(date.to_utc().timestamp() as u64));
} else {
warn!("Cannot parse {str}");
warn!("Cannot parse time from {str}");
return false;
}
true
}
pub(crate) fn track_at(&mut self, time: Timestamp) -> EventId {
info!("{} from {}", self.position.map_or(String::from("Stopping time-tracking"), |id| format!("Tracking \"{}\"", self.get_task_title(&id))), time.to_human_datetime()); // TODO omit seconds
info!("{} from {}", self.position.map_or(String::from("Stopping time-tracking"), |id| format!("Tracking \"{}\"", self.get_task_title(&id))), time.to_human_datetime());
let pos = self.get_position();
let tracking = build_tracking(pos);
// TODO this can lead to funny deletions
@ -723,6 +725,7 @@ impl Tasks {
self.submit(tracking.custom_created_at(time))
}
/// Sign and queue the event to the relay, returning its id
fn submit(&mut self, builder: EventBuilder) -> EventId {
let event = self.sender.submit(builder).unwrap();
let id = event.id;
@ -843,9 +846,11 @@ pub trait PropertyCollection<T> {
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) {
impl<T> PropertyCollection<T> for Vec<T>
where
T: Display + Eq + Clone,
{
fn remove_at(&mut self, index: usize) {
let col = self.remove(index);
info!("Removed property column \"{col}\"");
}
@ -1052,20 +1057,20 @@ mod tasks_test {
tasks.make_task_and_enter("proc: tags", State::Procedure);
let side = tasks.submit(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)]));
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new());
let subid = tasks.make_task("sub");
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([subid]));
let sub = tasks.get_by_id(&subid).unwrap();
let sub_id = tasks.make_task("sub");
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([sub_id]));
assert_eq!(tasks.len(), 3);
let sub = tasks.get_by_id(&sub_id).unwrap();
assert_eq!(sub.get_dependendees(), Vec::<&EventId>::new());
}
#[test]
fn test_tracking() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
//let task = tasks.make_task("task");
tasks.track_at(Timestamp::from(0));
assert_eq!(tasks.history.len(), 1);
let zero = EventId::all_zeros();
tasks.move_to(Some(zero));
let now: Timestamp = Timestamp::now() - 2u64;
@ -1085,10 +1090,11 @@ mod tasks_test {
fn test_timestamps() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
tasks.move_to(Some(zero));
tasks.track_at(Timestamp::from(Timestamp::now().as_u64() + 100));
assert_eq!(timestamps(tasks.history.values().nth(0).unwrap().into_iter(), &vec![&zero]).collect_vec().len(), 2)
// TODO Does not show both future and current tracking properly, need to split by now
// TODO Does not show both future and current tracking properly, need to split by current time
}