Compare commits

...

7 Commits

Author SHA1 Message Date
xeruf 8bf305d4d4 fix: only show default state for proper tasks 2024-08-18 22:43:14 +03:00
xeruf eaeeebca7b feat: add notes as stateless tasks 2024-08-18 22:37:02 +03:00
xeruf 2255abc1b8 docs: unify property columns documentation 2024-08-18 21:54:48 +03:00
xeruf 19d0fbb8fc feat(task): expose remaining relevant event properties 2024-08-18 21:38:20 +03:00
xeruf 903536bd3b docs: some helpful comments 2024-08-18 21:37:39 +03:00
xeruf 86654c8348 feat: show named task authors 2024-08-18 21:33:32 +03:00
xeruf d88cae4273 feat(main): enable filtering by author 2024-08-16 21:58:38 +03:00
6 changed files with 211 additions and 131 deletions

View File

@ -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.
- `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (1-indexed)
- `::[PROP]` - Sort by property PROP (multiple space-separated values allowed)
- `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (
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
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
- `)[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` - set status for current task from text and move up (empty: Open)
- `,[TEXT]` - list notes or add text note (comment / description)
- TBI: `*[INT]` - set priority - can also be used in task creation, with any digit
- TBI: status history and creation with attribution
- `*[INT]` - set priority - can also be used in task creation, with any digit
- TBI: show 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)
@ -126,32 +127,12 @@ Property Filters:
- `-TAG` - remove tag filters by prefix
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
- `@AUTHOR` - filter by author (`@` for self, id prefix, name prefix)
- TBI: `**INT` - filter by priority
- 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.
### 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
- TBI = To Be Implemented
@ -160,12 +141,13 @@ For debugging: `props`, `alltags`, `descriptions`
## Nostr reference
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
+ Tasks have Kind 1621 (originally: git issue - currently no native markdown support)
+ Kind 1622 may be used for task comments or replace Kind 1 for descriptions
+ Tasks have Kind 1621 (originally: git issue - currently no markdown support implemented)
+ TBI: Kind 1622 for task comments
+ 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
- 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.
Suggestions welcome!
- Do not track time on Closed task?
- Allow adding new parent via description?
- 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?

View File

@ -32,7 +32,8 @@ pub fn prompt(prompt: &str) -> Option<String> {
// For use in format strings but not possible, so need global find-replace
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 {
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
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 {
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 {
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
Single(time) => time.format(format).to_string(),

View File

@ -1,27 +1,47 @@
use itertools::Itertools;
use log::info;
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 PROCEDURE_KIND: u16 = 1639;
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:
- `id`
- `parentid`
- `name`
- `state`
- `hashtags`
/// Helper for available properties.
/// TODO: use formatting - bold / heading / italics - and generate from code
pub const PROPERTY_COLUMNS: &str =
"# Available Properties
Immutable:
- `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
- `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 by you
Utilities:
- `state` - indicator of current progress
- `rtime` - time tracked on this tasks and its subtree by everyone
- `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
where
@ -34,9 +54,39 @@ where
)
}
pub(crate) fn build_task(name: &str, tags: Vec<Tag>) -> EventBuilder {
info!("Created task \"{name}\" with tags [{}]", tags.iter().map(|tag| format_tag(tag)).join(", "));
EventBuilder::new(Kind::from(TASK_KIND), name, tags)
/// Build a task with informational output and optional labeled kind
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
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 {

View File

@ -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);
// 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();
client.connect().await;
@ -207,6 +197,19 @@ async fn main() {
let mut queue: Option<(Url, Vec<Event>)> = None;
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));
match result_received {
Ok(MostrMessage::NewRelay(url)) => {
@ -366,21 +369,31 @@ async fn main() {
tasks.move_up();
}
Some('@') | Some('&') => {
Some('&') => {
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('|') =>
match arg {
None => match tasks.get_position() {
None => {
info!("Filtering for Procedures");
tasks.set_filter(
tasks.filtered_tasks(None)
.filter(|t| t.pure_state() == State::Procedure)
.map(|t| t.event.id)
.collect()
);
tasks.set_state_filter(
StateFilter::State(State::Procedure.to_string()));
}
Some(id) => {
tasks.set_state_for(id, "", State::Procedure);
@ -392,8 +405,7 @@ async fn main() {
tasks.move_up();
tasks.make_task_with(
arg,
once(tasks.make_event_tag_from_id(pos, MARKER_DEPENDS))
.chain(tasks.parent_tag()),
once(tasks.make_event_tag_from_id(pos, MARKER_DEPENDS)),
true);
break 'arm;
}

View File

@ -10,8 +10,8 @@ use itertools::Itertools;
use log::{debug, error, info, trace, warn};
use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
use crate::helpers::some_non_empty;
use crate::kinds::{is_hashtag, PROCEDURE_KIND};
use crate::helpers::{local_datetimestamp, some_non_empty};
use crate::kinds::{is_hashtag, PROCEDURE_KIND, TASK_KIND};
pub static MARKER_PARENT: &str = "parent";
pub static MARKER_DEPENDS: &str = "depends";
@ -95,6 +95,11 @@ impl Task {
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> + '_ {
self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| TaskState {
@ -117,6 +122,13 @@ impl Task {
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 {
TaskState {
name: None,
@ -139,10 +151,14 @@ impl Task {
pub(crate) fn get(&self, property: &str) -> Option<String> {
match property {
// Static
"id" => Some(self.event.id.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()),
"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(),
"description" => Some(self.descriptions().join(" ")),
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
@ -205,11 +221,16 @@ impl Display for TaskState {
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
pub(crate) enum State {
Open,
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed,
/// Temporarily not actionable
Pending,
Procedure,
/// Actionable ordered task list
Procedure = PROCEDURE_KIND as isize,
}
impl From<&str> for State {
fn from(value: &str) -> Self {

View File

@ -1,7 +1,7 @@
use std::collections::{BTreeSet, HashMap, VecDeque};
use std::fmt::{Display, Formatter};
use std::io::{Error, stdout, Write};
use std::iter::once;
use std::iter::{empty, once};
use std::ops::{Div, Rem};
use std::str::FromStr;
use std::sync::mpsc::Sender;
@ -11,7 +11,7 @@ use chrono::Local;
use colored::Colorize;
use itertools::Itertools;
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 TagStandard::Hashtag;
@ -27,26 +27,29 @@ pub(crate) struct Tasks {
tasks: TaskMap,
/// History of active tasks by PubKey
history: HashMap<PublicKey, BTreeSet<Event>>,
/// Index of found users with metadata
users: HashMap<PublicKey, Metadata>,
/// The task properties currently visible
properties: Vec<String>,
/// The task properties sorted by
sorting: VecDeque<String>,
/// Currently active task
position: Option<EventId>,
/// A filtered view of the current tasks
view: Vec<EventId>,
/// Negative: Only Leaf nodes
/// Zero: Only Active node
/// Positive: Go down the respective level
depth: i8,
/// Currently active task
position: Option<EventId>,
/// Currently active tags
tags: BTreeSet<Tag>,
/// Tags filtered out
tags_excluded: BTreeSet<Tag>,
/// Current active state
state: StateFilter,
/// A filtered view of the current tasks
view: Vec<EventId>,
sender: EventSender,
}
@ -118,6 +121,7 @@ impl Tasks {
Tasks {
tasks: Default::default(),
history: Default::default(),
users: Default::default(),
properties: vec![
"state".into(),
"rtime".into(),
@ -329,12 +333,21 @@ impl Tasks {
.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() {
if let Some(TagStandard::Event { event_id, .. }) = tag.as_standardized() {
self.tasks.get_mut(event_id).map(|t| f(t));
if let Some(TagStandard::Event { event_id, marker, .. }) = tag.as_standardized() {
if marker.is_none() {
self.tasks.get_mut(event_id).map(|t| {
found = true;
f(t)
});
}
}
}
found
}
#[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()) {
return format!("Blocked by \"{}\"", task.get_title()).bright_red().to_string();
}
let state = task.state_or_default();
if state.state.is_open() && progress.is_some_and(|p| p > 0.1) {
state.state.colorize(&prog_string)
let state = task.pure_state();
if state.is_open() && progress.is_some_and(|p| p > 0.1) {
state.colorize(&prog_string)
} else {
state.get_colored_label()
task.state_label().unwrap_or_default()
}.to_string()
}
"progress" => prog_string.clone(),
"author" => self.get_author(&task.event.pubkey),
"path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id),
// 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
pub(crate) fn set_filter(&mut self, view: Vec<EventId>) {
@ -528,7 +549,7 @@ impl Tasks {
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> {
if let Ok(id) = EventId::parse(arg) {
return vec![id];
@ -597,24 +618,6 @@ impl Tasks {
// 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 {
Tag::from(TagStandard::Event {
event_id: id,
@ -655,20 +658,23 @@ impl Tasks {
/// Creates a task following the current state
/// Sanitizes input
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) {
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.move_to(Some(id));
}
/// Creates a task
/// Creates a task with tags from filter and position
/// Sanitizes input
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(
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())
);
if set_state {
@ -677,19 +683,6 @@ impl Tasks {
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 {
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 ");
if let Ok(num) = stripped.parse::<i64>() {
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) {
Ok(date) => Some(date.to_utc()),
@ -760,7 +753,12 @@ impl Tasks {
Some(c) => { c.insert(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) {
self.referenced_tasks(&event, |t| {
fn add_prop(&mut self, event: Event) {
let found = self.referenced_tasks(&event, |t| {
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>> {
@ -812,7 +817,7 @@ impl Tasks {
}
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(),
comment,
id,
@ -827,13 +832,19 @@ impl Tasks {
}
pub(crate) fn make_note(&mut self, note: &str) {
match self.position {
None => warn!("Cannot add note \"{}\" without active task", note),
Some(id) => {
let prop = self.build_prop(Kind::TextNote, note, id);
if let Some(id) = self.position {
if self.get_by_id(&id).is_some_and(|t| t.is_task()) {
let prop = build_prop(Kind::TextNote, note.trim(), id);
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
@ -1075,7 +1086,7 @@ mod tasks_test {
fn test_procedures() {
let mut tasks = stub_tasks();
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());
let sub_id = tasks.make_task("sub");
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([sub_id]));