forked from janek/mostr
Compare commits
1 Commits
main
...
taskprogre
Author | SHA1 | Date |
---|---|---|
xeruf | 97450591e3 |
|
@ -1,7 +1,6 @@
|
||||||
/target
|
/target
|
||||||
/examples
|
|
||||||
|
|
||||||
/.idea
|
|
||||||
relays
|
relays
|
||||||
keys
|
keys
|
||||||
*.html
|
*.html
|
||||||
|
/src/bin
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
35
Cargo.toml
35
Cargo.toml
|
@ -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"
|
|
||||||
|
|
44
DESIGN.md
44
DESIGN.md
|
@ -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
323
README.md
|
@ -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.
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
[toolchain]
|
|
||||||
channel = "nightly-2024-11-09"
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
156
src/helpers.rs
156
src/helpers.rs
|
@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
169
src/kinds.rs
169
src/kinds.rs
|
@ -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![]));
|
|
||||||
}
|
|
921
src/main.rs
921
src/main.rs
File diff suppressed because it is too large
Load Diff
354
src/task.rs
354
src/task.rs
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
2306
src/tasks.rs
2306
src/tasks.rs
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue