Compare commits

...

2 Commits

Author SHA1 Message Date
xeruf 7a8a048d6c feat: add procedures for dependency lists 2024-08-08 21:10:17 +03:00
xeruf c492d64d9e style: align command match branches 2024-08-08 18:16:25 +03:00
5 changed files with 120 additions and 47 deletions

View File

@ -94,6 +94,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
+ 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 - `/[TEXT]` - like `.`, but never creates a task
- `|[TASK]` - (un)mark current task as procedure or create and activate a new task procedure (where subtasks automatically depend on the previously created task)
Dots can be repeated to move to parent tasks. Dots can be repeated to move to parent tasks.
@ -132,6 +133,7 @@ An active tag or state filter will also set that attribute for newly created tas
- `rtime` - time tracked on this tasks and all recursive subtasks - `rtime` - time tracked on this tasks and all recursive subtasks
- `progress` - recursive subtask completion in percent - `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete - `subtasks` - how many direct subtasks are complete
- TBI `depends`
For debugging: `props`, `alltags`, `descriptions` For debugging: `props`, `alltags`, `descriptions`
@ -151,6 +153,7 @@ Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/
## Plans ## Plans
- Remove state filter when moving up?
- Task markdown support? - colored - Task markdown support? - colored
- Time tracking: Ability to postpone task and add planned timestamps (calendar entry) - Time tracking: Ability to postpone task and add planned timestamps (calendar entry)
- Parse Hashtag tags from task name - Parse Hashtag tags from task name
@ -168,6 +171,8 @@ The following features are not ready to be implemented
because they need conceptualization. because they need conceptualization.
Suggestions welcome! Suggestions welcome!
- Task Dependencies
- Task Templates
- Task Ownership - Task Ownership
- Combined formatting and recursion specifiers - Combined formatting and recursion specifiers
+ progress count/percentage and recursive or not + progress count/percentage and recursive or not

View File

@ -3,8 +3,9 @@ use log::info;
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard}; use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard};
pub const TASK_KIND: u16 = 1621; pub const TASK_KIND: u16 = 1621;
pub const PROCEDURE_KIND: u16 = 1639;
pub const TRACKING_KIND: u16 = 1650; pub const TRACKING_KIND: u16 = 1650;
pub const KINDS: [u16; 7] = [1, TASK_KIND, TRACKING_KIND, 1630, 1631, 1632, 1633]; pub const KINDS: [u16; 8] = [1, TASK_KIND, TRACKING_KIND, PROCEDURE_KIND, 1630, 1631, 1632, 1633];
pub const PROPERTY_COLUMNS: &str = "Available properties: pub const PROPERTY_COLUMNS: &str = "Available properties:
- `id` - `id`

View File

@ -294,7 +294,7 @@ async fn main() {
continue; continue;
}, },
Some(',') => { Some(',') =>
match arg { match arg {
None => { None => {
tasks.get_current_task().map_or_else( tasks.get_current_task().map_or_else(
@ -305,7 +305,6 @@ async fn main() {
} }
Some(arg) => tasks.make_note(arg), Some(arg) => tasks.make_note(arg),
} }
}
Some('>') => { Some('>') => {
tasks.update_state(&arg_default, State::Done); tasks.update_state(&arg_default, State::Done);
@ -321,18 +320,41 @@ async fn main() {
tasks.undo(); tasks.undo();
} }
Some('|') =>
match arg {
None => match tasks.get_position() {
None => {
tasks.set_filter(
tasks.current_tasks().into_iter()
.filter(|t| t.pure_state() == State::Procedure)
.map(|t| t.event.id)
.collect()
);
}
Some(id) => {
tasks.set_state_for(id, "", State::Procedure);
}
},
Some(arg) => {
let id = tasks.make_task(arg);
tasks.set_state_for(id, "", State::Procedure);
tasks.move_to(Some(id));
}
}
Some('?') => { Some('?') => {
tasks.set_state_filter(arg.map(|s| s.to_string())); tasks.set_state_filter(arg.map(|s| s.to_string()));
} }
Some('!') => match tasks.get_position() { Some('!') =>
None => warn!("First select a task to set its state!"), match tasks.get_position() {
Some(id) => { None => warn!("First select a task to set its state!"),
tasks.set_state_for_with(id, arg_default); Some(id) => {
tasks.set_state_for_with(id, arg_default);
}
} }
},
Some('#') => { Some('#') =>
match arg { match arg {
Some(arg) => tasks.set_tag(arg.to_string()), Some(arg) => tasks.set_tag(arg.to_string()),
None => { None => {
@ -340,37 +362,35 @@ async fn main() {
continue; continue;
} }
} }
}
Some('+') => { Some('+') =>
match arg { match arg {
Some(arg) => tasks.add_tag(arg.to_string()), Some(arg) => tasks.add_tag(arg.to_string()),
None => tasks.clear_filter() None => tasks.clear_filter()
} }
}
Some('-') => { Some('-') =>
match arg { match arg {
Some(arg) => tasks.remove_tag(arg), Some(arg) => tasks.remove_tag(arg),
None => tasks.clear_filter() None => tasks.clear_filter()
} }
}
Some('*') => match arg { Some('*') =>
Some(arg) => { match arg {
if let Ok(num) = arg.parse::<i64>() { Some(arg) => {
tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num))); if let Ok(num) = arg.parse::<i64>() {
} else if let Ok(date) = DateTime::parse_from_rfc3339(arg) { tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num)));
tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64)); } else if let Ok(date) = DateTime::parse_from_rfc3339(arg) {
} else { tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64));
warn!("Cannot parse {arg}"); } else {
warn!("Cannot parse {arg}");
}
}
None => {
println!("{}", tasks.times_tracked());
continue;
} }
} }
None => {
println!("{}", tasks.times_tracked());
continue
}
}
Some('.') => { Some('.') => {
let mut dots = 1; let mut dots = 1;
@ -422,7 +442,7 @@ async fn main() {
} }
} }
_ => { _ =>
if Regex::new("^wss?://").unwrap().is_match(&input) { if Regex::new("^wss?://").unwrap().is_match(&input) {
tasks.move_to(None); tasks.move_to(None);
let mut new_relay = relays.keys().find(|key| key.as_str().starts_with(&input)).cloned(); let mut new_relay = relays.keys().find(|key| key.as_str().starts_with(&input)).cloned();
@ -445,7 +465,6 @@ async fn main() {
} else { } else {
tasks.filter_or_create(&input); tasks.filter_or_create(&input);
} }
}
} }
or_print(tasks.print_tasks()); or_print(tasks.print_tasks());
} }

View File

@ -1,4 +1,5 @@
use fmt::Display; use fmt::Display;
use std::cmp::Ordering;
use std::collections::{BTreeSet, HashSet}; use std::collections::{BTreeSet, HashSet};
use std::fmt; use std::fmt;
@ -8,29 +9,47 @@ use log::{debug, error, info, trace, warn};
use nostr_sdk::{Event, EventBuilder, EventId, Kind, Tag, TagStandard, Timestamp}; use nostr_sdk::{Event, EventBuilder, EventId, Kind, Tag, TagStandard, Timestamp};
use crate::helpers::some_non_empty; use crate::helpers::some_non_empty;
use crate::kinds::is_hashtag; use crate::kinds::{is_hashtag, PROCEDURE_KIND};
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Task { pub(crate) struct Task {
/// Event that defines this task
pub(crate) event: Event, pub(crate) event: Event,
pub(crate) children: HashSet<EventId>, /// Cached sorted tags of the event with references remove - do not modify!
pub(crate) props: BTreeSet<Event>,
/// Cached sorted tags of the event
pub(crate) tags: Option<BTreeSet<Tag>>, pub(crate) tags: Option<BTreeSet<Tag>>,
/// Parent task references derived from the event tags
parents: Vec<EventId>, parents: Vec<EventId>,
/// Reference to children, populated dynamically
pub(crate) children: HashSet<EventId>,
/// Events belonging to this task, such as state updates and notes
pub(crate) props: BTreeSet<Event>,
}
impl PartialOrd<Self> for Task {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.event.partial_cmp(&other.event)
}
}
impl Ord for Task {
fn cmp(&self, other: &Self) -> Ordering {
self.event.cmp(&other.event)
}
} }
impl Task { impl Task {
pub(crate) fn new(event: Event) -> Task { pub(crate) fn new(event: Event) -> Task {
let (parents, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() { let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
Some(TagStandard::Event { event_id, .. }) => return Left(event_id), Some(TagStandard::Event { event_id, .. }) => return Left(event_id),
_ => Right(tag.clone()), _ => Right(tag.clone()),
}); });
// Separate refs for dependencies
Task { Task {
children: Default::default(), children: Default::default(),
props: Default::default(), props: Default::default(),
tags: Some(tags).filter(|t: &BTreeSet<Tag>| !t.is_empty()), tags: Some(tags).filter(|t: &BTreeSet<Tag>| !t.is_empty()),
parents, parents: refs,
event, event,
} }
} }
@ -117,6 +136,7 @@ impl Task {
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }), "hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
"tags" => self.filter_tags(|_| true), "tags" => self.filter_tags(|_| true),
"alltags" => Some(format!("{:?}", self.tags)), "alltags" => Some(format!("{:?}", self.tags)),
"parents" => Some(format!("{:?}", self.parents.iter().map(|id| id.to_string()).collect_vec())),
"props" => Some(format!( "props" => Some(format!(
"{:?}", "{:?}",
self.props self.props
@ -172,18 +192,20 @@ impl Display for TaskState {
pub(crate) enum State { pub(crate) enum State {
Closed, Closed,
Open, Open,
Active, Procedure,
Pending,
Done, Done,
} }
impl TryFrom<Kind> for State { impl TryFrom<Kind> for State {
type Error = (); type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> { fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value.as_u32() { match value.as_u16() {
1630 => Ok(State::Open), 1630 => Ok(State::Open),
1631 => Ok(State::Done), 1631 => Ok(State::Done),
1632 => Ok(State::Closed), 1632 => Ok(State::Closed),
1633 => Ok(State::Active), 1633 => Ok(State::Pending),
PROCEDURE_KIND => Ok(State::Procedure),
_ => Err(()), _ => Err(()),
} }
} }
@ -191,7 +213,7 @@ impl TryFrom<Kind> for State {
impl State { impl State {
pub(crate) fn is_open(&self) -> bool { pub(crate) fn is_open(&self) -> bool {
match self { match self {
State::Open | State::Active => true, State::Open | State::Procedure => true,
_ => false, _ => false,
} }
} }
@ -201,7 +223,8 @@ impl State {
State::Open => 1630, State::Open => 1630,
State::Done => 1631, State::Done => 1631,
State::Closed => 1632, State::Closed => 1632,
State::Active => 1633, State::Pending => 1633,
State::Procedure => PROCEDURE_KIND,
} }
} }
} }

View File

@ -11,8 +11,9 @@ use chrono::LocalResult::Single;
use colored::Colorize; use colored::Colorize;
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, TagStandard, Timestamp, Url}; use nostr_sdk::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, TagStandard, Timestamp, UncheckedUrl, Url};
use nostr_sdk::base64::write::StrConsumer; use nostr_sdk::base64::write::StrConsumer;
use nostr_sdk::prelude::Marker;
use TagStandard::Hashtag; use TagStandard::Hashtag;
use crate::{Events, EventSender}; use crate::{Events, EventSender};
@ -306,7 +307,7 @@ impl Tasks {
// TODO apply filters in transit // 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.is_open() || (
state == State::Done && state == State::Done &&
t.parent_id() != None t.parent_id() != None
) )
@ -382,13 +383,13 @@ impl Tasks {
.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),
// TODO format strings as config // TODO format strings configurable
"time" => display_time("MMMm", self.time_tracked(*task.get_id())), "time" => display_time("MMMm", self.time_tracked(*task.get_id())),
"rtime" => { "rtime" => {
let time = self.total_time_tracked(*task.get_id()); let time = self.total_time_tracked(*task.get_id());
total_time += time; total_time += time;
display_time("HH:MM", time) display_time("HH:MM", time)
}, }
prop => task.get(prop).unwrap_or(String::new()), prop => task.get(prop).unwrap_or(String::new()),
}) })
.collect::<Vec<String>>() .collect::<Vec<String>>()
@ -540,7 +541,31 @@ impl Tasks {
/// Sanitizes input /// Sanitizes input
pub(crate) fn make_task(&mut self, input: &str) -> EventId { pub(crate) fn make_task(&mut self, input: &str) -> EventId {
let id = self.submit(self.parse_task(input.trim())); let tag: Option<Tag> = self.get_current_task()
.and_then(|t| {
println!("{:?}", t);
if t.pure_state() == State::Procedure {
t.children.iter()
.filter_map(|id| self.get_by_id(id))
.max()
.map(|t| {
Tag::from(
TagStandard::Event {
event_id: t.event.id,
relay_url: self.sender.url.as_ref().map(|url| UncheckedUrl::new(url.as_str())),
marker: Some(Marker::Custom("depends".to_string())),
public_key: Some(t.event.pubkey),
}
)
})
} else {
None
}
});
let id = self.submit(
self.parse_task(input.trim())
.add_tags(tag.into_iter())
);
self.state.clone().inspect(|s| self.set_state_for_with(id, s)); self.state.clone().inspect(|s| self.set_state_for_with(id, s));
id id
} }
@ -604,7 +629,7 @@ impl Tasks {
t.children.insert(event.id); t.children.insert(event.id);
}); });
if self.tasks.contains_key(&event.id) { if self.tasks.contains_key(&event.id) {
warn!("Did not insert duplicate event {}", event.id); debug!("Did not insert duplicate event {}", event.id); // TODO warn in next sdk version
} else { } else {
self.tasks.insert(event.id, Task::new(event)); self.tasks.insert(event.id, Task::new(event));
} }