Compare commits
7 Commits
9ad1243078
...
8bf305d4d4
Author | SHA1 | Date |
---|---|---|
xeruf | 8bf305d4d4 | |
xeruf | eaeeebca7b | |
xeruf | 2255abc1b8 | |
xeruf | 19d0fbb8fc | |
xeruf | 903536bd3b | |
xeruf | 86654c8348 | |
xeruf | d88cae4273 |
42
README.md
42
README.md
|
@ -105,8 +105,9 @@ To stop time-tracking completely, simply move to the root of all tasks.
|
||||||
|
|
||||||
Dot or slash can be repeated to move to parent tasks before acting.
|
Dot or slash can be repeated to move to parent tasks before acting.
|
||||||
|
|
||||||
- `:[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 (
|
||||||
- `::[PROP]` - Sort by property PROP (multiple space-separated values allowed)
|
1-indexed), empty: list properties
|
||||||
|
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
|
||||||
- `([TIME]` - list tracked times or insert timetracking with the specified offset
|
- `([TIME]` - list tracked times or insert timetracking with the specified offset
|
||||||
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
|
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
|
||||||
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
|
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
|
||||||
|
@ -114,8 +115,8 @@ Dot or slash can be repeated to move to parent tasks before acting.
|
||||||
- `<[TEXT]` - close 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: Open)
|
- `!TEXT` - set status for current task from text and move up (empty: Open)
|
||||||
- `,[TEXT]` - list notes or add text note (comment / description)
|
- `,[TEXT]` - list notes or add text note (comment / description)
|
||||||
- TBI: `*[INT]` - set priority - can also be used in task creation, with any digit
|
- `*[INT]` - set priority - can also be used in task creation, with any digit
|
||||||
- TBI: status history and creation with attribution
|
- TBI: show status history and creation with attribution
|
||||||
- `&` - undo last action (moving in place or upwards confirms pending actions)
|
- `&` - 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)
|
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
|
||||||
|
|
||||||
|
@ -126,32 +127,12 @@ Property Filters:
|
||||||
- `-TAG` - remove tag filters by prefix
|
- `-TAG` - remove tag filters by prefix
|
||||||
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
|
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
|
||||||
- `@AUTHOR` - filter by author (`@` for self, id prefix, name prefix)
|
- `@AUTHOR` - filter by author (`@` for self, id prefix, name prefix)
|
||||||
|
- TBI: `**INT` - filter by priority
|
||||||
- TBI: Filter by time
|
- TBI: Filter by time
|
||||||
|
|
||||||
Status descriptions can be used for example for Kanban columns or review flows.
|
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.
|
An active tag or status filter will also set that attribute for newly created tasks.
|
||||||
|
|
||||||
### Available Columns
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `parentid`
|
|
||||||
- `name`
|
|
||||||
- `state` - indicator of current progress
|
|
||||||
- `status` - pure task status
|
|
||||||
- `hashtags` - list of hashtags set for the task
|
|
||||||
- `tags` - values of all nostr tags associated with the event, except event tags
|
|
||||||
- `desc` - last note on the task
|
|
||||||
- `description` - accumulated notes on the task
|
|
||||||
- `path` - name including parent tasks
|
|
||||||
- `rpath` - name including parent tasks up to active task
|
|
||||||
- `time` - time tracked on this task
|
|
||||||
- `rtime` - time tracked on this tasks and all recursive subtasks
|
|
||||||
- `progress` - recursive subtask completion in percent
|
|
||||||
- `subtasks` - how many direct subtasks are complete
|
|
||||||
- TBI `depends`
|
|
||||||
|
|
||||||
For debugging: `props`, `alltags`, `descriptions`
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- TBI = To Be Implemented
|
- TBI = To Be Implemented
|
||||||
|
@ -160,12 +141,13 @@ For debugging: `props`, `alltags`, `descriptions`
|
||||||
## Nostr reference
|
## Nostr reference
|
||||||
|
|
||||||
Mostr mainly uses the following NIPs:
|
Mostr mainly uses the following NIPs:
|
||||||
- Kind 1 for task descriptions
|
|
||||||
|
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
|
||||||
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
|
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
|
||||||
+ Tasks have Kind 1621 (originally: git issue - currently no native markdown support)
|
+ Tasks have Kind 1621 (originally: git issue - currently no markdown support implemented)
|
||||||
+ Kind 1622 may be used for task comments or replace Kind 1 for descriptions
|
+ TBI: Kind 1622 for task comments
|
||||||
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
|
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
|
||||||
- Implementing proprietary Kind 1650 for time-tracking
|
- Own Kind 1650 for time-tracking
|
||||||
|
|
||||||
Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/52.md
|
Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/52.md
|
||||||
- Kind 31922 for GANTT, since it has only Date
|
- Kind 31922 for GANTT, since it has only Date
|
||||||
|
@ -207,6 +189,8 @@ The following features are not ready to be implemented
|
||||||
because they need conceptualization.
|
because they need conceptualization.
|
||||||
Suggestions welcome!
|
Suggestions welcome!
|
||||||
|
|
||||||
|
- Do not track time on Closed task?
|
||||||
|
- Allow adding new parent via description?
|
||||||
- Special commands: help, exit, tutorial, change log level
|
- Special commands: help, exit, tutorial, change log level
|
||||||
- Duplicate task (subtasks? timetracking?)
|
- 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?
|
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
|
||||||
|
|
|
@ -32,7 +32,8 @@ pub fn prompt(prompt: &str) -> Option<String> {
|
||||||
|
|
||||||
// For use in format strings but not possible, so need global find-replace
|
// For use in format strings but not possible, so need global find-replace
|
||||||
pub const MAX_TIMESTAMP_WIDTH: u8 = 15;
|
pub const MAX_TIMESTAMP_WIDTH: u8 = 15;
|
||||||
/// Format nostr Timestamp relative to local time with optional day specifier or full date if needed
|
/// Format nostr Timestamp relative to local time
|
||||||
|
/// with optional day specifier or full date depending on distance to today
|
||||||
pub fn relative_datetimestamp(stamp: &Timestamp) -> String {
|
pub fn relative_datetimestamp(stamp: &Timestamp) -> String {
|
||||||
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
||||||
Single(time) => {
|
Single(time) => {
|
||||||
|
@ -54,11 +55,12 @@ pub fn relative_datetimestamp(stamp: &Timestamp) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a nostr timestamp in a sensible comprehensive format
|
||||||
pub fn local_datetimestamp(stamp: &Timestamp) -> String {
|
pub fn local_datetimestamp(stamp: &Timestamp) -> String {
|
||||||
format_stamp(stamp, "%y-%m-%d %a %H:%M")
|
format_stamp(stamp, "%y-%m-%d %a %H:%M")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a nostr timestamp with the given format
|
||||||
pub fn format_stamp(stamp: &Timestamp, format: &str) -> String {
|
pub fn format_stamp(stamp: &Timestamp, format: &str) -> String {
|
||||||
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
||||||
Single(time) => time.format(format).to_string(),
|
Single(time) => time.format(format).to_string(),
|
||||||
|
|
76
src/kinds.rs
76
src/kinds.rs
|
@ -1,27 +1,47 @@
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use log::info;
|
use log::info;
|
||||||
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard};
|
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard};
|
||||||
|
use nostr_sdk::TagStandard::Hashtag;
|
||||||
|
|
||||||
|
pub const METADATA_KIND: u16 = 0;
|
||||||
|
pub const NOTE_KIND: u16 = 1;
|
||||||
pub const TASK_KIND: u16 = 1621;
|
pub const TASK_KIND: u16 = 1621;
|
||||||
pub const PROCEDURE_KIND: u16 = 1639;
|
pub const PROCEDURE_KIND: u16 = 1639;
|
||||||
pub const TRACKING_KIND: u16 = 1650;
|
pub const TRACKING_KIND: u16 = 1650;
|
||||||
pub const KINDS: [u16; 8] = [1, TASK_KIND, TRACKING_KIND, PROCEDURE_KIND, 1630, 1631, 1632, 1633];
|
pub const KINDS: [u16; 9] = [
|
||||||
|
METADATA_KIND,
|
||||||
|
NOTE_KIND,
|
||||||
|
TASK_KIND,
|
||||||
|
TRACKING_KIND,
|
||||||
|
PROCEDURE_KIND,
|
||||||
|
1630, 1631, 1632, 1633];
|
||||||
|
|
||||||
pub const PROPERTY_COLUMNS: &str = "Available properties:
|
/// Helper for available properties.
|
||||||
- `id`
|
/// TODO: use formatting - bold / heading / italics - and generate from code
|
||||||
- `parentid`
|
pub const PROPERTY_COLUMNS: &str =
|
||||||
- `name`
|
"# Available Properties
|
||||||
- `state`
|
Immutable:
|
||||||
- `hashtags`
|
- `id` - unique task id
|
||||||
|
- `parentid` - unique task id of the parent, if any
|
||||||
|
- `name` - initial name of the task
|
||||||
|
- `created` - task creation timestamp
|
||||||
|
- `author` - name of the task creator
|
||||||
|
Task:
|
||||||
|
- `status` - pure task status
|
||||||
|
- `hashtags` - list of hashtags set for the task
|
||||||
- `tags` - values of all nostr tags associated with the event, except event tags
|
- `tags` - values of all nostr tags associated with the event, except event tags
|
||||||
- `desc` - last note on the task
|
- `desc` - last note on the task
|
||||||
- `description` - accumulated notes on the task
|
- `description` - accumulated notes on the task
|
||||||
- `path` - name including parent tasks
|
|
||||||
- `rpath` - name including parent tasks up to active task
|
|
||||||
- `time` - time tracked on this task by you
|
- `time` - time tracked on this task by you
|
||||||
|
Utilities:
|
||||||
|
- `state` - indicator of current progress
|
||||||
- `rtime` - time tracked on this tasks and its subtree by everyone
|
- `rtime` - time tracked on this tasks and its subtree by everyone
|
||||||
- `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
|
||||||
|
- `path` - name including parent tasks
|
||||||
|
- `rpath` - name including parent tasks up to active task
|
||||||
|
- TBI `depends` - list all tasks this task depends on before it becomes actionable
|
||||||
|
Debugging: `pubkey`, `props`, `alltags`, `descriptions`";
|
||||||
|
|
||||||
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
|
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
|
||||||
where
|
where
|
||||||
|
@ -34,9 +54,39 @@ where
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_task(name: &str, tags: Vec<Tag>) -> EventBuilder {
|
/// Build a task with informational output and optional labeled kind
|
||||||
info!("Created task \"{name}\" with tags [{}]", tags.iter().map(|tag| format_tag(tag)).join(", "));
|
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
||||||
EventBuilder::new(Kind::from(TASK_KIND), name, tags)
|
info!("Created {}task \"{name}\" with tags [{}]",
|
||||||
|
kind.map(|k| k.0).unwrap_or_default(),
|
||||||
|
tags.iter().map(|tag| format_tag(tag)).join(", "));
|
||||||
|
EventBuilder::new(kind.map(|k| k.1).unwrap_or(Kind::from(TASK_KIND)), name, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_prop(
|
||||||
|
kind: Kind,
|
||||||
|
comment: &str,
|
||||||
|
id: EventId,
|
||||||
|
) -> EventBuilder {
|
||||||
|
EventBuilder::new(
|
||||||
|
kind,
|
||||||
|
comment,
|
||||||
|
vec![Tag::event(id)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expects sanitized input
|
||||||
|
pub(crate) fn extract_tags(input: &str) -> (&str, Vec<Tag>) {
|
||||||
|
match input.split_once(": ") {
|
||||||
|
None => (input, vec![]),
|
||||||
|
Some(s) => {
|
||||||
|
let tags = s
|
||||||
|
.1
|
||||||
|
.split_ascii_whitespace()
|
||||||
|
.map(|t| Hashtag(t.to_string()).into())
|
||||||
|
.collect();
|
||||||
|
(s.0, tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_tag(tag: &Tag) -> String {
|
fn format_tag(tag: &Tag) -> String {
|
||||||
|
|
58
src/main.rs
58
src/main.rs
|
@ -180,21 +180,11 @@ async fn main() {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
let sub_id = client.subscribe(vec![Filter::new().kinds(KINDS.into_iter().map(|k| Kind::from(k)))], None).await;
|
let sub_id = client.subscribe(vec![
|
||||||
|
Filter::new().kinds(KINDS.into_iter().map(|k| Kind::from(k)))
|
||||||
|
], None).await;
|
||||||
info!("Subscribed with {:?}", sub_id);
|
info!("Subscribed with {:?}", sub_id);
|
||||||
|
|
||||||
// TODO user data from config file or home relay?
|
|
||||||
//let metadata = Metadata::new()
|
|
||||||
// .name("username")
|
|
||||||
// .display_name("My Username")
|
|
||||||
// .about("Description")
|
|
||||||
// .picture(Url::parse("https://example.com/avatar.png")?)
|
|
||||||
// .banner(Url::parse("https://example.com/banner.png")?)
|
|
||||||
// .nip05("username@example.com")
|
|
||||||
// .lud16("yuki@getalby.com")
|
|
||||||
// .custom_field("custom_field", "my value");
|
|
||||||
//client.set_metadata(&metadata).await?;
|
|
||||||
|
|
||||||
let mut notifications = client.notifications();
|
let mut notifications = client.notifications();
|
||||||
client.connect().await;
|
client.connect().await;
|
||||||
|
|
||||||
|
@ -207,6 +197,19 @@ async fn main() {
|
||||||
let mut queue: Option<(Url, Vec<Event>)> = None;
|
let mut queue: Option<(Url, Vec<Event>)> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
if let Ok(user) = var("USER") {
|
||||||
|
let metadata = Metadata::new()
|
||||||
|
.name(user);
|
||||||
|
// .display_name("My Username")
|
||||||
|
// .about("Description")
|
||||||
|
// .picture(Url::parse("https://example.com/avatar.png")?)
|
||||||
|
// .banner(Url::parse("https://example.com/banner.png")?)
|
||||||
|
// .nip05("username@example.com")
|
||||||
|
// .lud16("yuki@getalby.com")
|
||||||
|
// .custom_field("custom_field", "my value");
|
||||||
|
or_print(client.set_metadata(&metadata).await);
|
||||||
|
}
|
||||||
|
|
||||||
let result_received = rx.recv_timeout(Duration::from_secs(INACTVITY_DELAY));
|
let result_received = rx.recv_timeout(Duration::from_secs(INACTVITY_DELAY));
|
||||||
match result_received {
|
match result_received {
|
||||||
Ok(MostrMessage::NewRelay(url)) => {
|
Ok(MostrMessage::NewRelay(url)) => {
|
||||||
|
@ -366,21 +369,31 @@ async fn main() {
|
||||||
tasks.move_up();
|
tasks.move_up();
|
||||||
}
|
}
|
||||||
|
|
||||||
Some('@') | Some('&') => {
|
Some('&') => {
|
||||||
tasks.undo();
|
tasks.undo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Some('@') => {
|
||||||
|
let author = arg.and_then(|a| PublicKey::from_str(a).ok()).unwrap_or_else(|| keys.public_key());
|
||||||
|
info!("Filtering for events by {author}");
|
||||||
|
tasks.set_filter(
|
||||||
|
tasks.filtered_tasks(tasks.get_position())
|
||||||
|
.filter(|t| t.event.pubkey == author)
|
||||||
|
.map(|t| t.event.id)
|
||||||
|
.collect()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Some('*') => {
|
||||||
|
info!("Setting priority not yet implemented")
|
||||||
|
}
|
||||||
|
|
||||||
Some('|') =>
|
Some('|') =>
|
||||||
match arg {
|
match arg {
|
||||||
None => match tasks.get_position() {
|
None => match tasks.get_position() {
|
||||||
None => {
|
None => {
|
||||||
info!("Filtering for Procedures");
|
tasks.set_state_filter(
|
||||||
tasks.set_filter(
|
StateFilter::State(State::Procedure.to_string()));
|
||||||
tasks.filtered_tasks(None)
|
|
||||||
.filter(|t| t.pure_state() == State::Procedure)
|
|
||||||
.map(|t| t.event.id)
|
|
||||||
.collect()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
tasks.set_state_for(id, "", State::Procedure);
|
tasks.set_state_for(id, "", State::Procedure);
|
||||||
|
@ -392,8 +405,7 @@ async fn main() {
|
||||||
tasks.move_up();
|
tasks.move_up();
|
||||||
tasks.make_task_with(
|
tasks.make_task_with(
|
||||||
arg,
|
arg,
|
||||||
once(tasks.make_event_tag_from_id(pos, MARKER_DEPENDS))
|
once(tasks.make_event_tag_from_id(pos, MARKER_DEPENDS)),
|
||||||
.chain(tasks.parent_tag()),
|
|
||||||
true);
|
true);
|
||||||
break 'arm;
|
break 'arm;
|
||||||
}
|
}
|
||||||
|
|
31
src/task.rs
31
src/task.rs
|
@ -10,8 +10,8 @@ use itertools::Itertools;
|
||||||
use log::{debug, error, info, trace, warn};
|
use log::{debug, error, info, trace, warn};
|
||||||
use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
|
use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
|
||||||
|
|
||||||
use crate::helpers::some_non_empty;
|
use crate::helpers::{local_datetimestamp, some_non_empty};
|
||||||
use crate::kinds::{is_hashtag, PROCEDURE_KIND};
|
use crate::kinds::{is_hashtag, PROCEDURE_KIND, TASK_KIND};
|
||||||
|
|
||||||
pub static MARKER_PARENT: &str = "parent";
|
pub static MARKER_PARENT: &str = "parent";
|
||||||
pub static MARKER_DEPENDS: &str = "depends";
|
pub static MARKER_DEPENDS: &str = "depends";
|
||||||
|
@ -95,6 +95,11 @@ impl Task {
|
||||||
self.description_events().map(|e| &e.content)
|
self.description_events().map(|e| &e.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_task(&self) -> bool {
|
||||||
|
self.event.kind.as_u16() == TASK_KIND ||
|
||||||
|
self.states().next().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
fn states(&self) -> impl Iterator<Item=TaskState> + '_ {
|
fn states(&self) -> impl Iterator<Item=TaskState> + '_ {
|
||||||
self.props.iter().filter_map(|event| {
|
self.props.iter().filter_map(|event| {
|
||||||
event.kind.try_into().ok().map(|s| TaskState {
|
event.kind.try_into().ok().map(|s| TaskState {
|
||||||
|
@ -117,6 +122,13 @@ impl Task {
|
||||||
self.state().unwrap_or_else(|| self.default_state())
|
self.state().unwrap_or_else(|| self.default_state())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns None for a stateless task.
|
||||||
|
pub(crate) fn state_label(&self) -> Option<ColoredString> {
|
||||||
|
self.state()
|
||||||
|
.or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
|
||||||
|
.map(|state| state.get_colored_label())
|
||||||
|
}
|
||||||
|
|
||||||
fn default_state(&self) -> TaskState {
|
fn default_state(&self) -> TaskState {
|
||||||
TaskState {
|
TaskState {
|
||||||
name: None,
|
name: None,
|
||||||
|
@ -139,10 +151,14 @@ impl Task {
|
||||||
|
|
||||||
pub(crate) fn get(&self, property: &str) -> Option<String> {
|
pub(crate) fn get(&self, property: &str) -> Option<String> {
|
||||||
match property {
|
match property {
|
||||||
|
// Static
|
||||||
"id" => Some(self.event.id.to_string()),
|
"id" => Some(self.event.id.to_string()),
|
||||||
"parentid" => self.parent_id().map(|i| i.to_string()),
|
"parentid" => self.parent_id().map(|i| i.to_string()),
|
||||||
"status" => Some(self.state_or_default().get_label()),
|
|
||||||
"name" => Some(self.event.content.clone()),
|
"name" => Some(self.event.content.clone()),
|
||||||
|
"pubkey" => Some(self.event.pubkey.to_string()),
|
||||||
|
"created" => Some(local_datetimestamp(&self.event.created_at)),
|
||||||
|
// Dynamic
|
||||||
|
"status" => self.state_label().map(|c| c.to_string()),
|
||||||
"desc" => self.descriptions().last().cloned(),
|
"desc" => self.descriptions().last().cloned(),
|
||||||
"description" => Some(self.descriptions().join(" ")),
|
"description" => Some(self.descriptions().join(" ")),
|
||||||
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
|
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
|
||||||
|
@ -205,11 +221,16 @@ impl Display for TaskState {
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
|
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
|
||||||
pub(crate) enum State {
|
pub(crate) enum State {
|
||||||
Open,
|
/// Actionable
|
||||||
|
Open = 1630,
|
||||||
|
/// Completed
|
||||||
Done,
|
Done,
|
||||||
|
/// Not Actionable (anymore)
|
||||||
Closed,
|
Closed,
|
||||||
|
/// Temporarily not actionable
|
||||||
Pending,
|
Pending,
|
||||||
Procedure,
|
/// Actionable ordered task list
|
||||||
|
Procedure = PROCEDURE_KIND as isize,
|
||||||
}
|
}
|
||||||
impl From<&str> for State {
|
impl From<&str> for State {
|
||||||
fn from(value: &str) -> Self {
|
fn from(value: &str) -> Self {
|
||||||
|
|
129
src/tasks.rs
129
src/tasks.rs
|
@ -1,7 +1,7 @@
|
||||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
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::{empty, once};
|
||||||
use std::ops::{Div, Rem};
|
use std::ops::{Div, Rem};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::mpsc::Sender;
|
use std::sync::mpsc::Sender;
|
||||||
|
@ -11,7 +11,7 @@ use chrono::Local;
|
||||||
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, UncheckedUrl, Url};
|
use nostr_sdk::{Event, EventBuilder, EventId, JsonUtil, Keys, Kind, Metadata, PublicKey, Tag, TagStandard, Timestamp, UncheckedUrl, Url};
|
||||||
use nostr_sdk::prelude::Marker;
|
use nostr_sdk::prelude::Marker;
|
||||||
use TagStandard::Hashtag;
|
use TagStandard::Hashtag;
|
||||||
|
|
||||||
|
@ -27,26 +27,29 @@ pub(crate) struct Tasks {
|
||||||
tasks: TaskMap,
|
tasks: TaskMap,
|
||||||
/// History of active tasks by PubKey
|
/// History of active tasks by PubKey
|
||||||
history: HashMap<PublicKey, BTreeSet<Event>>,
|
history: HashMap<PublicKey, BTreeSet<Event>>,
|
||||||
|
/// Index of found users with metadata
|
||||||
|
users: HashMap<PublicKey, Metadata>,
|
||||||
|
|
||||||
/// The task properties currently visible
|
/// The task properties currently visible
|
||||||
properties: Vec<String>,
|
properties: Vec<String>,
|
||||||
/// The task properties sorted by
|
/// The task properties sorted by
|
||||||
sorting: VecDeque<String>,
|
sorting: VecDeque<String>,
|
||||||
|
|
||||||
|
/// Currently active task
|
||||||
|
position: Option<EventId>,
|
||||||
|
/// A filtered view of the current tasks
|
||||||
|
view: Vec<EventId>,
|
||||||
/// 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
|
||||||
depth: i8,
|
depth: i8,
|
||||||
|
|
||||||
/// Currently active task
|
|
||||||
position: Option<EventId>,
|
|
||||||
/// Currently active tags
|
/// Currently active tags
|
||||||
tags: BTreeSet<Tag>,
|
tags: BTreeSet<Tag>,
|
||||||
/// Tags filtered out
|
/// Tags filtered out
|
||||||
tags_excluded: BTreeSet<Tag>,
|
tags_excluded: BTreeSet<Tag>,
|
||||||
/// Current active state
|
/// Current active state
|
||||||
state: StateFilter,
|
state: StateFilter,
|
||||||
/// A filtered view of the current tasks
|
|
||||||
view: Vec<EventId>,
|
|
||||||
|
|
||||||
sender: EventSender,
|
sender: EventSender,
|
||||||
}
|
}
|
||||||
|
@ -118,6 +121,7 @@ impl Tasks {
|
||||||
Tasks {
|
Tasks {
|
||||||
tasks: Default::default(),
|
tasks: Default::default(),
|
||||||
history: Default::default(),
|
history: Default::default(),
|
||||||
|
users: Default::default(),
|
||||||
properties: vec![
|
properties: vec![
|
||||||
"state".into(),
|
"state".into(),
|
||||||
"rtime".into(),
|
"rtime".into(),
|
||||||
|
@ -329,12 +333,21 @@ impl Tasks {
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn referenced_tasks<F: Fn(&mut Task)>(&mut self, event: &Event, f: F) {
|
/// Executes the given function with each task referenced by this event without marker.
|
||||||
|
/// Returns true if any task was found.
|
||||||
|
pub(crate) fn referenced_tasks<F: Fn(&mut Task)>(&mut self, event: &Event, f: F) -> bool {
|
||||||
|
let mut found = false;
|
||||||
for tag in event.tags.iter() {
|
for tag in event.tags.iter() {
|
||||||
if let Some(TagStandard::Event { event_id, .. }) = tag.as_standardized() {
|
if let Some(TagStandard::Event { event_id, marker, .. }) = tag.as_standardized() {
|
||||||
self.tasks.get_mut(event_id).map(|t| f(t));
|
if marker.is_none() {
|
||||||
|
self.tasks.get_mut(event_id).map(|t| {
|
||||||
|
found = true;
|
||||||
|
f(t)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
@ -455,14 +468,16 @@ impl Tasks {
|
||||||
if let Some(task) = task.get_dependendees().iter().filter_map(|id| self.get_by_id(id)).find(|t| t.pure_state().is_open()) {
|
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();
|
return format!("Blocked by \"{}\"", task.get_title()).bright_red().to_string();
|
||||||
}
|
}
|
||||||
let state = task.state_or_default();
|
let state = task.pure_state();
|
||||||
if state.state.is_open() && progress.is_some_and(|p| p > 0.1) {
|
if state.is_open() && progress.is_some_and(|p| p > 0.1) {
|
||||||
state.state.colorize(&prog_string)
|
state.colorize(&prog_string)
|
||||||
} else {
|
} else {
|
||||||
state.get_colored_label()
|
task.state_label().unwrap_or_default()
|
||||||
}.to_string()
|
}.to_string()
|
||||||
}
|
}
|
||||||
"progress" => prog_string.clone(),
|
"progress" => prog_string.clone(),
|
||||||
|
|
||||||
|
"author" => self.get_author(&task.event.pubkey),
|
||||||
"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 configurable
|
// TODO format strings configurable
|
||||||
|
@ -472,6 +487,12 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_author(&self, pubkey: &PublicKey) -> String {
|
||||||
|
self.users.get(pubkey)
|
||||||
|
.and_then(|m| m.name.clone())
|
||||||
|
.unwrap_or_else(|| pubkey.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
// 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>) {
|
||||||
|
@ -528,7 +549,7 @@ impl Tasks {
|
||||||
self.sender.flush();
|
self.sender.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns ids of tasks matching the filter.
|
/// Returns ids of tasks starting with the given string.
|
||||||
pub(crate) fn get_filtered(&self, arg: &str) -> Vec<EventId> {
|
pub(crate) fn get_filtered(&self, arg: &str) -> Vec<EventId> {
|
||||||
if let Ok(id) = EventId::parse(arg) {
|
if let Ok(id) = EventId::parse(arg) {
|
||||||
return vec![id];
|
return vec![id];
|
||||||
|
@ -597,24 +618,6 @@ impl Tasks {
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
|
|
||||||
/// Expects sanitized input
|
|
||||||
pub(crate) fn parse_task(&self, input: &str) -> EventBuilder {
|
|
||||||
let mut tags: Vec<Tag> = self.tags.iter().cloned().collect();
|
|
||||||
match input.split_once(": ") {
|
|
||||||
None => build_task(input, tags),
|
|
||||||
Some(s) => {
|
|
||||||
tags.append(
|
|
||||||
&mut s
|
|
||||||
.1
|
|
||||||
.split_ascii_whitespace()
|
|
||||||
.map(|t| Hashtag(t.to_string()).into())
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
build_task(s.0, tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn make_event_tag_from_id(&self, id: EventId, marker: &str) -> Tag {
|
pub(crate) fn make_event_tag_from_id(&self, id: EventId, marker: &str) -> Tag {
|
||||||
Tag::from(TagStandard::Event {
|
Tag::from(TagStandard::Event {
|
||||||
event_id: id,
|
event_id: id,
|
||||||
|
@ -655,20 +658,23 @@ impl Tasks {
|
||||||
/// Creates a task following the current state
|
/// Creates a task following the current state
|
||||||
/// Sanitizes input
|
/// Sanitizes input
|
||||||
pub(crate) fn make_task(&mut self, input: &str) -> EventId {
|
pub(crate) fn make_task(&mut self, input: &str) -> EventId {
|
||||||
self.make_task_with(input, self.position_tags(), true)
|
self.make_task_with(input, empty(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn make_task_and_enter(&mut self, input: &str, state: State) {
|
pub(crate) fn make_task_and_enter(&mut self, input: &str, state: State) {
|
||||||
let id = self.make_task_with(input, self.position_tags(), false);
|
let id = self.make_task_with(input, empty(), false);
|
||||||
self.set_state_for(id, "", state);
|
self.set_state_for(id, "", state);
|
||||||
self.move_to(Some(id));
|
self.move_to(Some(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a task
|
/// Creates a task with tags from filter and position
|
||||||
/// Sanitizes input
|
/// Sanitizes input
|
||||||
pub(crate) fn make_task_with(&mut self, input: &str, tags: impl IntoIterator<Item=Tag>, set_state: bool) -> EventId {
|
pub(crate) fn make_task_with(&mut self, input: &str, tags: impl IntoIterator<Item=Tag>, set_state: bool) -> EventId {
|
||||||
|
let (input, input_tags) = extract_tags(input.trim());
|
||||||
let id = self.submit(
|
let id = self.submit(
|
||||||
self.parse_task(input.trim())
|
build_task(input, input_tags, None)
|
||||||
|
.add_tags(self.tags.iter().cloned())
|
||||||
|
.add_tags(self.position_tags())
|
||||||
.add_tags(tags.into_iter())
|
.add_tags(tags.into_iter())
|
||||||
);
|
);
|
||||||
if set_state {
|
if set_state {
|
||||||
|
@ -677,19 +683,6 @@ impl Tasks {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_prop(
|
|
||||||
&mut self,
|
|
||||||
kind: Kind,
|
|
||||||
comment: &str,
|
|
||||||
id: EventId,
|
|
||||||
) -> EventBuilder {
|
|
||||||
EventBuilder::new(
|
|
||||||
kind,
|
|
||||||
comment,
|
|
||||||
vec![Tag::event(id)],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) 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())
|
self.tasks.get(id).map_or(id.to_string(), |t| t.get_title())
|
||||||
}
|
}
|
||||||
|
@ -701,7 +694,7 @@ impl Tasks {
|
||||||
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
|
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
|
||||||
if let Ok(num) = stripped.parse::<i64>() {
|
if let Ok(num) = stripped.parse::<i64>() {
|
||||||
self.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
|
self.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
match interim::parse_date_string(stripped, Local::now(), interim::Dialect::Us) {
|
match interim::parse_date_string(stripped, Local::now(), interim::Dialect::Us) {
|
||||||
Ok(date) => Some(date.to_utc()),
|
Ok(date) => Some(date.to_utc()),
|
||||||
|
@ -760,7 +753,12 @@ impl Tasks {
|
||||||
Some(c) => { c.insert(event); }
|
Some(c) => { c.insert(event); }
|
||||||
None => { self.history.insert(event.pubkey, BTreeSet::from([event])); }
|
None => { self.history.insert(event.pubkey, BTreeSet::from([event])); }
|
||||||
},
|
},
|
||||||
_ => self.add_prop(&event),
|
METADATA_KIND =>
|
||||||
|
match Metadata::from_json(event.content()) {
|
||||||
|
Ok(metadata) => { self.users.insert(event.pubkey, metadata); }
|
||||||
|
Err(e) => warn!("Cannot parse metadata: {} from {:?}", e, event)
|
||||||
|
}
|
||||||
|
_ => self.add_prop(event),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -777,10 +775,17 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_prop(&mut self, event: &Event) {
|
fn add_prop(&mut self, event: Event) {
|
||||||
self.referenced_tasks(&event, |t| {
|
let found = self.referenced_tasks(&event, |t| {
|
||||||
t.props.insert(event.clone());
|
t.props.insert(event.clone());
|
||||||
});
|
});
|
||||||
|
if !found {
|
||||||
|
if event.kind.as_u16() == NOTE_KIND {
|
||||||
|
self.add_task(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warn!("Unknown event {:?}", event)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_own_history(&mut self) -> Option<&mut BTreeSet<Event>> {
|
fn get_own_history(&mut self) -> Option<&mut BTreeSet<Event>> {
|
||||||
|
@ -812,7 +817,7 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId {
|
pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId {
|
||||||
let prop = self.build_prop(
|
let prop = build_prop(
|
||||||
state.into(),
|
state.into(),
|
||||||
comment,
|
comment,
|
||||||
id,
|
id,
|
||||||
|
@ -827,13 +832,19 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn make_note(&mut self, note: &str) {
|
pub(crate) fn make_note(&mut self, note: &str) {
|
||||||
match self.position {
|
if let Some(id) = self.position {
|
||||||
None => warn!("Cannot add note \"{}\" without active task", note),
|
if self.get_by_id(&id).is_some_and(|t| t.is_task()) {
|
||||||
Some(id) => {
|
let prop = build_prop(Kind::TextNote, note.trim(), id);
|
||||||
let prop = self.build_prop(Kind::TextNote, note, id);
|
|
||||||
self.submit(prop);
|
self.submit(prop);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let (input, tags) = extract_tags(note.trim());
|
||||||
|
self.submit(
|
||||||
|
build_task(input, tags, Some(("stateless ", Kind::TextNote)))
|
||||||
|
.add_tags(self.parent_tag())
|
||||||
|
.add_tags(self.tags.iter().cloned())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Properties
|
// Properties
|
||||||
|
@ -1075,7 +1086,7 @@ mod tasks_test {
|
||||||
fn test_procedures() {
|
fn test_procedures() {
|
||||||
let mut tasks = stub_tasks();
|
let mut tasks = stub_tasks();
|
||||||
tasks.make_task_and_enter("proc: tags", State::Procedure);
|
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)]));
|
let side = tasks.submit(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
|
||||||
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new());
|
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new());
|
||||||
let sub_id = tasks.make_task("sub");
|
let sub_id = tasks.make_task("sub");
|
||||||
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([sub_id]));
|
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([sub_id]));
|
||||||
|
|
Loading…
Reference in New Issue