forked from janek/mostr
1
0
Fork 0

Compare commits

..

1 Commits

Author SHA1 Message Date
xeruf 97450591e3 feat: TaskProgress accumulation struct 2024-07-30 17:21:03 +03:00
12 changed files with 1412 additions and 4992 deletions

3
.gitignore vendored
View File

@ -1,7 +1,6 @@
/target /target
/examples
/.idea
relays relays
keys keys
*.html *.html
/src/bin

1982
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,34 +5,19 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
readme = "README.md" readme = "README.md"
license = "GPL 3.0" license = "GPL 3.0"
authors = ["melonion"] authors = ["melonion"]
version = "0.6.0" version = "0.2.0"
rust-version = "1.82"
edition = "2021" edition = "2021"
default-run = "mostr" default-run = "mostr"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
# Basics xdg = "2.5.2"
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] } itertools = "0.12.1"
regex = "1.10.6" log = "0.4.21"
# System chrono = "0.4.38"
log = "0.4" colog = "1.3.0"
env_logger = "0.11" colored = "2.1.0"
colog = "1.3" nostr-sdk = "0.30"
colored = "2.1" tokio = { version = "1.0.0", features = ["rt", "rt-multi-thread", "macros"] }
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" } once_cell = "1.19.0"
# OS-Specific Abstractions
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
directories = "5.0"
# Application Utils
itertools = "0.12"
chrono = "0.4"
parse_datetime = "0.5.0"
interim = { version = "0.1", features = ["chrono"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
[dev-dependencies]
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
chrono-english = "0.1"
linefeed = "0.6"

View File

@ -1,44 +0,0 @@
# Mostr Design & Internals
## Nostr Reference
All used nostr kinds are listed on the top of [kinds.rs](./src/kinds.rs)
Mostr mainly uses the following [NIPs](https://github.com/nostr-protocol/nips):
- 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 markdown support implemented)
+ TBI: Kind 1622 for task comments
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
- 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
- Kind 31923 for Calendar, since it has a time
## Immutability
Apart from user-specific temporary utilities such as the Bookmark List (Kind 10003),
all shared data is immutable, and modifications are recorded as separate events,
providing full audit security.
Deletions are not considered.
### Timestamps
Mostr provides convenient helpers to backdate an action to a limited extent.
But when closing one task with `)10` at 10:00 of the current day
and starting another with `(10` on the same day,
depending on the order of the event ids,
the started task would be terminated immediately
due to the equal timestamp.
That is why I decided to subtract one second from the timestamp
whenever timetracking is stopped,
making sure that the stop event always happens before the start event
when the same timestamp is provided in the interface.
Since the user interface is anyways focused on comprehensible output
and thus slightly fuzzy,
I then also add one second to each timestamp displayed
to make the displayed timestamps more intuitive.

323
README.md
View File

@ -1,12 +1,6 @@
# mostr # mostr
An immutable nested collaborative task manager, powered by nostr! A nested task chat, powered by nostr!
> Mostr is beta software.
> Do not entrust it exclusively with your data unless you know what you are doing!
> Intermediate versions might not properly persist all changes.
> A failed relay connection currently looses all intermediate changes.
## Quickstart ## Quickstart
@ -14,293 +8,88 @@ First, start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development - https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use - https://github.com/rnostr/rnostr for production use
Install rust(up) and run a development build with: Run development build with:
cargo run cargo run
A `relay` list can be placed in a config file Creating a test task:
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`. `nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
Ideally any project with different collaborators has its own relay.
If not saved, mostr will ask for a relay url
(entering none is fine too, but your data will not be persisted between sessions)
and a private key, alternatively generating one on the fly.
The key is saved in the system keychain.
Install latest build: Install latest build:
cargo install --path . cargo install --path . --offline
This one-liner can help you stay on the latest version ## Principles
(optionally add a `cd` to your mostr-directory in front):
git pull && cargo install --path . && mostr - active task is tracked automatically
- progress through subdivision rather than guessing
- TBI: show/hide closed/done tasks
Creating a test task externally: Recommendation: Flat hierarchy, using tags for filtering (TBI)
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
To exit the application, press `Ctrl-D`.
## Basic Usage
### Navigation and Nesting
Create tasks and navigate using the shortcuts below.
Whichever task is active (selected)
will be the parent task for newly created tasks
and automatically has time-tracking running.
To track task progress,
simply subdivide the task -
checking off tasks will automatically update the progress
for all parent tasks.
Generally a flat hierarchy is recommended
with tags for filtering,
since hierarchies cannot be changed.
Filtering by a tag is just as easy
as activating a task and more flexible.
Using subtasks has two main advantages:
- ability to accumulate time tracked
- swiftly navigate between related tasks
Managing a project with subtasks makes it continuously visible,
which is helpful if you want to be able to track time on the project itself
without a specific task,
Thus subtasks can be very useful for specific contexts,
for example a project or a specific place.
On the other hand, related tasks like chores
should be grouped with a tag instead.
Similarly for projects which are only sporadically worked on
when a specific task comes up, so they do not clutter the list.
### Collaboration
Since everything in mostr is inherently immutable,
live collaboration is easily possible.
After every command,
mostr checks if new updates arrived from the relay
and updates its display accordingly.
If a relay has a lot of events,
initial population of data can take a bit -
but you can already start creating events without issues,
updates will be fetched in the background.
For that reason,
it is recommended to leave mostr running
as you work.
### Time-Tracking
The currently active task is automatically time-tracked.
To stop time-tracking completely, simply move to the root of all tasks.
Time-tracking by default recursively summarizes
### Priorities
Task priorities can be set as any natural number,
with higher numbers denoting higher priorities.
The syntax here allows for very convenient incremental usage:
By default, using priorities between 1 and 9 is recommended,
with an exemplary interpretation like this:
* 1 Ideas / "Someday"
* 2 Later
* 3 Soon
* 4 Relevant
* 5 Important
* 9 DO NOW
Internally, when giving a single digit, a 0 is appended,
so that the default priorities increment in steps of 10.
So in case you need more than 10 priorities,
instead of stacking them on top,
you can granularly add them in between.
For example, `12` is in between `1` and `2`
which are equivalent to `10` and `20`,
not above `9` but above `09`!
By default, only tasks with priority `35` and upward are shown
so you can focus on what matters,
but you can temporarily override that using `**PRIO`.
### Quick Access
Paper-based lists are often popular because you can quickly put down a bunch of items.
Mostr offers three useful workflows depending on the use-case:
If you want to TBC...
- temporary task with subtasks (especially handy for progression)
- Filter by recently created
- Pin to bookmarks
- high priority
## Reference ## Reference
### Command Syntax ### Command Syntax
`TASK` creation syntax: `NAME #TAG *PRIO # TAG1 TAG2 ...` `TASK` creation syntax: `NAME: TAG1 TAG2 ...`
- `TASK` - create task - `TASK` - create task
+ prefix with space if you want a task to start with a command character - `.` - clear filters and reload
+ paste text with newlines to create one task per line
- `.` - clear all filters
- `.TASK` - `.TASK`
+ activate task by id + select 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 the given number (how many subtask levels to show, default is 1) - `.2` - set view depth to `2`, which can be substituted for any number (how many subtask levels to show, default 1)
- `/[TEXT]` - activate task or filter by smart-case substring match (empty: move to root)
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - mark current task as procedure or create a sibling task depending on the current one and move up
- sibling task shortcut?
Dot or slash can be repeated to move to parent tasks before acting. Dots can be repeated to move to parent tasks
Append `@TIME` to any task creation or change command to record the action with the given time.
- `:[IND][PROP]` - add property column PROP at IND or end, - `:[IND][COL]` - add / remove property column COL to IND or end
if it already exists remove property column PROP or IND; empty: list properties - `>[TEXT]` - Complete active task and move to parent, with optional state description
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed) - `<[TEXT]` - Close active task and move to parent, with optional state description
- `([TIME]` - list tracked times or insert timetracking with the specified offset (double to view all history) - `|TEXT` - Set state for current task from text (also aliased to `/` for now)
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights` - `-TEXT` - add text note (comment / description)
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
- `>[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: Open
- `!TIME: REASON` - defer current task to date
- `,[TEXT]` - list notes or add text (activity / task description)
- TBI: `;[TEXT]` - list comments or comment on task
- TBI: show status history and creation with attribution
- `&` - revert
- with string argument, find first matching task in history
- with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions)
- `*` - (un)bookmark current task or list all bookmarks
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters: Property Filters:
- `#TAG1 TAG2` - set tag filter - `#TAG` - filter by tag
- `+TAG` - add tag filter (empty: list all used tags) - `?STATE` - filter by state (type or description) - plain `?` to reset
- `-TAG` - remove tag filters (by prefix)
- `?STATUS` - set status filter (type or description) - plain `?` to reset, `??` to show all
- `*INT` - set priority filter - `**` to reset
- `@[AUTHOR|TIME]` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
Status descriptions can be used for example for Kanban columns or review flows. State descriptions can be used for example for Kanban columns.
An active tag or status filter will also set that attribute for newly created tasks. An active tag or state filter will also set that attribute for newly created tasks.
### Notes ### Available Columns
- TBI = To Be Implemented - `id`
- `. TASK` - create and enter a new task even if the name matches an existing one - `parentid`
- `name`
- `state`
- `hashtags`
- `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
For debugging: `props`, `alltags`, `descriptions`
TBI: Combined formatting and recursion specifiers -
for example progress count/percentage and recursive or not.
Subtask progress immediate/all/leafs.
## Plans ## Plans
- Handle event sending rejections (e.g. permissions) - Relay Selection, fetch most recent tasks first
- Local Database Cache, Negentropy Reconciliation - parse Hashtag tags from task name
-> Offline Use! - Personal time tracking
- Remove status filter when moving up? - Unified Filter object
- Task markdown support? - colored -> include sub
- Calendar Events - make future time-tracking editable -> parametrised replaceable events - Time tracking: Active not as task state, ability to postpone task and add planned timestamps (calendar entry)
- Speedup: Offline caching & Expiry (no need to fetch potential years of history) - TUI - Clear terminal?
+ Fetch most recent tasks first - Expiry (no need to fetch potential years of history)
+ Relay: compress tracked time for old tasks, filter closed tasks - Offline caching
+ Relay: filter out task status updates within few seconds, also on client side - Web Interface, Messenger integrations
- Relay: filter out task state updates within few seconds, also on client side
### Commands
- Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
### Conceptual
The following features are not ready to be implemented
because they need conceptualization.
Suggestions welcome!
- Queueing tasks
- 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?
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Templates
- Ownership
- Combined formatting and recursion specifiers
+ progress count/percentage and recursive or not
+ Subtask progress immediate/all/leafs
+ path full / leaf / top
### Interfaces & Integrations
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar
- n8n node
- Webcal Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward
Interfaces:
- text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/
## Exemplary Workflows - User Stories
- 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
### Freelancer
For a Freelancer, mostr can help structure work times
across different projects
because it can connect to multiple clients,
using their mental state effectively (Mind Management not Time Management).
It also enables transparency for clients
by sharing the tracked time -
but alternatively the freelancer
can track times on their own auxiliary relay
without problems.
### Family
With a mobile client implemented,
mostr can track shopping lists and other chores for a family,
and provide them context-dependently -
allowing you to batch shopping and activities without mental effort.
### Project Team
sharing, assigning, stand-ups, communication
### 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.
#### Vision of Work-Life-Balance for Freelancer
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

@ -1,2 +0,0 @@
[toolchain]
channel = "nightly-2024-11-09"

View File

@ -1,107 +0,0 @@
use std::cell::RefCell;
use std::ops::Sub;
use nostr_sdk::prelude::*;
use tokio::sync::mpsc::Sender;
use crate::kinds::TRACKING_KIND;
use crate::tasks;
use log::{debug, error, info, trace, warn};
use nostr_sdk::Event;
const UNDO_DELAY: u64 = 60;
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum MostrMessage {
Flush,
NewRelay(Url),
AddTasks(Url, Vec<Event>),
}
type Events = Vec<Event>;
#[derive(Debug, Clone)]
pub(crate) struct EventSender {
pub(crate) url: Option<Url>,
pub(crate) tx: Sender<MostrMessage>,
pub(crate) keys: Keys,
pub(crate) queue: RefCell<Events>,
}
impl EventSender {
pub(crate) fn from(url: Option<Url>, tx: &Sender<MostrMessage>, keys: &Keys) -> Self {
EventSender {
url,
tx: tx.clone(),
keys: keys.clone(),
queue: Default::default(),
}
}
// TODO this direly needs testing
pub(crate) fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
let min = Timestamp::now().sub(UNDO_DELAY);
{
// Always flush if oldest event older than a minute or newer than now
let borrow = self.queue.borrow();
if borrow
.iter()
.any(|e| e.created_at < min || e.created_at > Timestamp::now())
{
drop(borrow);
debug!("Flushing event queue because it is older than a minute");
self.force_flush();
}
}
let mut queue = self.queue.borrow_mut();
Ok(event_builder.sign_with_keys(&self.keys).inspect(|event| {
if event.kind == TRACKING_KIND
&& event.created_at > min
&& event.created_at < tasks::now()
{
// Do not send redundant movements
queue.retain(|e| e.kind != TRACKING_KIND);
}
queue.push(event.clone());
})?)
}
/// Sends all pending events
fn force_flush(&self) {
debug!("Flushing {} events from queue", self.queue.borrow().len());
let values = self.clear();
self.url.as_ref().map(|url| {
self.tx
.try_send(MostrMessage::AddTasks(url.clone(), values))
.err()
.map(|e| {
error!(
"Nostr communication thread failure, changes will not be persisted: {}",
e
)
})
});
}
/// Sends all pending events if there is a non-tracking event
pub(crate) fn flush(&self) {
if self
.queue
.borrow()
.iter()
.any(|event| event.kind != TRACKING_KIND)
{
self.force_flush()
}
}
pub(crate) fn clear(&self) -> Events {
trace!("Cleared queue: {:?}", self.queue.borrow());
self.queue.replace(Vec::with_capacity(3))
}
pub(crate) fn pubkey(&self) -> PublicKey {
self.keys.public_key()
}
}
impl Drop for EventSender {
fn drop(&mut self) {
self.force_flush();
debug!("Dropped {:?}", self);
}
}

View File

@ -1,156 +0,0 @@
use std::ops::Sub;
use chrono::LocalResult::Single;
use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc};
use log::{debug, error, info, trace, warn};
use nostr_sdk::Timestamp;
pub const CHARACTER_THRESHOLD: usize = 3;
pub fn to_string_or_default(arg: Option<impl ToString>) -> String {
arg.map(|arg| arg.to_string()).unwrap_or_default()
}
pub fn some_non_empty(str: &str) -> Option<String> {
if str.is_empty() { None } else { Some(str.to_string()) }
}
pub fn trim_start_count(str: &str, char: char) -> (&str, usize) {
let len = str.len();
let result = str.trim_start_matches(char);
let dots = len - result.len();
(result, dots)
}
pub trait ToTimestamp {
fn to_timestamp(&self) -> Timestamp;
}
impl<T: TimeZone> ToTimestamp for DateTime<T> {
fn to_timestamp(&self) -> Timestamp {
let stamp = self.to_utc().timestamp();
if let Some(t) = 0u64.checked_add_signed(stamp) {
Timestamp::from(t)
} else { Timestamp::zero() }
}
}
/// Parses the hour from a plain number in the String,
/// with max of max_future hours into the future.
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
str.parse::<u32>().ok().and_then(|hour| {
let now = Local::now();
#[allow(deprecated)]
now.date().and_hms_opt(hour, 0, 0).map(|time| {
if time - now > TimeDelta::hours(max_future) {
time.sub(TimeDelta::days(1))
} else {
time
}
})
})
}
pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
// Using two libraries for better exhaustiveness, see https://github.com/uutils/parse_datetime/issues/84
match interim::parse_date_string(str, Local::now(), interim::Dialect::Us) {
Ok(date) => Some(date.to_utc()),
Err(e) => {
match parse_datetime::parse_datetime_at_date(Local::now(), str) {
Ok(date) => Some(date.to_utc()),
Err(_) => {
warn!("Could not parse date from \"{str}\": {e}");
None
}
}
}
}.map(|time| {
// TODO properly map date without time to day start, also support intervals
if str.chars().any(|c| c.is_numeric()) {
time
} else {
#[allow(deprecated)]
time.date().and_time(NaiveTime::default()).unwrap()
}
})
}
/// Turn a human-readable relative timestamp into a nostr Timestamp.
/// - Plain number as hour, 18 hours back or 6 hours forward
/// - Number with prefix as minute offset
/// - Otherwise try to parse a relative date
pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
if let Some(num) = parse_hour(str, 6) {
return Some(num.to_timestamp());
}
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
if let Ok(num) = stripped.parse::<i64>() {
return Some(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
}
parse_date(str).and_then(|time| {
let stamp = time.to_utc().timestamp();
if stamp > 0 {
Some(Timestamp::from(stamp as u64))
} else {
warn!("Can only track times after 1970!");
None
}
})
}
/// Format DateTime easily comprehensible for human but unambiguous.
/// Length may vary.
pub fn format_datetime_relative(time: DateTime<Local>) -> String {
let date = time.date_naive();
let prefix =
match Local::now()
.date_naive()
.signed_duration_since(date)
.num_days() {
-1 => "tomorrow ".into(),
0 => "".into(),
1 => "yesterday ".into(),
//-3..=3 => date.format("%a ").to_string(),
-10..=10 => date.format("%d. %a ").to_string(),
-100..=100 => date.format("%a %b %d ").to_string(),
_ => date.format("%y-%m-%d %a ").to_string(),
};
format!("{}{}", prefix, time.format("%H:%M"))
}
/// Format a nostr timestamp with the given formatting function.
pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where
F: Fn(DateTime<Local>) -> String,
{
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
Single(time) => formatter(time),
_ => stamp.to_human_datetime().to_string(),
}
}
/// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today.
pub fn format_timestamp_relative(stamp: &Timestamp) -> String {
format_as_datetime(stamp, format_datetime_relative)
}
/// Format nostr timestamp with the given format.
pub fn format_timestamp(stamp: &Timestamp, format: &str) -> String {
format_as_datetime(stamp, |time| time.format(format).to_string())
}
/// Format nostr timestamp in a sensible comprehensive format with consistent length and consistent sorting.
///
/// Currently: 18 characters
pub fn format_timestamp_local(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M")
}
pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
// Rough difference in days
match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 {
0 => format_timestamp(stamp, "%H:%M"),
-3..=3 => format_timestamp(stamp, "%a %H:%M"),
_ => format_timestamp_local(stamp),
}
}

View File

@ -1,169 +0,0 @@
use crate::task::MARKER_PARENT;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools;
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue;
pub const PROCEDURE_KIND_ID: u16 = 1639;
pub const PROCEDURE_KIND: Kind = Kind::Regular(PROCEDURE_KIND_ID);
pub const TRACKING_KIND: Kind = Kind::Regular(1650);
pub const BASIC_KINDS: [Kind; 4] = [
Kind::Metadata,
Kind::TextNote,
TASK_KIND,
Kind::Bookmarks,
];
pub const PROP_KINDS: [Kind; 6] = [
TRACKING_KIND,
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
PROCEDURE_KIND,
];
pub type Prio = u16;
pub const PRIO: &str = "priority";
// TODO: use formatting - bold / heading / italics - and generate from code
/// Helper for available properties.
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 or abbreviated key 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
- `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
- `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: `kind`, `pubkey`, `props`, `alltags`, `descriptions`";
pub struct EventTag {
pub id: EventId,
pub marker: Option<String>,
}
/// Return event tag if existing
pub(crate) fn match_event_tag(tag: &Tag) -> Option<EventTag> {
let mut vec = tag.as_slice().into_iter();
if vec.next() == Some(&"e".to_string()) {
if let Some(id) = vec.next().and_then(|v| EventId::parse(v).ok()) {
vec.next();
return Some(EventTag { id, marker: vec.next().cloned() });
}
}
None
}
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
where
I: IntoIterator<Item=EventId>,
{
EventBuilder::new(Kind::from(TRACKING_KIND), "")
.tags(id.into_iter().map(Tag::event))
}
pub fn join<'a, T>(tags: T) -> String
where
T: IntoIterator<Item=&'a Tag>,
{
tags.into_iter().map(format_tag).join(", ")
}
/// Return Hashtags embedded in the string.
pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
input.split_ascii_whitespace()
.filter(|s| s.starts_with('#'))
.map(|s| s.trim_start_matches('#'))
.map(to_hashtag)
}
/// Extracts everything after a " # " as a list of tags
/// as well as various embedded tags.
///
/// Expects sanitized input.
pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
let words = input.split_ascii_whitespace();
let mut prio = None;
let result = words.filter(|s| {
if s.starts_with('*') {
if s.len() == 1 {
prio = Some(HIGH_PRIO);
return false;
}
return match s[1..].parse::<Prio>() {
Ok(num) => {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
false
}
_ => true,
};
}
true
}).collect_vec();
let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag(&s)))
.chain(prio.map(|p| to_prio_tag(p)))
.collect_vec();
tags.sort();
tags.dedup();
(main, tags)
}
pub fn to_hashtag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into()
}
fn format_tag(tag: &Tag) -> String {
if let Some(et) = match_event_tag(tag) {
return format!("{}: {:.8}",
et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()),
et.id);
}
match tag.as_standardized() {
Some(TagStandard::PublicKey {
public_key,
alias,
..
}) => format!("Key{}: {:.8}", public_key, alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default()),
Some(TagStandard::Hashtag(content)) =>
format!("#{content}"),
_ => tag.as_slice().join(" ")
}
}
pub(crate) fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
}
pub(crate) fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
}
#[test]
fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it"),
("Hello from #mars with #greetings #yeah".to_string(),
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag)).collect()));
assert_eq!(extract_tags("So tagless #"),
("So tagless".to_string(), vec![]));
}

File diff suppressed because it is too large Load Diff

View File

@ -1,67 +1,36 @@
use fmt::Display; use fmt::Display;
use std::cmp::Ordering; use std::collections::{BTreeSet, HashSet};
use std::collections::BTreeSet;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; use std::ops::Div;
use std::iter::once;
use std::string::ToString;
use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right}; use itertools::Either::{Left, Right};
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::{Alphabet, Event, EventId, Kind, Tag, Timestamp}; use nostr_sdk::{Alphabet, Event, EventBuilder, EventId, Kind, Tag, Timestamp};
use crate::helpers::{format_timestamp_local, some_non_empty}; use crate::EventSender;
use crate::kinds::{is_hashtag, match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
pub static MARKER_PARENT: &str = "parent"; #[derive(Debug, Clone, PartialEq)]
pub static MARKER_DEPENDS: &str = "depends";
pub static MARKER_PROPERTY: &str = "property";
#[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,
/// Cached sorted tags of the event with references removed pub(crate) children: HashSet<EventId>,
tags: Option<BTreeSet<Tag>>,
/// Task references derived from the event tags
refs: Vec<(String, EventId)>,
/// Events belonging to this task, such as state updates and notes
pub(crate) props: BTreeSet<Event>, pub(crate) props: BTreeSet<Event>,
} /// Cached sorted tags of the event
pub(crate) tags: Option<BTreeSet<Tag>>,
impl PartialOrd<Self> for Task { parents: Vec<EventId>,
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 Hash for Task {
fn hash<H: Hasher>(&self, state: &mut H) {
self.event.id.hash(state);
}
} }
impl Task { impl Task {
pub(crate) fn new(event: Event) -> Task { pub(crate) fn new(event: Event) -> Task {
let (refs, tags) = event.tags.iter().partition_map(|tag| if let Some(et) = match_event_tag(tag) { let (parents, tags) = event.tags.iter().partition_map(|tag| match tag {
Left((et.marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), et.id)) Tag::Event { event_id, .. } => return Left(event_id),
} else { _ => Right(tag.clone()),
Right(tag.clone())
}); });
// Separate refs for dependencies
Task { Task {
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()),
refs, parents,
event, event,
} }
} }
@ -70,99 +39,59 @@ impl Task {
&self.event.id &self.event.id
} }
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> { pub(crate) fn parent_id(&self) -> Option<EventId> {
self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker)) self.parents.first().cloned()
} }
pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next()
}
pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect()
}
/// Trimmed event content or stringified id
pub(crate) fn get_title(&self) -> String { pub(crate) fn get_title(&self) -> String {
some_non_empty(self.event.content.trim()) Some(self.event.content.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.get_id().to_string()) .unwrap_or_else(|| self.get_id().to_string())
} }
pub(crate) fn get_filter_title(&self) -> String { pub(crate) fn descriptions(&self) -> impl Iterator<Item = &String> + '_ {
self.event.content.trim().trim_start_matches('#').to_string()
}
pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
self.props.iter().filter(|event| event.kind == Kind::TextNote)
}
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content)
}
pub(crate) fn is_task_kind(&self) -> bool {
self.event.kind == TASK_KIND
}
/// Whether this is an actionable task - false if stateless activity
pub(crate) fn is_task(&self) -> bool {
self.is_task_kind() ||
self.props.iter().any(|event| State::try_from(event.kind).is_ok())
}
pub(crate) fn priority(&self) -> Option<Prio> {
self.priority_raw().and_then(|s| s.parse().ok())
}
pub(crate) fn priority_raw(&self) -> Option<&str> {
self.props.iter()
.chain(once(&self.event))
.find_map(|p| {
p.tags.iter().find_map(|t|
t.content().take_if(|_| { t.kind().to_string() == PRIO }))
})
}
fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
self.props.iter().filter_map(|event| { self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| TaskState { if event.kind == Kind::TextNote {
name: some_non_empty(&event.content), Some(&event.content)
state: s, } else {
time: event.created_at, None
}) }
}) })
} }
pub(crate) fn last_state_update(&self) -> Timestamp { fn states(&self) -> impl Iterator<Item = TaskState> + '_ {
self.state().map(|s| s.time).unwrap_or(self.event.created_at) self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| TaskState {
name: Some(event.content.clone()).filter(|c| !c.is_empty()),
state: s,
time: event.created_at.clone(),
})
})
} }
pub(crate) fn state(&self) -> Option<TaskState> { pub(crate) fn state(&self) -> Option<TaskState> {
let now = now(); self.states().max_by_key(|t| t.time)
// TODO do not iterate constructed state objects
let state = self.states().take_while_inclusive(|ts| ts.time > now);
state.last().map(|ts| {
if ts.time <= now {
ts
} else {
self.default_state()
}
})
} }
pub(crate) fn pure_state(&self) -> State { pub(crate) fn pure_state(&self) -> State {
self.state().map_or(State::Open, |s| s.state) self.state().map_or(State::Open, |s| s.state)
} }
pub(crate) fn state_or_default(&self) -> TaskState { pub(crate) fn set_state(
self.state().unwrap_or_else(|| self.default_state()) &mut self,
} sender: &EventSender,
state: State,
/// Returns None for activities. comment: &str,
pub(crate) fn state_label(&self) -> Option<ColoredString> { ) -> Option<Event> {
self.state() sender
.or_else(|| Some(self.default_state()).filter(|_| self.is_task())) .submit(EventBuilder::new(
.map(|state| state.get_colored_label()) state.kind(),
comment,
vec![Tag::event(self.event.id)],
))
.inspect(|e| {
self.props.insert(e.clone());
})
} }
fn default_state(&self) -> TaskState { fn default_state(&self) -> TaskState {
@ -173,57 +102,68 @@ impl Task {
} }
} }
pub(crate) fn get_hashtags(&self) -> impl Iterator<Item=&Tag> { /// Total time this task has been active.
self.tags().filter(|t| is_hashtag(t)) /// TODO: Consider caching
pub(crate) fn time_tracked(&self) -> u64 {
let mut total = 0;
let mut start: Option<Timestamp> = None;
for state in self.states() {
match state.state {
State::Active => start = start.or(Some(state.time)),
_ => {
if let Some(stamp) = start {
total += (state.time - stamp).as_u64();
start = None;
}
}
}
}
if let Some(start) = start {
total += (Timestamp::now() - start).as_u64();
}
total
} }
fn tags(&self) -> impl Iterator<Item=&Tag> { fn filter_tags<P>(&self, predicate: P) -> Option<String>
self.tags.iter().flatten().chain(
self.props.iter().flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
)
}
fn join_tags<P>(&self, predicate: P) -> String
where where
P: FnMut(&&Tag) -> bool, P: FnMut(&&Tag) -> bool,
{ {
self.tags() self.tags.as_ref().map(|tags| {
.filter(predicate) tags.into_iter()
.map(|t| t.content().unwrap().to_string()) .filter(predicate)
.sorted_unstable() .map(|t| format!("{}", t.content().unwrap()))
.dedup() .collect::<Vec<String>>()
.join(" ") .join(" ")
})
} }
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()),
"state" => self.state().map(|s| s.to_string()),
"name" => Some(self.event.content.clone()), "name" => Some(self.event.content.clone()),
"key" | "pubkey" => Some(self.event.pubkey.to_string()), "time" => Some(self.time_tracked().div(60))
"created" => Some(format_timestamp_local(&self.event.created_at)), .filter(|t| t > &0)
"kind" => Some(self.event.kind.to_string()), .map(|t| format!("{}m", t)),
// Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()),
"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" => Some(self.join_tags(|tag| { is_hashtag(tag) })), "hashtags" => self.filter_tags(|tag| {
"tags" => Some(self.join_tags(|_| true)), // TODO test these! tag.single_letter_tag()
.is_some_and(|sltag| sltag.character == Alphabet::T)
}),
"tags" => self.filter_tags(|_| true),
"alltags" => Some(format!("{:?}", self.tags)), "alltags" => Some(format!("{:?}", self.tags)),
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
"props" => Some(format!( "props" => Some(format!(
"{:?}", "{:?}",
self.props self.props
.iter() .iter()
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content)) .map(|e| format!("{} kind {} '{}'", e.created_at, e.kind, e.content))
.collect_vec() .collect::<Vec<String>>()
)), )),
"descriptions" => Some(format!( "descriptions" => Some(format!(
"{:?}", "{:?}",
self.descriptions().collect_vec() self.descriptions().collect::<Vec<&String>>()
)), )),
_ => { _ => {
warn!("Unknown task property {}", property); warn!("Unknown task property {}", property);
@ -234,22 +174,20 @@ impl Task {
} }
pub(crate) struct TaskState { pub(crate) struct TaskState {
pub(crate) state: State, state: State,
name: Option<String>, name: Option<String>,
pub(crate) time: Timestamp, pub(crate) time: Timestamp,
} }
impl TaskState { impl TaskState {
pub(crate) fn get_label_for(state: &State, comment: &str) -> String {
some_non_empty(comment).unwrap_or_else(|| state.to_string())
}
pub(crate) fn get_label(&self) -> String { pub(crate) fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string()) self.name.clone().unwrap_or_else(|| self.state.to_string())
} }
pub(crate) fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub(crate) fn matches_label(&self, label: &str) -> bool { pub(crate) fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label)) self.state == State::Active
|| self
.name
.as_ref()
.is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label) || self.state.to_string().eq_ignore_ascii_case(label)
} }
} }
@ -268,74 +206,41 @@ impl Display for TaskState {
} }
} }
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)] #[derive(Debug, Copy, Clone, PartialEq)]
pub(crate) enum State { pub(crate) enum State {
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed, Closed,
/// Temporarily not actionable Open,
Pending, Active,
/// Actionable ordered task list Done,
Procedure = PROCEDURE_KIND_ID as isize,
}
impl TryFrom<&str> for State {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_ascii_lowercase().as_str() {
"closed" => Ok(State::Closed),
"done" => Ok(State::Done),
"pending" => Ok(State::Pending),
"proc" | "procedure" | "list" => Ok(State::Procedure),
"open" => Ok(State::Open),
_ => Err(()),
}
}
} }
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 { match value.as_u32() {
Kind::GitStatusOpen => Ok(State::Open), 1630 => Ok(State::Open),
Kind::GitStatusApplied => Ok(State::Done), 1631 => Ok(State::Done),
Kind::GitStatusClosed => Ok(State::Closed), 1632 => Ok(State::Closed),
Kind::GitStatusDraft => Ok(State::Pending), 1633 => Ok(State::Active),
_ => { _ => Err(()),
if value == PROCEDURE_KIND {
Ok(State::Procedure)
} else {
Err(())
}
}
} }
} }
} }
impl State { impl State {
pub(crate) fn is_open(&self) -> bool { pub(crate) fn is_open(&self) -> bool {
matches!(self, State::Open | State::Pending | State::Procedure)
}
pub(crate) fn kind(self) -> u16 {
self as u16
}
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self { match self {
State::Open => str.green(), State::Open | State::Active => true,
State::Done => str.bright_black(), _ => false,
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
} }
} }
}
impl From<State> for Kind { pub(crate) fn kind(&self) -> Kind {
fn from(value: State) -> Self { match self {
Kind::from(value.kind()) State::Open => Kind::from(1630),
State::Done => Kind::from(1631),
State::Closed => Kind::from(1632),
State::Active => Kind::from(1633),
}
} }
} }
impl Display for State { impl Display for State {
@ -343,34 +248,3 @@ impl Display for State {
fmt::Debug::fmt(self, f) fmt::Debug::fmt(self, f)
} }
} }
#[cfg(test)]
mod tasks_test {
use super::*;
use nostr_sdk::{EventBuilder, Keys};
#[test]
fn test_state() {
let keys = Keys::generate();
let mut task = Task::new(
EventBuilder::new(TASK_KIND, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.get_hashtags().count(), 1);
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
task.props.insert(
EventBuilder::new(State::Open.into(), "").tags([Tag::hashtag("tag2")])
.custom_created_at(Timestamp::from(Timestamp::now() - 2))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
assert_eq!(task.get_hashtags().count(), 2);
task.props.insert(
EventBuilder::new(State::Closed.into(), "")
.custom_created_at(Timestamp::from(Timestamp::now() + 1))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Closed);
}
}

File diff suppressed because it is too large Load Diff