Compare commits
221 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
29c104b96b | ||
![]() |
220d713675 | ||
![]() |
fd7819e979 | ||
![]() |
fe6ac592be | ||
![]() |
cbeba49bb3 | ||
![]() |
7e3039ef1a | ||
![]() |
0de4e2e55d | ||
![]() |
3ed60c3457 | ||
![]() |
7acdede38c | ||
![]() |
8c1902c1d3 | ||
![]() |
942e2fca75 | ||
![]() |
0d11c0a361 | ||
![]() |
d6d9a876a3 | ||
![]() |
eb1bafad2d | ||
![]() |
828114f5de | ||
![]() |
2e76250edc | ||
![]() |
86010962a2 | ||
![]() |
fb3a479147 | ||
![]() |
dcc7778815 | ||
![]() |
9ea491a301 | ||
![]() |
50503f7f66 | ||
![]() |
984e4f129d | ||
![]() |
ee33086824 | ||
![]() |
a1347def62 | ||
![]() |
4769c12336 | ||
![]() |
1b065c434f | ||
![]() |
ae4a678d77 | ||
![]() |
660d7b1815 | ||
![]() |
d85ff3ac8d | ||
![]() |
77bc359d8a | ||
![]() |
ced5c4b3ef | ||
![]() |
932d07b893 | ||
![]() |
76baed51e2 | ||
![]() |
e8312959c3 | ||
![]() |
1df75055df | ||
![]() |
cdf3d3a805 | ||
![]() |
29ef9634a4 | ||
![]() |
cf04d4d852 | ||
![]() |
0c6ad19600 | ||
![]() |
6f2a7951d5 | ||
![]() |
03fd79ad95 | ||
![]() |
6362718aa7 | ||
![]() |
cdf75cda24 | ||
![]() |
e1c1b1d4f6 | ||
![]() |
6fc8b42bcc | ||
![]() |
0dba23bcc6 | ||
![]() |
db11b54220 | ||
![]() |
df598efdc3 | ||
![]() |
d159004340 | ||
![]() |
591adafd6e | ||
![]() |
ca263b50d2 | ||
![]() |
e95a14ae89 | ||
![]() |
a3eeb38e5f | ||
![]() |
3a4588b45d | ||
![]() |
6ef5c47e98 | ||
![]() |
87392fccb6 | ||
![]() |
78438696ac | ||
![]() |
654f273ad9 | ||
![]() |
cb15fbaac5 | ||
![]() |
a8fb3f919d | ||
![]() |
044c853993 | ||
![]() |
b26d64646c | ||
![]() |
7ecfa6e810 | ||
![]() |
fe0b59ef65 | ||
![]() |
031d9a3b69 | ||
![]() |
58117b901a | ||
![]() |
29476e60ad | ||
![]() |
1a7b65fe1c | ||
![]() |
94976905d3 | ||
![]() |
0cf354942e | ||
![]() |
a6b611312b | ||
![]() |
bd32e61212 | ||
![]() |
5cd82e8581 | ||
![]() |
eea8511a6e | ||
![]() |
5032b4db93 | ||
![]() |
fc97b513c4 | ||
![]() |
9c92a19cde | ||
![]() |
0a7685d907 | ||
![]() |
20fc8f9a3a | ||
![]() |
1f13c45831 | ||
![]() |
e320523fc0 | ||
![]() |
b87970d4e2 | ||
![]() |
2ce5801925 | ||
![]() |
ca50bdf3bb | ||
![]() |
9eb6138852 | ||
![]() |
88ecd68eb8 | ||
![]() |
00bd7a997a | ||
![]() |
cb1d8ef8fb | ||
![]() |
7561bc0e2f | ||
![]() |
360b44e64e | ||
![]() |
adcd35967f | ||
![]() |
2400f7c45b | ||
![]() |
e186d034e5 | ||
![]() |
59b789d5ed | ||
![]() |
473f26d7a5 | ||
![]() |
43f8a3ebca | ||
![]() |
9a9c30dbb7 | ||
![]() |
a0e411d2e9 | ||
![]() |
ecc5b7686b | ||
![]() |
d095c65b23 | ||
![]() |
6b8bf29b20 | ||
![]() |
2cec689bf1 | ||
![]() |
44feea9894 | ||
![]() |
3fa8df4eaa | ||
![]() |
b9f1d461fb | ||
![]() |
7cedd980fb | ||
![]() |
55d42fc52c | ||
![]() |
15a2ffd7e6 | ||
![]() |
5f8a232bd5 | ||
![]() |
5dfd7a084b | ||
![]() |
ca24693dbb | ||
![]() |
ed72bcebcf | ||
![]() |
3749f72048 | ||
![]() |
d1735476cc | ||
![]() |
5dbea00562 | ||
![]() |
cc1e9d4d69 | ||
![]() |
d5e6bd2578 | ||
![]() |
60b33b1dd3 | ||
![]() |
561fd9e1e5 | ||
![]() |
91b6047f9a | ||
![]() |
5294d9081f | ||
![]() |
b81e5a27bf | ||
![]() |
8f0a169677 | ||
![]() |
ae525c870f | ||
![]() |
b9307b7b5d | ||
![]() |
e9bee3c114 | ||
![]() |
dc8df51e0f | ||
![]() |
cc64c0f493 | ||
![]() |
5a8fa69e4c | ||
![]() |
f33d890d7f | ||
![]() |
dd78a2f460 | ||
![]() |
5303d0cb41 | ||
![]() |
2053f045b2 | ||
![]() |
baf93bd788 | ||
![]() |
d8eebcfb6a | ||
![]() |
7f33bdc9ab | ||
![]() |
306e0e0421 | ||
![]() |
49d8eef29c | ||
![]() |
74fff5a2b1 | ||
![]() |
bdb8b6e814 | ||
![]() |
b0c92e64fa | ||
![]() |
4e4ad7099f | ||
![]() |
613a8b3822 | ||
![]() |
1533676bff | ||
![]() |
52be8c53eb | ||
![]() |
5f25e116a1 | ||
![]() |
d1720f89ae | ||
![]() |
f6082f12f2 | ||
![]() |
3d389e8d52 | ||
![]() |
28d1f4c983 | ||
![]() |
93fde86169 | ||
![]() |
769b9578fe | ||
![]() |
c27ccb8282 | ||
![]() |
d744fb8457 | ||
![]() |
c2f775e891 | ||
![]() |
617b1ea6d1 | ||
![]() |
fd970b3709 | ||
![]() |
c6de8daee9 | ||
![]() |
a297f61363 | ||
![]() |
96ca945263 | ||
![]() |
d4bca1c26f | ||
![]() |
516acadd4a | ||
![]() |
945eb6906a | ||
![]() |
34657540de | ||
![]() |
afe3fa8670 | ||
![]() |
753afad2fd | ||
![]() |
7755967a7a | ||
![]() |
cb75a5749f | ||
![]() |
0744e86922 | ||
![]() |
ddb68f7107 | ||
![]() |
4b59b273f5 | ||
![]() |
bb3bb1fd56 | ||
![]() |
593ebcddca | ||
![]() |
132ea048a5 | ||
![]() |
ddc57dc36a | ||
![]() |
77bfc4cb7a | ||
![]() |
43e7a5cd10 | ||
![]() |
3942105764 | ||
![]() |
945e29b5ed | ||
![]() |
1297be43bc | ||
![]() |
5a62e8f99e | ||
![]() |
018357b21e | ||
![]() |
d1ae0f5458 | ||
![]() |
d4f544173a | ||
![]() |
c2b106ea69 | ||
![]() |
003d1d6120 | ||
![]() |
039c390c66 | ||
![]() |
b974957bc9 | ||
![]() |
dd5aaf71d2 | ||
![]() |
c5a2872534 | ||
![]() |
1a1f23007b | ||
![]() |
9eaf10006b | ||
![]() |
6492a22cc9 | ||
![]() |
13dac88ded | ||
![]() |
1263e39fb3 | ||
![]() |
714d4a4d5b | ||
![]() |
1d7d3eea74 | ||
![]() |
3cab294122 | ||
![]() |
01305c5a78 | ||
![]() |
14a1cbe09c | ||
![]() |
533378b24d | ||
![]() |
a0f5d62c81 | ||
![]() |
84e46827ce | ||
![]() |
3c93e0aae7 | ||
![]() |
c3d18e4494 | ||
![]() |
75a5f27843 | ||
![]() |
58126ba6e7 | ||
![]() |
9f14a0f3f9 | ||
![]() |
f240413e2a | ||
![]() |
999068bdd9 | ||
![]() |
f7f4bdc4f3 | ||
![]() |
ed1f482707 | ||
![]() |
3dca6a4b23 | ||
![]() |
17b3334aea | ||
![]() |
77ba311bab | ||
![]() |
9da41db427 | ||
![]() |
a67bd384ec | ||
![]() |
ace365de38 | ||
![]() |
07bba314ec | ||
![]() |
126bd8cf81 | ||
![]() |
b5b2ea9b71 |
25 changed files with 5109 additions and 2448 deletions
57
.github/workflows/ci.yml
vendored
Normal file
57
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
on: [push, pull_request, create]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
env:
|
||||||
|
CARGO_PROFILE_TEST_BUILD_OVERRIDE_DEBUG: true
|
||||||
|
CARGO_PROFILE_dev_OPT_LEVEL: 0
|
||||||
|
RUSTFLAGS: ""
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
# https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
jdk: [11]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
- name: Install libdbus on Ubuntu
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libdbus-1-dev
|
||||||
|
- run: cargo test
|
||||||
|
- run: cargo build --release
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: mostr_${{ github.sha }}_${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
target/release/mostr
|
||||||
|
target/release/mostr.exe
|
||||||
|
#build-arm:
|
||||||
|
# runs-on: ${{ matrix.os }}
|
||||||
|
# if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
# strategy:
|
||||||
|
# matrix:
|
||||||
|
# os: [macos-latest-large]
|
||||||
|
# jdk: [11]
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
# - uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
# - run: cargo test --all-features
|
||||||
|
#release:
|
||||||
|
# needs: [build, build-arm]
|
||||||
|
# runs-on: ubuntu-latest
|
||||||
|
# if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/download-artifact@v4 # https://github.com/actions/download-artifact
|
||||||
|
# with:
|
||||||
|
# pattern: software-challenge-gui-${{ github.sha }}-*
|
||||||
|
# path: build
|
||||||
|
# merge-multiple: true
|
||||||
|
# - name: Release ${{ github.ref }}
|
||||||
|
# uses: softprops/action-gh-release@v1 # https://github.com/softprops/action-gh-release
|
||||||
|
# with:
|
||||||
|
# files: build/*.jar
|
||||||
|
# env:
|
||||||
|
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -1,6 +1,3 @@
|
||||||
/target
|
/target
|
||||||
/examples
|
/.idea
|
||||||
|
|
||||||
relays
|
|
||||||
keys
|
|
||||||
*.html
|
*.html
|
2285
Cargo.lock
generated
2285
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
35
Cargo.toml
35
Cargo.toml
|
@ -5,27 +5,38 @@ 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.3.0"
|
version = "0.9.2"
|
||||||
|
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]
|
||||||
xdg = "2.5"
|
nostr-sdk = { version = "0.38", features = ["nip49"] }
|
||||||
itertools = "0.12"
|
# Basics
|
||||||
|
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
regex = "1.11"
|
||||||
|
# System
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = "0.4"
|
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
colog = "1.3"
|
colog = "1.3"
|
||||||
colored = "2.1"
|
colored = "2.2"
|
||||||
parse_datetime = "0.5.0"
|
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "5364854" }
|
||||||
|
# OS-Specific Abstractions
|
||||||
|
keyring = "3"
|
||||||
|
directories = "5.0"
|
||||||
|
whoami = "1.5"
|
||||||
|
# slint = "1.8"
|
||||||
|
# Application Utils
|
||||||
|
itertools = "0.12"
|
||||||
|
chrono = "0.4"
|
||||||
|
parse_datetime = "0.5"
|
||||||
interim = { version = "0.1", features = ["chrono"] }
|
interim = { version = "0.1", features = ["chrono"] }
|
||||||
nostr-sdk = "0.34" # { git = "https://github.com/rust-nostr/nostr" }
|
|
||||||
tokio = { version = "1.37", features = ["rt", "rt-multi-thread", "macros"] }
|
|
||||||
regex = "1.10.5"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
chrono-english = "0.1"
|
mostr = { path = ".", default-features = false }
|
||||||
linefeed = "0.6"
|
|
||||||
rustyline = { version = "14.0", features = ["custom-bindings"] }
|
[features]
|
||||||
|
default = ["persistence"]
|
||||||
|
persistence = ["keyring/apple-native", "keyring/windows-native", "keyring/linux-native-sync-persistent", "keyring/crypto-rust"]
|
||||||
|
|
44
DESIGN.md
Normal file
44
DESIGN.md
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# 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.
|
279
README.md
279
README.md
|
@ -2,35 +2,118 @@
|
||||||
|
|
||||||
An immutable nested collaborative task manager, powered by nostr!
|
An immutable nested collaborative task manager, 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
|
||||||
|
|
||||||
First, start a nostr relay, such as
|
Install rust(up) and run a development build with:
|
||||||
- https://github.com/coracle-social/bucket for local development
|
|
||||||
- https://github.com/rnostr/rnostr for production use
|
|
||||||
|
|
||||||
Run development build with:
|
cargo run -- ARGS
|
||||||
|
|
||||||
cargo run
|
A `relay` list can be placed in a config file
|
||||||
|
|
||||||
A `relay` list and private `key` can be placed in config files
|
|
||||||
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
|
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
|
||||||
Currently, all relays are fetched and synced to,
|
Ideally any project with different collaborators has its own relay.
|
||||||
separation is planned -
|
|
||||||
ideally for any project with different collaborators,
|
|
||||||
an own relay will be used.
|
|
||||||
If not saved, mostr will ask for a relay url
|
If not saved, mostr will ask for a relay url
|
||||||
(entering none is fine too, but your data will not be persisted between sessions)
|
(entering none is fine too, but your data will not be persisted between sessions)
|
||||||
and a private key, alternatively generating one on the fly.
|
and a private key, alternatively generating one on the fly.
|
||||||
Both are currently saved in plain text to the above files.
|
The key is saved in the system keychain.
|
||||||
|
|
||||||
Install latest build:
|
Install latest build:
|
||||||
|
|
||||||
cargo install --path .
|
cargo install --path .
|
||||||
|
|
||||||
Creating a test task externally:
|
This one-liner can help you stay on the latest version
|
||||||
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
|
(optionally add a `cd` to your mostr-directory in front to use it anywhere):
|
||||||
|
|
||||||
To exit the application, press `Ctrl-D`.
|
git pull && cargo install --path . && mostr
|
||||||
|
|
||||||
|
To exit mostr, press `Ctrl-D`.
|
||||||
|
|
||||||
|
### Migrating
|
||||||
|
|
||||||
|
All data is stored on the relay.
|
||||||
|
To use mostr on a new device,
|
||||||
|
the only thing needed is your private key.
|
||||||
|
|
||||||
|
To export your password-encrypted key,
|
||||||
|
run mostr with the `--export` flag on the previous machine,
|
||||||
|
optionally deleting the key from the system keystore.
|
||||||
|
|
||||||
|
You can then import a password-encrypted key
|
||||||
|
using the `--import` flag.
|
||||||
|
|
||||||
|
To change your keypair on an existing machine,
|
||||||
|
simply delete the current one through the `export` command
|
||||||
|
and rerun mostr.
|
||||||
|
|
||||||
|
There is no harm in using mostr from multiple devices,
|
||||||
|
though there may be delays in updates if it is used in parallel.
|
||||||
|
For best user experience,
|
||||||
|
exit mostr on a device when you are done
|
||||||
|
to ensure all changes are propagated.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
### Command Syntax
|
||||||
|
|
||||||
|
Uppercased words are placeholders, brackets enclose optional arguments.
|
||||||
|
|
||||||
|
`TASK` creation syntax: `NAME #TAG *PRIO @ASSIGNEE # TAG1 TAG2 ...`
|
||||||
|
|
||||||
|
- `TASK` - create task
|
||||||
|
+ prefix with space if you want a task to start with a command character
|
||||||
|
+ paste text with newlines to create one task per line
|
||||||
|
- `.` - clear all filters
|
||||||
|
- `.TASK`
|
||||||
|
+ activate task by id
|
||||||
|
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
|
||||||
|
+ no match: create & activate task
|
||||||
|
- `.2` - set view depth to the given number (how many subtask levels to show, default is 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.
|
||||||
|
Append `@TIME` to any task creation or change command to record the action with the given time.
|
||||||
|
To add tags or edit the priority or assignee, make the change part of a comment or state update:
|
||||||
|
|
||||||
|
- `:[IND][PROP]` - add property column PROP at IND or end,
|
||||||
|
if it already exists remove property column PROP or IND; empty: list properties
|
||||||
|
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
|
||||||
|
- `([TIME]` - list tracked times or insert time-tracking with the specified offset (double to view all history)
|
||||||
|
such as `(20` (for 20:00), `(-1d`, `(-15 minutes`, `(yesterday 17:20`, `(in 2 fortnights`
|
||||||
|
- TBI: track whole interval in one with dash
|
||||||
|
- `)[TIME]` - stop time-tracking 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 (hide) current task until given time
|
||||||
|
- `,[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:
|
||||||
|
|
||||||
|
- `#TAG1 TAG2` - set tag filter
|
||||||
|
- `+TAG` - add tag filter (empty: list all used tags)
|
||||||
|
- `-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.
|
||||||
|
An active tag or status filter will also set that attribute for newly created tasks.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
|
@ -65,6 +148,10 @@ should be grouped with a tag instead.
|
||||||
Similarly for projects which are only sporadically worked on
|
Similarly for projects which are only sporadically worked on
|
||||||
when a specific task comes up, so they do not clutter the list.
|
when a specific task comes up, so they do not clutter the list.
|
||||||
|
|
||||||
|
### Task States
|
||||||
|
|
||||||
|
> TODO: Mark as Done vs Closed
|
||||||
|
|
||||||
### Collaboration
|
### Collaboration
|
||||||
|
|
||||||
Since everything in mostr is inherently immutable,
|
Since everything in mostr is inherently immutable,
|
||||||
|
@ -85,103 +172,84 @@ as you work.
|
||||||
|
|
||||||
The currently active task is automatically time-tracked.
|
The currently active task is automatically time-tracked.
|
||||||
To stop time-tracking completely, simply move to the root of all tasks.
|
To stop time-tracking completely, simply move to the root of all tasks.
|
||||||
|
Time-tracking by default recursively summarizes
|
||||||
|
|
||||||
## Reference
|
### Priorities
|
||||||
|
|
||||||
### Command Syntax
|
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:
|
||||||
|
|
||||||
`TASK` creation syntax: `NAME: TAG1 TAG2 ...`
|
* 1 Ideas / "Someday"
|
||||||
|
* 2 Later
|
||||||
|
* 3 Soon
|
||||||
|
* 4 Relevant
|
||||||
|
* 5 Important
|
||||||
|
* 9 DO NOW
|
||||||
|
|
||||||
- `TASK` - create task (prefix with space if you want a task to start with a command character)
|
Internally, when giving a single digit, a 0 is appended,
|
||||||
- `.` - clear filters
|
so that the default priorities increment in steps of 10.
|
||||||
- `.TASK`
|
So in case you need more than 10 priorities,
|
||||||
+ activate task by id
|
instead of stacking them on top,
|
||||||
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
|
you can granularly add them in between.
|
||||||
+ no match: create & activate task
|
For example, `12` is in between `1` and `2`
|
||||||
- `.2` - set view depth to the given number (how many subtask levels to show, default is 1)
|
which are equivalent to `10` and `20`,
|
||||||
- `/[TEXT]` - activate task or filter by smart-case substring match
|
not above `9` but above `09`!
|
||||||
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
|
|
||||||
- `|[TASK]` - (un)mark current task as procedure or create a sibling task depending on the current one and move up
|
|
||||||
|
|
||||||
Dot or slash can be repeated to move to parent tasks before acting.
|
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`.
|
||||||
|
|
||||||
- `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (
|
### Quick Access
|
||||||
1-indexed), empty: list properties
|
|
||||||
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
|
|
||||||
- `([TIME]` - list tracked times or insert timetracking with the specified offset
|
|
||||||
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
|
|
||||||
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
|
|
||||||
- `>[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)
|
|
||||||
- `,[TEXT]` - list notes or add text note (comment / description)
|
|
||||||
- TBI: `*[INT]` - set priority - can also be used in task creation, with any digit
|
|
||||||
- TBI: `;[TEXT]` - list comments or comment on task
|
|
||||||
- TBI: show status history and creation with attribution
|
|
||||||
- `&` - undo last action (moving in place or upwards confirms pending actions)
|
|
||||||
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
|
|
||||||
|
|
||||||
Property Filters:
|
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...
|
||||||
|
|
||||||
- `#TAG1 TAG2` - set tag filter (empty: list all used tags)
|
- temporary task with subtasks (especially handy for progression)
|
||||||
- `+TAG` - add tag filter
|
- Filter by recently created
|
||||||
- `-TAG` - remove tag filters by prefix
|
- Pin to bookmarks
|
||||||
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
|
- high priority
|
||||||
- `@AUTHOR` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
|
|
||||||
- TBI: `**INT` - filter by priority
|
|
||||||
- TBI: Filter by time
|
|
||||||
|
|
||||||
Status descriptions can be used for example for Kanban columns or review flows.
|
|
||||||
An active tag or status filter will also set that attribute for newly created tasks.
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- TBI = To Be Implemented
|
- TBI = To Be Implemented
|
||||||
- `. TASK` - create and enter a new task even if the name matches an existing one
|
- `. TASK` - create and enter a new task even if the name matches an existing one
|
||||||
|
|
||||||
## Nostr reference
|
# Development and Contributions
|
||||||
|
|
||||||
Mostr mainly uses the following NIPs:
|
This Project follows the [Kull Collaboration Convention](https://kull.jfischer.org/),
|
||||||
|
especially the commit message format.
|
||||||
|
Currently a separate dev branch is maintained because users regularly receive updates via the main branch.
|
||||||
|
Once proper packaging is in place, this can be simplified.
|
||||||
|
|
||||||
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
|
## Local Development Tools
|
||||||
- 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
|
Start a nostr relay, such as
|
||||||
- Kind 31922 for GANTT, since it has only Date
|
- https://github.com/coracle-social/bucket for local development
|
||||||
- Kind 31923 for Calendar, since it has a time
|
- https://github.com/rnostr/rnostr for production use
|
||||||
|
|
||||||
|
To create a test task externally:
|
||||||
|
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
|
||||||
|
|
||||||
## Plans
|
## Plans
|
||||||
|
|
||||||
|
- Handle event sending rejections (e.g. permissions)
|
||||||
- Local Database Cache, Negentropy Reconciliation
|
- Local Database Cache, Negentropy Reconciliation
|
||||||
-> Offline Use!
|
-> Offline Use!
|
||||||
- Scheduling
|
|
||||||
- Remove status filter when moving up?
|
- Remove status filter when moving up?
|
||||||
- Task markdown support? - colored
|
- Task markdown support? - colored
|
||||||
- Time tracking: Ability to postpone task and add planned timestamps (calendar entry)
|
- Calendar Events - make future time-tracking editable -> parametrised replaceable events
|
||||||
- Parse Hashtag tags from task name
|
|
||||||
- Unified Filter object
|
|
||||||
-> include subtasks of matched tasks
|
|
||||||
- Speedup: Offline caching & Expiry (no need to fetch potential years of history)
|
- Speedup: Offline caching & Expiry (no need to fetch potential years of history)
|
||||||
+ Fetch most recent tasks first
|
+ Fetch most recent tasks first
|
||||||
+ Relay: compress tracked time for old tasks, filter closed tasks
|
+ Relay: compress tracked time for old tasks, filter closed tasks
|
||||||
+ Relay: filter out task status updates within few seconds, also on client side
|
+ Relay: filter out task status updates within few seconds, also on client side
|
||||||
|
|
||||||
### Fixes
|
### Commands
|
||||||
|
|
||||||
- New Relay does not load until next is added
|
Open Command characters: `_^\=$%~'"`, `{}[]`
|
||||||
https://github.com/rust-nostr/nostr/issues/533
|
|
||||||
- Handle event sending rejections (e.g. permissions)
|
|
||||||
- Recursive filter handling
|
|
||||||
|
|
||||||
### Command
|
|
||||||
|
|
||||||
- Open Command characters: `_^\=$%~'"`, `{}[]`
|
|
||||||
- Remove colon from task creation syntax
|
|
||||||
- reassign undo to `&` and use `@` for people
|
|
||||||
|
|
||||||
### Conceptual
|
### Conceptual
|
||||||
|
|
||||||
|
@ -192,7 +260,7 @@ Suggestions welcome!
|
||||||
- Queueing tasks
|
- Queueing tasks
|
||||||
- Allow adding new parent via description?
|
- Allow adding new parent via description?
|
||||||
- Special commands: help, exit, tutorial, change log level
|
- Special commands: help, exit, tutorial, change log level
|
||||||
- Duplicate task (subtasks? timetracking?)
|
- Duplicate task (subtasks? time-tracking?)
|
||||||
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
|
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
|
||||||
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
|
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
|
||||||
- Templates
|
- Templates
|
||||||
|
@ -202,16 +270,28 @@ Suggestions welcome!
|
||||||
+ Subtask progress immediate/all/leafs
|
+ Subtask progress immediate/all/leafs
|
||||||
+ path full / leaf / top
|
+ path full / leaf / top
|
||||||
|
|
||||||
### Interfaces
|
### Interfaces & Integrations
|
||||||
|
|
||||||
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
|
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
|
||||||
- Kanban, GANTT, Calendar
|
- Kanban, GANTT, Calendar
|
||||||
- Web Interface, Messenger integrations
|
- n8n node
|
||||||
|
- Webcal Feed: Scheduled (planning) / Tracked (events, time-tracking) with args for how far back/forward
|
||||||
|
|
||||||
## Exemplary Workflows
|
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/
|
||||||
|
|
||||||
|
### Config Files
|
||||||
|
|
||||||
|
- format strings
|
||||||
|
- thresholds: auto-send message, time-tracking overview interval and count
|
||||||
|
- global and per-relay: username, key location, tag mappings (i.e. server implies pc, home implies phys) -> also get from relay
|
||||||
|
|
||||||
|
## Exemplary Workflows - User Stories
|
||||||
|
|
||||||
- Freelancer
|
- Freelancer
|
||||||
- Family Chore management
|
- Family Chore Management
|
||||||
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
|
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
|
||||||
+ Permissions via status or assignment (reassignment?)
|
+ Permissions via status or assignment (reassignment?)
|
||||||
+ Tasks can be blocked while having a status (e.g. kanban column)
|
+ Tasks can be blocked while having a status (e.g. kanban column)
|
||||||
|
@ -219,6 +299,29 @@ Suggestions welcome!
|
||||||
+ Schedule for multiple people
|
+ Schedule for multiple people
|
||||||
- Tracking Daily Routines / Habits
|
- 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
|
### Contexts
|
||||||
|
|
||||||
A context is a custom set of filters such as status, tags, assignee
|
A context is a custom set of filters such as status, tags, assignee
|
||||||
|
@ -228,7 +331,7 @@ since they will automatically take on that context.
|
||||||
By automating these contexts based on triggers, scripts or time,
|
By automating these contexts based on triggers, scripts or time,
|
||||||
relevant tasks can be surfaced automatically.
|
relevant tasks can be surfaced automatically.
|
||||||
|
|
||||||
#### Example
|
#### Vision of Work-Life-Balance for Freelancer
|
||||||
|
|
||||||
In the morning, your groggy brain is good at divergent thinking,
|
In the morning, your groggy brain is good at divergent thinking,
|
||||||
and you like to do sports in the morning.
|
and you like to do sports in the morning.
|
||||||
|
|
22
examples/question.rs
Normal file
22
examples/question.rs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let mut map: HashMap<usize, String> = HashMap::new();
|
||||||
|
let add_string = |map: &mut HashMap<usize, String>, string: String| {
|
||||||
|
map.insert(string.len(), string);
|
||||||
|
};
|
||||||
|
add_string(&mut map, "hi".to_string());
|
||||||
|
add_string(&mut map, "ho".to_string());
|
||||||
|
map.add_string("hi".to_string());
|
||||||
|
map.add_string("ho".to_string());
|
||||||
|
map.get(&1);
|
||||||
|
}
|
||||||
|
|
||||||
|
trait InsertString {
|
||||||
|
fn add_string(&mut self, event: String);
|
||||||
|
}
|
||||||
|
impl InsertString for HashMap<usize, String> {
|
||||||
|
fn add_string(&mut self, event: String) {
|
||||||
|
self.insert(event.len(), event);
|
||||||
|
}
|
||||||
|
}
|
24
examples/relay-test-0_34.rs
Normal file
24
examples/relay-test-0_34.rs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
//tracing_subscriber::fmt::init();
|
||||||
|
let client = Client::new(Keys::generate());
|
||||||
|
let result = client.subscribe(vec![Filter::new()], None).await;
|
||||||
|
println!("subscribe: {:?}", result);
|
||||||
|
let result = client.add_relay("ws://localhost:4736").await;
|
||||||
|
println!("add relay: {:?}", result);
|
||||||
|
client.connect().await;
|
||||||
|
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
let _thread = tokio::spawn(async move {
|
||||||
|
client.send_event_builder(EventBuilder::new(Kind::TextNote, "test")).await;
|
||||||
|
tokio::time::sleep(Duration::from_secs(20)).await;
|
||||||
|
});
|
||||||
|
while let Ok(notification) = notifications.recv().await {
|
||||||
|
if let RelayPoolNotification::Event { event, .. } = notification {
|
||||||
|
println!("At {} found {} kind {} content \"{}\"", event.created_at, event.id, event.kind, event.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
44
examples/relay-test.rs
Normal file
44
examples/relay-test.rs
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
//tracing_subscriber::fmt::init();
|
||||||
|
let client = Client::new(Keys::generate());
|
||||||
|
//let result = client.subscribe(vec![Filter::new()], None).await;
|
||||||
|
//println!("{:?}", result);
|
||||||
|
let mut notifications = client.notifications();
|
||||||
|
|
||||||
|
let result = client.add_relay("ws://localhost:3333").await;
|
||||||
|
println!("{:?}", result);
|
||||||
|
let result = client.connect_relay("ws://localhost:3333").await;
|
||||||
|
println!("{:?}", result);
|
||||||
|
|
||||||
|
//let _thread = tokio::spawn(async move {
|
||||||
|
// let result = client.add_relay("ws://localhost:4736").await;
|
||||||
|
// println!("{:?}", result);
|
||||||
|
// let result = client.connect_relay("ws://localhost:4736").await;
|
||||||
|
// println!("{:?}", result);
|
||||||
|
|
||||||
|
// // Block b
|
||||||
|
// //let result = client.add_relay("ws://localhost:54736").await;
|
||||||
|
// //println!("{:?}", result);
|
||||||
|
// //let result = client.connect_relay("ws://localhost:54736").await;
|
||||||
|
// //println!("{:?}", result);
|
||||||
|
|
||||||
|
// tokio::time::sleep(Duration::from_secs(20)).await;
|
||||||
|
//});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match notifications.recv().await {
|
||||||
|
Ok(notification) => {
|
||||||
|
if let RelayPoolNotification::Event { event, .. } = notification {
|
||||||
|
println!("At {} found {} kind {} content \"{}\"", event.created_at, event.id, event.kind, event.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Aborting due to {:?}", e);
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
examples/rustyline.rs
Normal file
50
examples/rustyline.rs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
use rustyline::error::ReadlineError;
|
||||||
|
use rustyline::{Cmd, ConditionalEventHandler, DefaultEditor, Event, EventContext, EventHandler, KeyEvent, Movement, RepeatCount, Result};
|
||||||
|
|
||||||
|
struct CtrlCHandler;
|
||||||
|
impl ConditionalEventHandler for CtrlCHandler {
|
||||||
|
fn handle(&self, evt: &Event, n: RepeatCount, positive: bool, ctx: &EventContext) -> Option<Cmd> {
|
||||||
|
Some(if !ctx.line().is_empty() {
|
||||||
|
Cmd::Kill(Movement::WholeLine)
|
||||||
|
} else {
|
||||||
|
Cmd::Interrupt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// `()` can be used when no completer is required
|
||||||
|
let mut rl = DefaultEditor::new()?;
|
||||||
|
|
||||||
|
rl.bind_sequence(
|
||||||
|
KeyEvent::ctrl('c'),
|
||||||
|
EventHandler::Conditional(Box::from(CtrlCHandler)));
|
||||||
|
#[cfg(feature = "with-file-history")]
|
||||||
|
if rl.load_history("history.txt").is_err() {
|
||||||
|
println!("No previous history.");
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let readline = rl.readline(">> ");
|
||||||
|
match readline {
|
||||||
|
Ok(line) => {
|
||||||
|
rl.add_history_entry(line.as_str());
|
||||||
|
println!("Line: {}", line);
|
||||||
|
},
|
||||||
|
Err(ReadlineError::Interrupted) => {
|
||||||
|
println!("CTRL-C");
|
||||||
|
break
|
||||||
|
},
|
||||||
|
Err(ReadlineError::Eof) => {
|
||||||
|
println!("CTRL-D");
|
||||||
|
break
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
println!("Error: {:?}", err);
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "with-file-history")]
|
||||||
|
rl.save_history("history.txt");
|
||||||
|
Ok(())
|
||||||
|
}
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.84.0"
|
99
src/event_sender.rs
Normal file
99
src/event_sender.rs
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
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 {
|
||||||
|
NewRelay(RelayUrl),
|
||||||
|
SendTask(RelayUrl, Event),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Events = Vec<Event>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct EventSender {
|
||||||
|
pub(crate) url: Option<RelayUrl>,
|
||||||
|
pub(crate) tx: Sender<MostrMessage>,
|
||||||
|
pub(crate) keys: Keys,
|
||||||
|
pub(crate) queue: RefCell<Events>,
|
||||||
|
}
|
||||||
|
impl EventSender {
|
||||||
|
pub(crate) fn from(url: Option<RelayUrl>, 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 event = event_builder.sign_with_keys(&self.keys)?;
|
||||||
|
|
||||||
|
let time = event.created_at;
|
||||||
|
{
|
||||||
|
// Always flush if any event is newer or more than a minute older than the current event
|
||||||
|
let borrow = self.queue.borrow();
|
||||||
|
if borrow
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.created_at < time.sub(UNDO_DELAY) || e.created_at > time)
|
||||||
|
{
|
||||||
|
drop(borrow);
|
||||||
|
debug!("Flushing event queue because it is offset from the current event");
|
||||||
|
self.force_flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut queue = self.queue.borrow_mut();
|
||||||
|
if event.kind == TRACKING_KIND {
|
||||||
|
// Remove extraneous movements if tracking event is not at a custom time
|
||||||
|
queue.retain(|e| e.kind != TRACKING_KIND);
|
||||||
|
}
|
||||||
|
queue.push(event.clone());
|
||||||
|
Ok(event)
|
||||||
|
}
|
||||||
|
/// Sends all pending events
|
||||||
|
pub(crate) fn force_flush(&self) {
|
||||||
|
debug!("Flushing {} events from queue", self.queue.borrow().len());
|
||||||
|
let values = self.clear();
|
||||||
|
self.url.as_ref().map(|url| {
|
||||||
|
values.into_iter()
|
||||||
|
.find_map(|event| self.tx.try_send(MostrMessage::SendTask(url.clone(), event)).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);
|
||||||
|
}
|
||||||
|
}
|
99
src/hashtag.rs
Normal file
99
src/hashtag.rs
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
use nostr_sdk::{Alphabet, Tag};
|
||||||
|
use std::cmp::Ordering;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
pub fn is_hashtag(tag: &Tag) -> bool {
|
||||||
|
tag.single_letter_tag()
|
||||||
|
.is_some_and(|letter| letter.character == Alphabet::T)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This exists so that Hashtags can easily be matched without caring about case
|
||||||
|
/// but displayed in their original case
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Hashtag {
|
||||||
|
value: String,
|
||||||
|
lowercased: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hashtag {
|
||||||
|
pub fn contains(&self, token: &str) -> bool {
|
||||||
|
self.lowercased.contains(&token.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
pub fn matches(&self, token: &str) -> bool {
|
||||||
|
token.contains(&self.lowercased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Hashtag {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hash for Hashtag {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
state.write(self.lowercased.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for Hashtag {}
|
||||||
|
impl PartialEq<Self> for Hashtag {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.lowercased == other.lowercased
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl TryFrom<&Tag> for Hashtag {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: &Tag) -> Result<Self, Self::Error> {
|
||||||
|
value.content().take_if(|_| is_hashtag(value))
|
||||||
|
.map(|s| Hashtag::from(s))
|
||||||
|
.ok_or_else(|| "Tag is not a Hashtag".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<&str> for Hashtag {
|
||||||
|
fn from(value: &str) -> Self {
|
||||||
|
let val = value.trim().to_string();
|
||||||
|
Hashtag {
|
||||||
|
lowercased: val.to_ascii_lowercase(),
|
||||||
|
value: val,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<&Hashtag> for Tag {
|
||||||
|
fn from(value: &Hashtag) -> Self {
|
||||||
|
Tag::hashtag(&value.lowercased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Hashtag {
|
||||||
|
fn cmp(&self, other: &Self) -> Ordering {
|
||||||
|
self.lowercased.cmp(&other.lowercased)
|
||||||
|
// Wanted to do this so lowercase tags are preferred,
|
||||||
|
// but is technically undefined behaviour
|
||||||
|
// because it deviates from Eq implementation
|
||||||
|
//match {
|
||||||
|
// Ordering::Equal => self.0.cmp(&other.0),
|
||||||
|
// other => other,
|
||||||
|
//}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PartialOrd for Hashtag {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.lowercased.cmp(&other.lowercased))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_hashtag() {
|
||||||
|
assert_eq!("yeah", "YeaH".to_ascii_lowercase());
|
||||||
|
assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal);
|
||||||
|
|
||||||
|
use itertools::Itertools;
|
||||||
|
let strings = vec!["yeah", "YeaH"];
|
||||||
|
let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec();
|
||||||
|
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
|
||||||
|
tags.sort_unstable();
|
||||||
|
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
|
||||||
|
}
|
212
src/helpers.rs
212
src/helpers.rs
|
@ -1,59 +1,107 @@
|
||||||
use std::fmt::Display;
|
|
||||||
use std::io::{stdin, stdout, Write};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
|
|
||||||
use chrono::LocalResult::Single;
|
use chrono::LocalResult::Single;
|
||||||
|
use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc};
|
||||||
use log::{debug, error, info, trace, warn};
|
use log::{debug, error, info, trace, warn};
|
||||||
use nostr_sdk::Timestamp;
|
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> {
|
pub fn some_non_empty(str: &str) -> Option<String> {
|
||||||
if str.is_empty() { None } else { Some(str.to_string()) }
|
if str.is_empty() { None } else { Some(str.to_string()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO as macro so that log comes from appropriate module
|
pub fn trim_start_count(str: &str, char: char) -> (&str, usize) {
|
||||||
pub fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> {
|
let len = str.len();
|
||||||
match result {
|
let result = str.trim_start_matches(char);
|
||||||
Ok(value) => Some(value),
|
let dots = len - result.len();
|
||||||
Err(error) => {
|
(result, dots)
|
||||||
warn!("{}", error);
|
}
|
||||||
None
|
|
||||||
}
|
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prompt(prompt: &str) -> Option<String> {
|
|
||||||
print!("{} ", prompt);
|
/// Parses the hour optionally with minute from a plain number in a String,
|
||||||
stdout().flush().unwrap();
|
/// with max of max_future hours into the future.
|
||||||
match stdin().lines().next() {
|
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
|
||||||
Some(Ok(line)) => Some(line),
|
parse_hour_after(str, Local::now() - TimeDelta::hours(24 - max_future))
|
||||||
_ => None,
|
}
|
||||||
|
|
||||||
|
/// Parses the hour optionally with minute from a plain number in a String.
|
||||||
|
pub fn parse_hour_after<T: TimeZone>(str: &str, after: DateTime<T>) -> Option<DateTime<T>> {
|
||||||
|
str.parse::<u32>().ok().and_then(|number| {
|
||||||
|
#[allow(deprecated)]
|
||||||
|
after.date().and_hms_opt(
|
||||||
|
if str.len() > 2 { number / 100 } else { number },
|
||||||
|
if str.len() > 2 { number % 100 } else { 0 },
|
||||||
|
0,
|
||||||
|
).map(|time| {
|
||||||
|
if time < after {
|
||||||
|
time + TimeDelta::days(1)
|
||||||
|
} else {
|
||||||
|
time
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
|
pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
|
||||||
|
parse_date_with_ref(str, Local::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_date_with_ref(str: &str, reference: DateTime<Local>) -> Option<DateTime<Utc>> {
|
||||||
// Using two libraries for better exhaustiveness, see https://github.com/uutils/parse_datetime/issues/84
|
// 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) {
|
match interim::parse_date_string(str, reference, interim::Dialect::Us) {
|
||||||
Ok(date) => Some(date.to_utc()),
|
Ok(date) => Some(date.to_utc()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
match parse_datetime::parse_datetime_at_date(Local::now(), str) {
|
match parse_datetime::parse_datetime_at_date(reference, str) {
|
||||||
Ok(date) => Some(date.to_utc()),
|
Ok(date) => Some(date.to_utc()),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
warn!("Could not parse date from {str}: {e}");
|
warn!("Could not parse date from \"{str}\": {e}");
|
||||||
None
|
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()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
|
/// Turn a human-readable relative timestamp into a nostr Timestamp.
|
||||||
|
/// - Plain number as hour after given date, if none 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, after: Option<DateTime<Local>>) -> Option<Timestamp> {
|
||||||
|
if let Some(num) = parse_hour_after(str, after.unwrap_or(Local::now() - TimeDelta::hours(18))) {
|
||||||
|
return Some(num.to_timestamp());
|
||||||
|
}
|
||||||
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
|
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
|
||||||
if let Ok(num) = stripped.parse::<i64>() {
|
if let Ok(num) = stripped.parse::<i64>() {
|
||||||
|
// Complication needed because timestamp can only add u64, but we also want reverse
|
||||||
return Some(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
|
return Some(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
|
||||||
}
|
}
|
||||||
parse_date(str).and_then(|time| {
|
parse_date(str).and_then(|time| {
|
||||||
if time.timestamp() > 0 {
|
let stamp = time.to_utc().timestamp();
|
||||||
Some(Timestamp::from(time.timestamp() as u64))
|
if stamp > 0 {
|
||||||
|
Some(Timestamp::from(stamp as u64))
|
||||||
} else {
|
} else {
|
||||||
warn!("Can only track times after 1970!");
|
warn!("Can only track times after 1970!");
|
||||||
None
|
None
|
||||||
|
@ -61,41 +109,109 @@ pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// For use in format strings but not possible, so need global find-replace
|
/// Format DateTime easily comprehensible for human but unambiguous.
|
||||||
pub const MAX_TIMESTAMP_WIDTH: u8 = 15;
|
/// Length may vary.
|
||||||
/// Format nostr Timestamp relative to local time
|
pub fn format_datetime_relative(time: DateTime<Local>) -> String {
|
||||||
/// with optional day specifier or full date depending on distance to today
|
|
||||||
pub fn relative_datetimestamp(stamp: &Timestamp) -> String {
|
|
||||||
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
|
||||||
Single(time) => {
|
|
||||||
let date = time.date_naive();
|
let date = time.date_naive();
|
||||||
let prefix = match Local::now()
|
let prefix =
|
||||||
|
match Local::now()
|
||||||
.date_naive()
|
.date_naive()
|
||||||
.signed_duration_since(date)
|
.signed_duration_since(date)
|
||||||
.num_days()
|
.num_days() {
|
||||||
{
|
|
||||||
-1 => "tomorrow ".into(),
|
-1 => "tomorrow ".into(),
|
||||||
0 => "".into(),
|
0 => "".into(),
|
||||||
1 => "yesterday ".into(),
|
1 => "yesterday ".into(),
|
||||||
2..=6 => date.format("last %a ").to_string(),
|
//-3..=3 => date.format("%a ").to_string(),
|
||||||
_ => date.format("%y-%m-%d ").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!("{}{}", prefix, time.format("%H:%M"))
|
||||||
}
|
}
|
||||||
_ => stamp.to_human_datetime(),
|
|
||||||
|
/// 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,
|
||||||
|
{
|
||||||
|
Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0).earliest()
|
||||||
|
.map_or_else(|| stamp.to_human_datetime().to_string(), formatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format nostr timestamp with seconds precision.
|
||||||
|
pub fn format_timestamp_full(stamp: &Timestamp) -> String {
|
||||||
|
format_timestamp(stamp, "%y-%m-%d %a %H:%M:%S")
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a nostr timestamp in a sensible comprehensive format
|
mod test {
|
||||||
pub fn local_datetimestamp(stamp: &Timestamp) -> String {
|
use super::*;
|
||||||
format_stamp(stamp, "%y-%m-%d %a %H:%M")
|
use chrono::{FixedOffset, NaiveDate, Timelike};
|
||||||
}
|
use interim::datetime::DateTime;
|
||||||
|
|
||||||
/// Format a nostr timestamp with the given format
|
#[test]
|
||||||
pub fn format_stamp(stamp: &Timestamp, format: &str) -> String {
|
fn parse_hours() {
|
||||||
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
let now = Local::now();
|
||||||
Single(time) => time.format(format).to_string(),
|
#[allow(deprecated)]
|
||||||
_ => stamp.to_human_datetime(),
|
let date = now.date();
|
||||||
|
if now.hour() > 2 {
|
||||||
|
assert_eq!(
|
||||||
|
parse_hour("23", 22).unwrap(),
|
||||||
|
date.and_hms_opt(23, 0, 0).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if now.hour() < 22 {
|
||||||
|
assert_eq!(
|
||||||
|
parse_hour("02", 2).unwrap(),
|
||||||
|
date.and_hms_opt(2, 0, 0).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_hour("2301", 1).unwrap(),
|
||||||
|
(date - TimeDelta::days(1)).and_hms_opt(23, 01, 0).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let date = NaiveDate::from_ymd_opt(2020, 10, 10).unwrap();
|
||||||
|
let time = Utc.from_utc_datetime(
|
||||||
|
&date.and_hms_opt(10, 1,0).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(parse_hour_after("2201", time).unwrap(), Utc.from_utc_datetime(&date.and_hms_opt(22, 1, 0).unwrap()));
|
||||||
|
assert_eq!(parse_hour_after("10", time).unwrap(), Utc.from_utc_datetime(&(date + TimeDelta::days(1)).and_hms_opt(10, 0, 0).unwrap()));
|
||||||
|
|
||||||
|
// TODO test timezone offset issues
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timezone() {
|
||||||
|
assert_eq!(
|
||||||
|
FixedOffset::east_opt(7200).unwrap().timestamp_millis_opt(1000).unwrap().time(),
|
||||||
|
NaiveTime::from_hms_opt(2, 0, 1).unwrap()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
185
src/kinds.rs
185
src/kinds.rs
|
@ -1,30 +1,34 @@
|
||||||
|
use crate::task::MARKER_PARENT;
|
||||||
|
use crate::tasks::NostrUsers;
|
||||||
|
use crate::tasks::HIGH_PRIO;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use log::info;
|
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard};
|
||||||
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard};
|
use std::borrow::Cow;
|
||||||
use nostr_sdk::TagStandard::Hashtag;
|
|
||||||
|
|
||||||
use crate::task::{MARKER_PARENT, State};
|
pub const TASK_KIND: Kind = Kind::GitIssue;
|
||||||
|
pub const PROCEDURE_KIND_ID: u16 = 1639;
|
||||||
pub const METADATA_KIND: u16 = 0;
|
pub const PROCEDURE_KIND: Kind = Kind::Regular(PROCEDURE_KIND_ID);
|
||||||
pub const NOTE_KIND: u16 = 1;
|
pub const TRACKING_KIND: Kind = Kind::Regular(1650);
|
||||||
pub const TASK_KIND: u16 = 1621;
|
pub const BASIC_KINDS: [Kind; 4] = [
|
||||||
pub const TRACKING_KIND: u16 = 1650;
|
Kind::Metadata,
|
||||||
pub const KINDS: [u16; 3] = [
|
Kind::TextNote,
|
||||||
METADATA_KIND,
|
|
||||||
NOTE_KIND,
|
|
||||||
TASK_KIND,
|
TASK_KIND,
|
||||||
|
Kind::Bookmarks,
|
||||||
];
|
];
|
||||||
pub const PROP_KINDS: [u16; 6] = [
|
pub const PROP_KINDS: [Kind; 6] = [
|
||||||
TRACKING_KIND,
|
TRACKING_KIND,
|
||||||
State::Open as u16,
|
Kind::GitStatusOpen,
|
||||||
State::Done as u16,
|
Kind::GitStatusApplied,
|
||||||
State::Closed as u16,
|
Kind::GitStatusClosed,
|
||||||
State::Pending as u16,
|
Kind::GitStatusDraft,
|
||||||
State::Procedure as u16
|
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.
|
/// Helper for available properties.
|
||||||
/// TODO: use formatting - bold / heading / italics - and generate from code
|
|
||||||
pub const PROPERTY_COLUMNS: &str =
|
pub const PROPERTY_COLUMNS: &str =
|
||||||
"# Available Properties
|
"# Available Properties
|
||||||
Immutable:
|
Immutable:
|
||||||
|
@ -38,10 +42,11 @@ Task:
|
||||||
- `hashtags` - list of hashtags set for the task
|
- `hashtags` - list of hashtags set for the task
|
||||||
- `tags` - values of all nostr tags associated with the event, except event tags
|
- `tags` - values of all nostr tags associated with the event, except event tags
|
||||||
- `desc` - last note on the task
|
- `desc` - last note on the task
|
||||||
- `description` - accumulated notes on the task
|
- `description` - all notes on the task
|
||||||
- `time` - time tracked on this task by you
|
- `time` - time tracked on this task by you
|
||||||
Utilities:
|
Utilities:
|
||||||
- `state` - indicator of current progress
|
- `state` - indicator of current progress
|
||||||
|
- `owner` - author or task assignee
|
||||||
- `rtime` - time tracked on this tasks and its subtree by everyone
|
- `rtime` - time tracked on this tasks and its subtree by everyone
|
||||||
- `progress` - recursive subtask completion in percent
|
- `progress` - recursive subtask completion in percent
|
||||||
- `subtasks` - how many direct subtasks are complete
|
- `subtasks` - how many direct subtasks are complete
|
||||||
|
@ -50,75 +55,123 @@ Utilities:
|
||||||
- TBI `depends` - list all tasks this task depends on before it becomes actionable
|
- TBI `depends` - list all tasks this task depends on before it becomes actionable
|
||||||
Debugging: `kind`, `pubkey`, `props`, `alltags`, `descriptions`";
|
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
|
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item=EventId>,
|
I: IntoIterator<Item=EventId>,
|
||||||
{
|
{
|
||||||
EventBuilder::new(
|
EventBuilder::new(Kind::from(TRACKING_KIND), "")
|
||||||
Kind::from(TRACKING_KIND),
|
.tags(id.into_iter().map(Tag::event))
|
||||||
"",
|
|
||||||
id.into_iter().map(|id| Tag::event(id)),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a task with informational output and optional labeled kind
|
/// Formats and joins the tags with commata
|
||||||
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
pub fn join_tags<'a, T>(tags: T) -> String
|
||||||
info!("Created {}task \"{name}\" with tags [{}]",
|
where
|
||||||
kind.map(|k| k.0).unwrap_or_default(),
|
T: IntoIterator<Item=&'a Tag>,
|
||||||
tags.iter().map(|tag| format_tag(tag)).join(", "));
|
{
|
||||||
EventBuilder::new(kind.map(|k| k.1).unwrap_or(Kind::from(TASK_KIND)), name, tags)
|
tags.into_iter().map(format_tag).join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_prop(
|
/// Return Hashtags embedded in the string.
|
||||||
kind: Kind,
|
pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
|
||||||
comment: &str,
|
input.split_ascii_whitespace()
|
||||||
id: EventId,
|
.filter(|s| s.starts_with('#'))
|
||||||
) -> EventBuilder {
|
.map(|s| s.trim_start_matches('#'))
|
||||||
EventBuilder::new(
|
.map(to_hashtag_tag)
|
||||||
kind,
|
|
||||||
comment,
|
|
||||||
vec![Tag::event(id)],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expects sanitized input
|
/// Extracts everything after a " # " as a list of tags
|
||||||
pub(crate) fn extract_tags(input: &str) -> (&str, Vec<Tag>) {
|
/// as well as various embedded tags.
|
||||||
match input.split_once(": ") {
|
///
|
||||||
None => (input, vec![]),
|
/// Expects sanitized input.
|
||||||
Some(s) => {
|
pub(crate) fn extract_tags(input: &str, users: &NostrUsers) -> (String, Vec<Tag>) {
|
||||||
let tags = s
|
let words = input.split_ascii_whitespace();
|
||||||
.1
|
let mut tags = Vec::with_capacity(4);
|
||||||
.split_ascii_whitespace()
|
let result = words.filter(|s| {
|
||||||
.map(|t| Hashtag(t.to_string()).into())
|
if s.starts_with('@') {
|
||||||
.collect();
|
if let Ok(key) = PublicKey::parse(&s[1..]) {
|
||||||
(s.0, tags)
|
tags.push(Tag::public_key(key));
|
||||||
|
return false;
|
||||||
|
} else if let Some((key, _)) = users.find_user(&s[1..]) {
|
||||||
|
tags.push(Tag::public_key(*key));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if s.starts_with('*') {
|
||||||
|
if s.len() == 1 {
|
||||||
|
tags.push(to_prio_tag(HIGH_PRIO));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Ok(num) = s[1..].parse::<Prio>() {
|
||||||
|
tags.push(to_prio_tag(num * (if s.len() > 2 { 1 } else { 10 })));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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_tag(&s)))
|
||||||
|
.chain(tags)
|
||||||
|
.collect_vec();
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
|
(main, tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_tag(tag: &Tag) -> String {
|
pub fn to_hashtag_tag(tag: &str) -> Tag {
|
||||||
|
TagStandard::Hashtag(tag.to_string()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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);
|
||||||
|
}
|
||||||
|
format_tag_basic(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_tag_basic(tag: &Tag) -> String {
|
||||||
match tag.as_standardized() {
|
match tag.as_standardized() {
|
||||||
Some(TagStandard::Event {
|
|
||||||
event_id,
|
|
||||||
marker,
|
|
||||||
..
|
|
||||||
}) => format!("{}: {:.8}", marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()), event_id),
|
|
||||||
Some(TagStandard::PublicKey {
|
Some(TagStandard::PublicKey {
|
||||||
public_key,
|
public_key,
|
||||||
alias,
|
alias,
|
||||||
..
|
..
|
||||||
}) => format!("Key{}: {:.8}", public_key.to_string(), alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default()),
|
}) => format!("Key{}: {:.8}", alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default(), public_key),
|
||||||
Some(TagStandard::Hashtag(content)) =>
|
Some(TagStandard::Hashtag(content)) =>
|
||||||
format!("#{content}"),
|
format!("#{content}"),
|
||||||
_ => tag.content().map_or_else(
|
_ => tag.as_slice().join(" ")
|
||||||
|| format!("Kind {}", tag.kind()),
|
|
||||||
|content| content.to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_hashtag(tag: &Tag) -> bool {
|
pub fn to_prio_tag(value: Prio) -> Tag {
|
||||||
tag.single_letter_tag()
|
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
|
||||||
.is_some_and(|letter| letter.character == Alphabet::T)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_tags() {
|
||||||
|
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it", &Default::default()),
|
||||||
|
("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_tag))
|
||||||
|
.collect()));
|
||||||
|
assert_eq!(extract_tags("So tagless @hewo #", &Default::default()),
|
||||||
|
("So tagless @hewo".to_string(), vec![]));
|
||||||
|
}
|
0
src/lib.rs
Normal file
0
src/lib.rs
Normal file
895
src/main.rs
895
src/main.rs
File diff suppressed because it is too large
Load diff
321
src/task.rs
321
src/task.rs
|
@ -1,32 +1,43 @@
|
||||||
|
mod state;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
use fmt::Display;
|
use fmt::Display;
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::{BTreeSet, HashSet};
|
use std::collections::btree_set::Iter;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use std::iter::{once, Chain, Once};
|
||||||
|
use std::str::FromStr;
|
||||||
use std::string::ToString;
|
use std::string::ToString;
|
||||||
|
|
||||||
|
use crate::hashtag::{is_hashtag, Hashtag};
|
||||||
|
use crate::helpers::{format_timestamp_local, some_non_empty};
|
||||||
|
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
|
||||||
|
use crate::tasks::now;
|
||||||
|
|
||||||
|
pub use crate::task::state::State;
|
||||||
|
pub use crate::task::state::StateChange;
|
||||||
|
|
||||||
use colored::{ColoredString, Colorize};
|
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::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
|
use nostr_sdk::{Alphabet, Event, EventId, Kind, PublicKey, SingleLetterTag, Tag, TagKind, Timestamp};
|
||||||
|
|
||||||
use crate::helpers::{local_datetimestamp, some_non_empty};
|
|
||||||
use crate::kinds::{is_hashtag, TASK_KIND};
|
|
||||||
|
|
||||||
pub static MARKER_PARENT: &str = "parent";
|
pub static MARKER_PARENT: &str = "parent";
|
||||||
pub static MARKER_DEPENDS: &str = "depends";
|
pub static MARKER_DEPENDS: &str = "depends";
|
||||||
|
pub static MARKER_PROPERTY: &str = "property";
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct Task {
|
pub(crate) struct Task {
|
||||||
/// Event that defines this task
|
/// Event that defines this task
|
||||||
pub(crate) event: Event,
|
pub(super) event: Event, // TODO make private
|
||||||
/// Cached sorted tags of the event with references remove - do not modify!
|
/// Cached sorted tags of the event with references removed
|
||||||
pub(crate) tags: Option<BTreeSet<Tag>>,
|
tags: Option<BTreeSet<Tag>>,
|
||||||
/// Task references derived from the event tags
|
/// Task references derived from the event tags
|
||||||
refs: Vec<(String, EventId)>,
|
refs: Vec<(String, EventId)>,
|
||||||
|
|
||||||
/// Reference to children, populated dynamically
|
|
||||||
pub(crate) children: HashSet<EventId>,
|
|
||||||
/// Events belonging to this task, such as state updates and notes
|
/// Events belonging to this task, such as state updates and notes
|
||||||
pub(crate) props: BTreeSet<Event>,
|
pub(crate) props: BTreeSet<Event>,
|
||||||
}
|
}
|
||||||
|
@ -43,15 +54,21 @@ impl Ord for Task {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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| match tag.as_standardized() {
|
let (refs, tags) = event.tags.iter().partition_map(|tag| if let Some(et) = match_event_tag(tag) {
|
||||||
Some(TagStandard::Event { event_id, marker, .. }) => Left((marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), event_id.clone())),
|
Left((et.marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), et.id))
|
||||||
_ => Right(tag.clone()),
|
} else {
|
||||||
|
Right(tag.clone())
|
||||||
});
|
});
|
||||||
// Separate refs for dependencies
|
// 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,
|
refs,
|
||||||
|
@ -59,111 +76,183 @@ impl Task {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_id(&self) -> &EventId {
|
/// All Events including the task and its props in chronological order
|
||||||
&self.event.id
|
pub(crate) fn all_events(&self) -> impl DoubleEndedIterator<Item=&Event> {
|
||||||
|
once(&self.event).chain(self.props.iter().rev())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_id(&self) -> EventId {
|
||||||
|
self.event.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_participants(&self) -> impl Iterator<Item=PublicKey> + '_ {
|
||||||
|
self.tags()
|
||||||
|
.filter(|t| t.kind() == TagKind::p())
|
||||||
|
.filter_map(|t| t.content()
|
||||||
|
.and_then(|c| PublicKey::from_str(c).inspect_err(|e| warn!("Unparseable pubkey in {:?}", t)).ok()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_assignee(&self) -> Option<PublicKey> {
|
||||||
|
self.get_participants().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_owner(&self) -> PublicKey {
|
||||||
|
self.get_assignee()
|
||||||
|
.unwrap_or_else(|| self.event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trimmed event content or stringified id
|
||||||
|
pub(crate) fn get_title(&self) -> String {
|
||||||
|
some_non_empty(self.event.content.trim())
|
||||||
|
.unwrap_or_else(|| self.get_id().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title with leading hashtags removed
|
||||||
|
pub(crate) fn get_filter_title(&self) -> String {
|
||||||
|
self.event.content.trim().trim_start_matches('#').to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
|
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
|
||||||
self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker))
|
self.refs.iter().filter_map(move |(str, id)|
|
||||||
|
Some(id).filter(|_| str == marker))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parent_id(&self) -> Option<&EventId> {
|
pub(crate) fn parent_id(&self) -> Option<&EventId> {
|
||||||
self.find_refs(MARKER_PARENT).next()
|
self.find_refs(MARKER_PARENT).next()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
|
pub(crate) fn find_dependents(&self) -> Vec<&EventId> {
|
||||||
self.find_refs(MARKER_DEPENDS).collect()
|
self.find_refs(MARKER_DEPENDS).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_title(&self) -> String {
|
fn description_events(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
|
||||||
Some(self.event.content.trim().to_string())
|
self.props.iter().filter(|event| event.kind == Kind::TextNote)
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.unwrap_or_else(|| self.get_id().to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
|
/// Description items, ordered newest to oldest
|
||||||
self.props.iter().filter_map(|event| {
|
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ {
|
||||||
if event.kind == Kind::TextNote {
|
self.description_events()
|
||||||
Some(event)
|
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty()))
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
|
fn states(&self) -> impl DoubleEndedIterator<Item=StateChange> + '_ {
|
||||||
self.description_events().map(|e| &e.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn is_task(&self) -> bool {
|
|
||||||
self.event.kind.as_u16() == TASK_KIND ||
|
|
||||||
self.states().next().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn states(&self) -> impl Iterator<Item=TaskState> + '_ {
|
|
||||||
self.props.iter().filter_map(|event| {
|
self.props.iter().filter_map(|event| {
|
||||||
event.kind.try_into().ok().map(|s| TaskState {
|
event.kind.try_into().ok().map(|s| StateChange {
|
||||||
name: some_non_empty(&event.content),
|
name: some_non_empty(&event.content),
|
||||||
state: s,
|
state: s,
|
||||||
time: event.created_at.clone(),
|
time: event.created_at,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn state(&self) -> Option<TaskState> {
|
pub fn last_state_update(&self) -> Timestamp {
|
||||||
self.states().max_by_key(|t| t.time)
|
self.state().map(|s| s.time).unwrap_or(self.event.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_at(&self, time: Timestamp) -> Option<StateChange> {
|
||||||
|
// TODO do not iterate constructed state objects
|
||||||
|
let state = self.states().take_while_inclusive(|ts| ts.time > time);
|
||||||
|
state.last().map(|ts| {
|
||||||
|
if ts.time <= time {
|
||||||
|
ts
|
||||||
|
} else {
|
||||||
|
self.default_state()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the current state if this is a task rather than an activity
|
||||||
|
pub fn state(&self) -> Option<StateChange> {
|
||||||
|
let now = now();
|
||||||
|
self.state_at(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn pure_state(&self) -> State {
|
pub(crate) fn pure_state(&self) -> State {
|
||||||
self.state().map_or(State::Open, |s| s.state)
|
State::from(self.state())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn state_or_default(&self) -> TaskState {
|
pub(crate) fn state_or_default(&self) -> StateChange {
|
||||||
self.state().unwrap_or_else(|| self.default_state())
|
self.state().unwrap_or_else(|| self.default_state())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns None for a stateless task.
|
/// Returns None for activities.
|
||||||
pub(crate) fn state_label(&self) -> Option<ColoredString> {
|
pub(crate) fn state_label(&self) -> Option<ColoredString> {
|
||||||
self.state()
|
self.state()
|
||||||
.or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
|
.or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
|
||||||
.map(|state| state.get_colored_label())
|
.map(|state| state.get_colored_label())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_state(&self) -> TaskState {
|
fn default_state(&self) -> StateChange {
|
||||||
TaskState {
|
StateChange {
|
||||||
name: None,
|
name: None,
|
||||||
state: State::Open,
|
state: State::Open,
|
||||||
time: self.event.created_at,
|
time: self.event.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filter_tags<P>(&self, predicate: P) -> Option<String>
|
pub(crate) fn list_hashtags(&self) -> impl Iterator<Item=Hashtag> + '_ {
|
||||||
|
self.tags().filter_map(|t| Hashtag::try_from(t).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags of this task that are not event references, newest to oldest
|
||||||
|
fn tags(&self) -> impl Iterator<Item=&Tag> {
|
||||||
|
self.props.iter()
|
||||||
|
.flat_map(|e| e.tags.iter()
|
||||||
|
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
|
||||||
|
.chain(self.tags.iter().flatten())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_tags<P>(&self, predicate: P) -> String
|
||||||
where
|
where
|
||||||
P: FnMut(&&Tag) -> bool,
|
P: FnMut(&&Tag) -> bool,
|
||||||
{
|
{
|
||||||
self.tags.as_ref().map(|tags| {
|
self.tags()
|
||||||
tags.into_iter()
|
|
||||||
.filter(predicate)
|
.filter(predicate)
|
||||||
.map(|t| format!("{}", t.content().unwrap()))
|
.map(|t| t.content().unwrap().to_string())
|
||||||
|
.sorted_unstable()
|
||||||
|
.dedup()
|
||||||
.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
|
// Static
|
||||||
"id" => Some(self.event.id.to_string()),
|
"id" => Some(self.get_id().to_string()),
|
||||||
"parentid" => self.parent_id().map(|i| i.to_string()),
|
"parentid" => self.parent_id().map(|i| i.to_string()),
|
||||||
"name" => Some(self.event.content.clone()),
|
"name" => Some(self.event.content.clone()),
|
||||||
"pubkey" => Some(self.event.pubkey.to_string()),
|
"key" | "pubkey" => Some(self.event.pubkey.to_string()),
|
||||||
"created" => Some(local_datetimestamp(&self.event.created_at)),
|
"created" => Some(format_timestamp_local(&self.event.created_at)),
|
||||||
"kind" => Some(self.event.kind.to_string()),
|
"kind" => Some(self.event.kind.to_string()),
|
||||||
// Dynamic
|
// Dynamic
|
||||||
|
"priority" => self.priority_raw().map(|c| c.to_string()),
|
||||||
"status" => self.state_label().map(|c| c.to_string()),
|
"status" => self.state_label().map(|c| c.to_string()),
|
||||||
"desc" => self.descriptions().last().cloned(),
|
"desc" => self.descriptions().next().cloned(),
|
||||||
"description" => Some(self.descriptions().join(" ")),
|
"description" => Some(self.descriptions().rev().join(" ")),
|
||||||
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
|
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
|
||||||
"tags" => self.filter_tags(|_| true),
|
"tags" => Some(self.join_tags(|_| true)), // TODO test these!
|
||||||
"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())),
|
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
|
||||||
"props" => Some(format!(
|
"props" => Some(format!(
|
||||||
|
@ -173,10 +262,7 @@ impl Task {
|
||||||
.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()
|
||||||
)),
|
)),
|
||||||
"descriptions" => Some(format!(
|
"descriptions" => Some(format!("{:?}", self.descriptions().collect_vec())),
|
||||||
"{:?}",
|
|
||||||
self.descriptions().collect_vec()
|
|
||||||
)),
|
|
||||||
_ => {
|
_ => {
|
||||||
warn!("Unknown task property {}", property);
|
warn!("Unknown task property {}", property);
|
||||||
None
|
None
|
||||||
|
@ -184,110 +270,3 @@ impl Task {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct TaskState {
|
|
||||||
pub(crate) state: State,
|
|
||||||
name: Option<String>,
|
|
||||||
pub(crate) time: Timestamp,
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|
|
||||||
|| self.state.to_string().eq_ignore_ascii_case(label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Display for TaskState {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
let state_str = self.state.to_string();
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}",
|
|
||||||
self.name
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.trim())
|
|
||||||
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
|
|
||||||
.map_or(state_str, |s| format!("{}: {}", self.state, s))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const PROCEDURE_KIND: u16 = 1639;
|
|
||||||
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
|
|
||||||
pub(crate) enum State {
|
|
||||||
/// Actionable
|
|
||||||
Open = 1630,
|
|
||||||
/// Completed
|
|
||||||
Done,
|
|
||||||
/// Not Actionable (anymore)
|
|
||||||
Closed,
|
|
||||||
/// Temporarily not actionable
|
|
||||||
Pending,
|
|
||||||
/// Actionable ordered task list
|
|
||||||
Procedure = PROCEDURE_KIND as isize,
|
|
||||||
}
|
|
||||||
impl From<&str> for State {
|
|
||||||
fn from(value: &str) -> Self {
|
|
||||||
match value {
|
|
||||||
"Closed" => State::Closed,
|
|
||||||
"Done" => State::Done,
|
|
||||||
"Pending" => State::Pending,
|
|
||||||
"Proc" | "Procedure" | "List" => State::Procedure,
|
|
||||||
_ => State::Open,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl TryFrom<Kind> for State {
|
|
||||||
type Error = ();
|
|
||||||
|
|
||||||
fn try_from(value: Kind) -> Result<Self, Self::Error> {
|
|
||||||
match value.as_u16() {
|
|
||||||
1630 => Ok(State::Open),
|
|
||||||
1631 => Ok(State::Done),
|
|
||||||
1632 => Ok(State::Closed),
|
|
||||||
1633 => Ok(State::Pending),
|
|
||||||
PROCEDURE_KIND => Ok(State::Procedure),
|
|
||||||
_ => Err(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl State {
|
|
||||||
pub(crate) fn is_open(&self) -> bool {
|
|
||||||
match self {
|
|
||||||
State::Open | State::Pending | State::Procedure => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn kind(self) -> u16 {
|
|
||||||
self as u16
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
|
|
||||||
match self {
|
|
||||||
State::Open => str.green(),
|
|
||||||
State::Done => str.bright_black(),
|
|
||||||
State::Closed => str.magenta(),
|
|
||||||
State::Pending => str.yellow(),
|
|
||||||
State::Procedure => str.blue(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl From<State> for Kind {
|
|
||||||
fn from(value: State) -> Self {
|
|
||||||
Kind::from(value.kind())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Display for State {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
fmt::Debug::fmt(self, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
128
src/task/state.rs
Normal file
128
src/task/state.rs
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
use crate::helpers::some_non_empty;
|
||||||
|
use crate::kinds::{PROCEDURE_KIND, PROCEDURE_KIND_ID};
|
||||||
|
|
||||||
|
use colored::{ColoredString, Colorize};
|
||||||
|
use nostr_sdk::{Kind, Timestamp};
|
||||||
|
use std::fmt;
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
|
pub struct StateChange {
|
||||||
|
pub(super) state: State,
|
||||||
|
pub(super) name: Option<String>,
|
||||||
|
pub(super) time: Timestamp,
|
||||||
|
}
|
||||||
|
impl StateChange {
|
||||||
|
pub fn get_label_for(state: &State, comment: &str) -> String {
|
||||||
|
some_non_empty(comment).unwrap_or_else(|| state.to_string())
|
||||||
|
}
|
||||||
|
pub fn get_label(&self) -> String {
|
||||||
|
self.name.clone().unwrap_or_else(|| self.state.to_string())
|
||||||
|
}
|
||||||
|
pub fn get_colored_label(&self) -> ColoredString {
|
||||||
|
self.state.colorize(&self.get_label())
|
||||||
|
}
|
||||||
|
pub fn matches_label(&self, label: &str) -> bool {
|
||||||
|
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|
||||||
|
|| self.state.to_string().eq_ignore_ascii_case(label)
|
||||||
|
}
|
||||||
|
pub fn get_timestamp(&self) -> Timestamp {
|
||||||
|
self.time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for StateChange {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
let state_str = self.state.to_string();
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
self.name
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.trim())
|
||||||
|
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
|
||||||
|
.map_or(state_str, |s| format!("{}: {}", self.state, s))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<Option<StateChange>> for State {
|
||||||
|
fn from(value: Option<StateChange>) -> Self {
|
||||||
|
value.map_or(State::Open, |s| s.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||||
|
pub enum State {
|
||||||
|
/// Actionable
|
||||||
|
Open = 1630,
|
||||||
|
/// Completed
|
||||||
|
Done,
|
||||||
|
/// Not Actionable (anymore)
|
||||||
|
Closed,
|
||||||
|
/// Temporarily not actionable
|
||||||
|
Pending,
|
||||||
|
/// Ordered task list
|
||||||
|
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 {
|
||||||
|
type Error = ();
|
||||||
|
|
||||||
|
fn try_from(value: Kind) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
Kind::GitStatusOpen => Ok(State::Open),
|
||||||
|
Kind::GitStatusApplied => Ok(State::Done),
|
||||||
|
Kind::GitStatusClosed => Ok(State::Closed),
|
||||||
|
Kind::GitStatusDraft => Ok(State::Pending),
|
||||||
|
_ => {
|
||||||
|
if value == PROCEDURE_KIND {
|
||||||
|
Ok(State::Procedure)
|
||||||
|
} else {
|
||||||
|
Err(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl State {
|
||||||
|
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 {
|
||||||
|
State::Open => str.green(),
|
||||||
|
State::Done => str.bright_black(),
|
||||||
|
State::Closed => str.magenta(),
|
||||||
|
State::Pending => str.yellow(),
|
||||||
|
State::Procedure => str.blue(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<State> for Kind {
|
||||||
|
fn from(value: State) -> Self {
|
||||||
|
Kind::from(value.kind())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Display for State {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
fmt::Debug::fmt(self, f)
|
||||||
|
}
|
||||||
|
}
|
40
src/task/tests.rs
Normal file
40
src/task/tests.rs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
use super::*;
|
||||||
|
use nostr_sdk::{EventBuilder, Keys, Tag, Timestamp};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_state() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let mut task = Task::new(
|
||||||
|
EventBuilder::new(Kind::GitIssue, "task").tags([Tag::hashtag("tag1")])
|
||||||
|
.sign_with_keys(&keys).unwrap());
|
||||||
|
assert_eq!(task.pure_state(), State::Open);
|
||||||
|
assert_eq!(task.list_hashtags().count(), 1);
|
||||||
|
|
||||||
|
let now = Timestamp::now();
|
||||||
|
task.props.insert(
|
||||||
|
EventBuilder::new(State::Done.into(), "")
|
||||||
|
.custom_created_at(now)
|
||||||
|
.sign_with_keys(&keys).unwrap());
|
||||||
|
assert_eq!(task.pure_state(), State::Done);
|
||||||
|
task.props.insert(
|
||||||
|
EventBuilder::new(State::Open.into(), "Ready").tags([Tag::hashtag("tag2")])
|
||||||
|
.custom_created_at(now - 2)
|
||||||
|
.sign_with_keys(&keys).unwrap());
|
||||||
|
assert_eq!(task.pure_state(), State::Done);
|
||||||
|
assert_eq!(task.list_hashtags().count(), 2);
|
||||||
|
task.props.insert(
|
||||||
|
EventBuilder::new(State::Closed.into(), "")
|
||||||
|
.custom_created_at(now + 9)
|
||||||
|
.sign_with_keys(&keys).unwrap());
|
||||||
|
assert_eq!(task.pure_state(), State::Closed);
|
||||||
|
assert_eq!(task.state_at(now), Some(StateChange {
|
||||||
|
state: State::Done,
|
||||||
|
name: None,
|
||||||
|
time: now,
|
||||||
|
}));
|
||||||
|
assert_eq!(task.state_at(now - 1), Some(StateChange {
|
||||||
|
state: State::Open,
|
||||||
|
name: Some("Ready".to_string()),
|
||||||
|
time: now - 2,
|
||||||
|
}));
|
||||||
|
}
|
1831
src/tasks.rs
1831
src/tasks.rs
File diff suppressed because it is too large
Load diff
160
src/tasks/children_traversal.rs
Normal file
160
src/tasks/children_traversal.rs
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
use std::iter::FusedIterator;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nostr_sdk::EventId;
|
||||||
|
use crate::task::Task;
|
||||||
|
use crate::tasks::{TaskMap, TaskMapMethods, TasksRelay};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
enum TraversalFilter {
|
||||||
|
Reject = 0b00,
|
||||||
|
TakeSelf = 0b01,
|
||||||
|
TakeChildren = 0b10,
|
||||||
|
Take = 0b11,
|
||||||
|
}
|
||||||
|
impl TraversalFilter {
|
||||||
|
fn takes_children(&self) -> bool {
|
||||||
|
self == &TraversalFilter::Take ||
|
||||||
|
self == &TraversalFilter::TakeChildren
|
||||||
|
}
|
||||||
|
fn takes_self(&self) -> bool {
|
||||||
|
self == &TraversalFilter::Take ||
|
||||||
|
self == &TraversalFilter::TakeSelf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Breadth-First Iterator over tasks with recursive children
|
||||||
|
pub(super) struct ChildrenTraversal<'a> {
|
||||||
|
tasks: &'a TaskMap,
|
||||||
|
/// Found Events
|
||||||
|
queue: Vec<EventId>,
|
||||||
|
/// Index of the next element in the queue
|
||||||
|
index: usize,
|
||||||
|
/// Depth of the next element
|
||||||
|
depth: usize,
|
||||||
|
/// Element with the next depth boundary
|
||||||
|
next_depth_at: usize,
|
||||||
|
}
|
||||||
|
impl<'a> ChildrenTraversal<'a> {
|
||||||
|
fn rooted(tasks: &'a TaskMap, id: Option<&EventId>) -> Self {
|
||||||
|
let mut queue = Vec::with_capacity(tasks.len());
|
||||||
|
queue.append(
|
||||||
|
&mut tasks
|
||||||
|
.values()
|
||||||
|
.filter(move |t| t.parent_id() == id)
|
||||||
|
.map(|t| t.get_id())
|
||||||
|
.collect_vec()
|
||||||
|
);
|
||||||
|
Self::with_queue(tasks, queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_queue(tasks: &'a TaskMap, queue: Vec<EventId>) -> Self {
|
||||||
|
ChildrenTraversal {
|
||||||
|
tasks: &tasks,
|
||||||
|
next_depth_at: queue.len(),
|
||||||
|
index: 0,
|
||||||
|
depth: 1,
|
||||||
|
queue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn from(tasks: &'a TasksRelay, id: EventId) -> Self {
|
||||||
|
let mut queue = Vec::with_capacity(64);
|
||||||
|
queue.push(id);
|
||||||
|
ChildrenTraversal {
|
||||||
|
tasks: &tasks.tasks,
|
||||||
|
queue,
|
||||||
|
index: 0,
|
||||||
|
depth: 0,
|
||||||
|
next_depth_at: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process until the given depth
|
||||||
|
/// Returns true if that depth was reached
|
||||||
|
pub(super) fn process_depth(&mut self, depth: usize) -> bool {
|
||||||
|
while self.depth < depth {
|
||||||
|
if self.next().is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all children
|
||||||
|
pub(super) fn get_all(mut self) -> Vec<EventId> {
|
||||||
|
while self.next().is_some() {}
|
||||||
|
self.queue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all tasks until the specified depth
|
||||||
|
pub(super) fn get_depth(mut self, depth: usize) -> Vec<EventId> {
|
||||||
|
self.process_depth(depth);
|
||||||
|
self.queue
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_depth(&mut self) {
|
||||||
|
if self.next_depth_at == self.index {
|
||||||
|
self.depth += 1;
|
||||||
|
self.next_depth_at = self.queue.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get next id and advance, without adding children
|
||||||
|
fn next_task(&mut self) -> Option<EventId> {
|
||||||
|
if self.index >= self.queue.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = self.queue[self.index];
|
||||||
|
self.index += 1;
|
||||||
|
Some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the next known task and run it through the filter
|
||||||
|
fn next_filtered<F>(&mut self, filter: &F) -> Option<&'a Task>
|
||||||
|
where
|
||||||
|
F: Fn(&Task) -> TraversalFilter,
|
||||||
|
{
|
||||||
|
self.next_task().and_then(|id| {
|
||||||
|
if let Some(task) = self.tasks.get(&id) {
|
||||||
|
let take = filter(task);
|
||||||
|
if take.takes_children() {
|
||||||
|
self.queue_children_of(&task);
|
||||||
|
}
|
||||||
|
if take.takes_self() {
|
||||||
|
self.check_depth();
|
||||||
|
return Some(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.check_depth();
|
||||||
|
self.next_filtered(filter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue_children_of(&mut self, task: &'a Task) {
|
||||||
|
self.queue.extend(self.tasks.children_ids_for(task.get_id()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl FusedIterator for ChildrenTraversal<'_> {}
|
||||||
|
impl<'a> Iterator for ChildrenTraversal<'a> {
|
||||||
|
type Item = EventId;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
self.next_task().inspect(|id| {
|
||||||
|
match self.tasks.get(id) {
|
||||||
|
None => {
|
||||||
|
// Unknown task, might still find children, just slower
|
||||||
|
for task in self.tasks.values() {
|
||||||
|
if task.parent_id().is_some_and(|i| i == id) {
|
||||||
|
self.queue.push(task.get_id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(task) => {
|
||||||
|
self.queue_children_of(&task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.check_depth();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
83
src/tasks/durations.rs
Normal file
83
src/tasks/durations.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nostr_sdk::{Event, EventId, Timestamp};
|
||||||
|
use crate::kinds::match_event_tag;
|
||||||
|
|
||||||
|
pub(super) fn referenced_events(event: &Event) -> impl Iterator<Item=EventId> + '_ {
|
||||||
|
event.tags.iter().filter_map(|tag| match_event_tag(tag).map(|t| t.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the id of a referenced event if it is contained in the provided ids list.
|
||||||
|
fn matching_tag_id<'a>(event: &'a Event, ids: &'a [EventId]) -> Option<EventId> {
|
||||||
|
referenced_events(event).find(|id| ids.contains(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters out event timestamps to those that start or stop one of the given events
|
||||||
|
pub(super) fn timestamps<'a>(
|
||||||
|
events: impl Iterator<Item=&'a Event>,
|
||||||
|
ids: &'a [EventId],
|
||||||
|
) -> impl Iterator<Item=(&Timestamp, Option<EventId>)> {
|
||||||
|
events
|
||||||
|
.map(|event| (&event.created_at, matching_tag_id(event, ids)))
|
||||||
|
.dedup_by(|(_, e1), (_, e2)| e1 == e2)
|
||||||
|
.skip_while(|element| element.1.is_none())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates Events to accumulate times tracked
|
||||||
|
/// Expects a sorted iterator
|
||||||
|
pub(super) struct Durations<'a> {
|
||||||
|
events: Box<dyn Iterator<Item=&'a Event> + 'a>,
|
||||||
|
ids: &'a [EventId],
|
||||||
|
threshold: Option<Timestamp>,
|
||||||
|
}
|
||||||
|
impl Durations<'_> {
|
||||||
|
pub(super) fn from<'b>(
|
||||||
|
events: impl IntoIterator<Item=&'b Event> + 'b,
|
||||||
|
ids: &'b [EventId],
|
||||||
|
) -> Durations<'b> {
|
||||||
|
Durations {
|
||||||
|
events: Box::new(events.into_iter()),
|
||||||
|
ids,
|
||||||
|
threshold: Some(Timestamp::now()), // TODO consider offset?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Iterator for Durations<'_> {
|
||||||
|
type Item = Duration;
|
||||||
|
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
let mut start: Option<u64> = None;
|
||||||
|
while let Some(event) = self.events.next() {
|
||||||
|
if matching_tag_id(event, self.ids).is_some() {
|
||||||
|
if self.threshold.is_some_and(|th| event.created_at > th) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
start = start.or(Some(event.created_at.as_u64()))
|
||||||
|
} else {
|
||||||
|
if let Some(stamp) = start {
|
||||||
|
return Some(Duration::from_secs(event.created_at.as_u64() - stamp));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let now = self.threshold.unwrap_or(Timestamp::now()).as_u64();
|
||||||
|
start.filter(|t| t < &now)
|
||||||
|
.map(|stamp| Duration::from_secs(now.saturating_sub(stamp)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore]
|
||||||
|
fn test_timestamps() {
|
||||||
|
let mut tasks = crate::tasks::tests::stub_tasks();
|
||||||
|
let zero = EventId::all_zeros();
|
||||||
|
|
||||||
|
tasks.track_at(Timestamp::now() + 100, Some(zero));
|
||||||
|
assert_eq!(
|
||||||
|
timestamps(tasks.get_own_events_history(), &[zero])
|
||||||
|
.collect_vec()
|
||||||
|
.len(),
|
||||||
|
2
|
||||||
|
)
|
||||||
|
// TODO Does not show both future and current tracking properly, need to split by current time
|
||||||
|
}
|
||||||
|
|
82
src/tasks/nostr_users.rs
Normal file
82
src/tasks/nostr_users.rs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nostr_sdk::{Keys, Metadata, PublicKey, Tag, Timestamp};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use log::debug;
|
||||||
|
|
||||||
|
#[derive(Default, Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct NostrUsers {
|
||||||
|
users: HashMap<PublicKey, Metadata>,
|
||||||
|
user_times: HashMap<PublicKey, Timestamp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NostrUsers {
|
||||||
|
pub(crate) fn find_user_with_displayname(&self, term: &str) -> Option<(PublicKey, String)> {
|
||||||
|
self.find_user(term)
|
||||||
|
.map(|(k, _)| (*k, self.get_displayname(k)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find username or key starting with the given term.
|
||||||
|
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
|
||||||
|
let lowered = term.trim().to_ascii_lowercase();
|
||||||
|
let term = lowered.as_str();
|
||||||
|
if term.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if let Ok(key) = PublicKey::from_str(term) {
|
||||||
|
return self.users.get_key_value(&key);
|
||||||
|
}
|
||||||
|
self.users.iter()
|
||||||
|
.sorted_unstable_by_key(|(k, v)| self.get_user_time(k))
|
||||||
|
.rev()
|
||||||
|
.find(|(k, v)|
|
||||||
|
// TODO regex word boundary
|
||||||
|
v.name.as_ref().is_some_and(|n| n.to_ascii_lowercase().starts_with(term)) ||
|
||||||
|
v.display_name.as_ref().is_some_and(|n| n.to_ascii_lowercase().starts_with(term)) ||
|
||||||
|
(term.len() > 4 && k.to_string().starts_with(term)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_displayname(&self, pubkey: &PublicKey) -> String {
|
||||||
|
self.users.get(pubkey)
|
||||||
|
.and_then(|m| m.display_name.clone().or(m.name.clone()))
|
||||||
|
.unwrap_or_else(|| pubkey.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
|
||||||
|
self.users.get(pubkey)
|
||||||
|
.and_then(|m| m.name.clone())
|
||||||
|
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_user_time(&self, pubkey: &PublicKey) -> u64 {
|
||||||
|
match self.user_times.get(pubkey) {
|
||||||
|
Some(t) => t.as_u64(),
|
||||||
|
None => Timestamp::zero().as_u64(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn insert(&mut self, pubkey: PublicKey, metadata: Metadata, timestamp: Timestamp) {
|
||||||
|
if self.get_user_time(&pubkey) < timestamp.as_u64() {
|
||||||
|
debug!("Inserting user metadata for {}", pubkey);
|
||||||
|
self.users.insert(pubkey, metadata);
|
||||||
|
self.user_times.insert(pubkey, timestamp);
|
||||||
|
} else {
|
||||||
|
debug!("Skipping older user metadata for {}", pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn create(&mut self, pubkey: PublicKey) {
|
||||||
|
if !self.users.contains_key(&pubkey) {
|
||||||
|
self.users.insert(pubkey, Default::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_user_extract() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let mut users = NostrUsers::default();
|
||||||
|
users.insert(keys.public_key, Metadata::new().display_name("Tester Jo"), Timestamp::now());
|
||||||
|
assert_eq!(crate::kinds::extract_tags("Hello @test", &users),
|
||||||
|
("Hello".to_string(), vec![Tag::public_key(keys.public_key)]));
|
||||||
|
}
|
495
src/tasks/tests.rs
Normal file
495
src/tasks/tests.rs
Normal file
|
@ -0,0 +1,495 @@
|
||||||
|
use super::*;
|
||||||
|
use crate::event_sender::EventSender;
|
||||||
|
use crate::hashtag::Hashtag;
|
||||||
|
use crate::kinds::{extract_tags, to_hashtag_tag, TASK_KIND};
|
||||||
|
use crate::task::{State, Task, MARKER_DEPENDS, MARKER_PARENT};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use nostr_sdk::{EventBuilder, EventId, Keys, Kind, Tag, Timestamp};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub(super) fn stub_tasks() -> TasksRelay {
|
||||||
|
use nostr_sdk::Keys;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let (tx, _rx) = mpsc::channel(16);
|
||||||
|
TasksRelay::with_sender(EventSender {
|
||||||
|
url: None,
|
||||||
|
tx,
|
||||||
|
keys: Keys::generate(),
|
||||||
|
queue: Default::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_position {
|
||||||
|
($tasks:expr, $id:expr $(,)?) => {
|
||||||
|
let pos = $tasks.get_position();
|
||||||
|
assert_eq!(pos, Some($id),
|
||||||
|
"Current: {:?}\nExpected: {:?}",
|
||||||
|
$tasks.get_task_path(pos),
|
||||||
|
$tasks.get_task_path(Some($id)),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_tasks_visible {
|
||||||
|
($tasks:expr, $expected:expr $(,)?) => {
|
||||||
|
assert_tasks!($tasks, $tasks.visible_tasks(), $expected,
|
||||||
|
"\nQuick Access: {:?}",
|
||||||
|
$tasks.quick_access_raw().map(|id| $tasks.get_task_path(Some(id))).collect_vec());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_tasks_view {
|
||||||
|
($tasks:expr, $expected:expr $(,)?) => {
|
||||||
|
assert_tasks!($tasks, $tasks.viewed_tasks(), $expected, "");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! assert_tasks {
|
||||||
|
($tasks:expr, $tasklist:expr, $expected:expr $(, $($arg:tt)*)?) => {
|
||||||
|
assert_eq!(
|
||||||
|
$tasklist
|
||||||
|
.iter()
|
||||||
|
.map(|t| t.get_id())
|
||||||
|
.collect::<HashSet<EventId>>(),
|
||||||
|
HashSet::from_iter($expected.clone()),
|
||||||
|
"Tasks Visible: {:?}\nExpected: {:?}{}",
|
||||||
|
$tasklist.iter().map(|t| t.get_id()).map(|id| $tasks.get_task_path(Some(id))).collect_vec(),
|
||||||
|
$expected.into_iter().map(|id| $tasks.get_task_path(Some(id))).collect_vec(),
|
||||||
|
format!($($($arg)*)?)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_recursive_closing() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
|
||||||
|
tasks.custom_time = Some(Timestamp::zero());
|
||||||
|
let parent = tasks.make_task_unwrapped("parent #tag1");
|
||||||
|
tasks.move_to(Some(parent));
|
||||||
|
let sub = tasks.make_task_unwrapped("sub #oi # tag2");
|
||||||
|
assert_eq!(
|
||||||
|
tasks.all_hashtags(),
|
||||||
|
["oi", "tag1", "tag2"].into_iter().map(Hashtag::from).collect()
|
||||||
|
);
|
||||||
|
tasks.make_note("note with #tag3 # yeah");
|
||||||
|
let all_tags = ["oi", "tag1", "tag2", "tag3", "yeah"].into_iter().map(Hashtag::from).collect();
|
||||||
|
assert_eq!(tasks.all_hashtags(), all_tags);
|
||||||
|
|
||||||
|
tasks.custom_time = Some(Timestamp::now());
|
||||||
|
tasks.update_state("Finished #YeaH # oi", State::Done);
|
||||||
|
assert_eq!(
|
||||||
|
tasks.get_by_id(&parent).unwrap().list_hashtags().collect_vec(),
|
||||||
|
["YeaH", "oi", "tag3", "yeah", "tag1"].map(Hashtag::from)
|
||||||
|
);
|
||||||
|
assert_eq!(tasks.all_hashtags(), all_tags);
|
||||||
|
|
||||||
|
tasks.custom_time = Some(now());
|
||||||
|
tasks.update_state("Closing Down", State::Closed);
|
||||||
|
assert_eq!(tasks.get_by_id(&sub).unwrap().pure_state(), State::Closed);
|
||||||
|
assert_eq!(tasks.get_by_id(&parent).unwrap().pure_state(), State::Closed);
|
||||||
|
assert_eq!(tasks.nonclosed_tasks().next(), None);
|
||||||
|
assert_eq!(tasks.all_hashtags(), Default::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_context() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
tasks.update_tags(["dp", "yeah"].into_iter().map(Hashtag::from));
|
||||||
|
assert_eq!(tasks.get_prompt_suffix(), " #dp #yeah");
|
||||||
|
tasks.remove_tag("Y");
|
||||||
|
assert_eq!(tasks.tags, ["dp"].into_iter().map(Hashtag::from).collect());
|
||||||
|
|
||||||
|
tasks.set_priority(Some(HIGH_PRIO));
|
||||||
|
assert_eq!(tasks.get_prompt_suffix(), " #dp *85");
|
||||||
|
let id_hp = tasks.make_task_unwrapped("high prio tagged # tag");
|
||||||
|
let hp = tasks.get_by_id(&id_hp).unwrap();
|
||||||
|
assert_eq!(hp.priority(), Some(HIGH_PRIO));
|
||||||
|
assert_eq!(
|
||||||
|
hp.list_hashtags().collect_vec(),
|
||||||
|
vec!["DP", "tag"].into_iter().map(Hashtag::from).collect_vec()
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.state = StateFilter::from("WIP");
|
||||||
|
tasks.set_priority(Some(QUICK_PRIO));
|
||||||
|
|
||||||
|
tasks.make_task_and_enter("another *4", State::Pending);
|
||||||
|
let task2 = tasks.get_current_task().unwrap();
|
||||||
|
assert_eq!(task2.priority(), Some(40));
|
||||||
|
assert_eq!(task2.pure_state(), State::Pending);
|
||||||
|
assert_eq!(task2.state().unwrap().get_label(), "Pending");
|
||||||
|
tasks.make_note("*3");
|
||||||
|
let task2 = tasks.get_current_task().unwrap();
|
||||||
|
assert_eq!(task2.descriptions().next(), None);
|
||||||
|
assert_eq!(task2.priority(), Some(30));
|
||||||
|
let anid = task2.get_id();
|
||||||
|
|
||||||
|
tasks.custom_time = Some(Timestamp::now() + 1);
|
||||||
|
let s1 = tasks.make_task_unwrapped("sub1");
|
||||||
|
tasks.custom_time = Some(Timestamp::now() + 2);
|
||||||
|
tasks.set_priority(Some(QUICK_PRIO + 1));
|
||||||
|
let s2 = tasks.make_task_unwrapped("sub2");
|
||||||
|
let s3 = tasks.make_task_unwrapped("sub3");
|
||||||
|
tasks.set_priority(Some(QUICK_PRIO));
|
||||||
|
|
||||||
|
assert_tasks_visible!(tasks, [s1, s2, s3]);
|
||||||
|
tasks.state = StateFilter::Default;
|
||||||
|
assert_tasks_view!(tasks, [s1, s2, s3]);
|
||||||
|
assert_tasks_visible!(tasks, [id_hp, s1, s2, s3]);
|
||||||
|
tasks.move_up();
|
||||||
|
tasks.set_search_depth(1);
|
||||||
|
assert_tasks_view!(tasks, [id_hp]);
|
||||||
|
assert_tasks_visible!(tasks, [s1, s2, s3, id_hp]);
|
||||||
|
|
||||||
|
tasks.set_priority(None);
|
||||||
|
let s4 = tasks.make_task_with("sub4", [tasks.make_event_tag_from_id(anid, MARKER_PARENT)], true).unwrap();
|
||||||
|
assert_eq!(tasks.get_parent(Some(&s4)), Some(&anid));
|
||||||
|
assert_tasks_view!(tasks, [anid, id_hp]);
|
||||||
|
// s2-4 are newest while s2,s3,hp are highest prio
|
||||||
|
assert_tasks_visible!(tasks, [s4, s2, s3, anid, id_hp]);
|
||||||
|
|
||||||
|
//let keys = Keys::generate();
|
||||||
|
//let builder = EventBuilder::new(Kind::from(1234), "test").tags([Tag::public_key(k//eys.public_key)]);
|
||||||
|
//println!("{:?}", builder);
|
||||||
|
//println!("{:?}", builder.sign_with_keys(&keys));
|
||||||
|
//env_logger::init();
|
||||||
|
|
||||||
|
// ASSIGNEE
|
||||||
|
assert_eq!(tasks.pubkey, Some(tasks.sender.pubkey()));
|
||||||
|
let hoi = tasks.make_task("hoi").unwrap();
|
||||||
|
let hoi = tasks.get_by_id(&hoi).unwrap();
|
||||||
|
assert_eq!(hoi.get_owner(), tasks.sender.pubkey());
|
||||||
|
// https://github.com/rust-nostr/nostr/issues/736
|
||||||
|
//assert_eq!(hoi.get_participants().collect_vec(), vec![tasks.sender.pubkey()]);
|
||||||
|
//assert_eq!(hoi.get_assignee(), Some(tasks.sender.pubkey()));
|
||||||
|
|
||||||
|
let pubkey = Keys::generate().public_key;
|
||||||
|
let test1id = tasks.make_task(&("test1 @".to_string() + &pubkey.to_string())).unwrap();
|
||||||
|
let test1 = tasks.get_by_id(&test1id).unwrap();
|
||||||
|
assert_eq!(test1.get_owner(), pubkey);
|
||||||
|
|
||||||
|
tasks.pubkey = Some(pubkey);
|
||||||
|
let test2id = tasks.make_task("test2").unwrap();
|
||||||
|
let test2 = tasks.get_by_id(&test2id).unwrap();
|
||||||
|
assert_eq!(test2.get_owner(), pubkey);
|
||||||
|
|
||||||
|
tasks.pubkey = None;
|
||||||
|
let all = tasks.make_task("all").unwrap();
|
||||||
|
let all = tasks.get_by_id(&all).unwrap();
|
||||||
|
assert_eq!(all.get_assignee(), None);
|
||||||
|
assert_eq!(all.get_owner(), tasks.sender.pubkey());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sibling_dependency() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
let parent = tasks.make_task_unwrapped("parent");
|
||||||
|
let sub = tasks.submit(
|
||||||
|
EventBuilder::new(TASK_KIND, "sub")
|
||||||
|
.tags([tasks.make_event_tag_from_id(parent, MARKER_PARENT)]),
|
||||||
|
);
|
||||||
|
assert_tasks_view!(tasks, [parent]);
|
||||||
|
tasks.track_at(Timestamp::now(), Some(sub));
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 1);
|
||||||
|
assert_tasks_view!(tasks, []);
|
||||||
|
|
||||||
|
tasks.make_dependent_sibling("sibling");
|
||||||
|
assert_eq!(tasks.len(), 3);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bookmarks() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
let zero = EventId::all_zeros();
|
||||||
|
let test = tasks.make_task_unwrapped("test # tag");
|
||||||
|
let parent = tasks.make_task_unwrapped("parent");
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 2);
|
||||||
|
tasks.move_to(Some(parent));
|
||||||
|
let pin = tasks.make_task_unwrapped("pin");
|
||||||
|
|
||||||
|
tasks.search_depth = 1;
|
||||||
|
assert_eq!(tasks.filtered_tasks(None, true).len(), 2);
|
||||||
|
assert_eq!(tasks.filtered_tasks(None, false).len(), 2);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(zero), false).len(), 0);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(parent), false).len(), 1);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(pin), false).len(), 0);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(zero), false).len(), 0);
|
||||||
|
|
||||||
|
tasks.submit(
|
||||||
|
EventBuilder::new(Kind::Bookmarks, "")
|
||||||
|
.tags([Tag::event(pin), Tag::event(zero)])
|
||||||
|
);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 1);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(pin), true).len(), 0);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(pin), false).len(), 0);
|
||||||
|
assert_eq!(tasks.filtered_tasks(Some(zero), true).len(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
tasks.filtered_tasks(Some(zero), false),
|
||||||
|
vec![tasks.get_by_id(&pin).unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.move_to(None);
|
||||||
|
assert_eq!(tasks.view_depth, 0);
|
||||||
|
assert_tasks_visible!(tasks, [pin, test, parent]);
|
||||||
|
tasks.set_view_depth(1);
|
||||||
|
assert_tasks_visible!(tasks, [pin, test]);
|
||||||
|
tasks.add_tag("tag");
|
||||||
|
assert_tasks_visible!(tasks, [test]);
|
||||||
|
assert_eq!(
|
||||||
|
tasks.filtered_tasks(None, true),
|
||||||
|
vec![tasks.get_by_id(&test).unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
|
tasks.submit(EventBuilder::new(Kind::Bookmarks, ""));
|
||||||
|
assert!(tasks.bookmarks.is_empty());
|
||||||
|
tasks.clear_filters();
|
||||||
|
assert_tasks_visible!(tasks, [pin, test]);
|
||||||
|
tasks.set_view_depth(0);
|
||||||
|
tasks.custom_time = Some(now());
|
||||||
|
let mut new = (0..3).map(|t| tasks.make_task_unwrapped(t.to_string().as_str())).collect_vec();
|
||||||
|
// Show the newest tasks in quick access and remove old pin
|
||||||
|
new.extend([test, parent]);
|
||||||
|
assert_tasks_visible!(tasks, new);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_procedures() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
tasks.make_task_and_enter("proc # tags", State::Procedure);
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 1);
|
||||||
|
let side = tasks.submit(
|
||||||
|
EventBuilder::new(TASK_KIND, "side")
|
||||||
|
.tags([tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)])
|
||||||
|
);
|
||||||
|
assert_eq!(tasks.viewed_tasks(), Vec::<&Task>::new());
|
||||||
|
let sub_id = tasks.make_task_unwrapped("sub");
|
||||||
|
assert_tasks_view!(tasks, [sub_id]);
|
||||||
|
assert_eq!(tasks.len(), 3);
|
||||||
|
let sub = tasks.get_by_id(&sub_id).unwrap();
|
||||||
|
assert_eq!(sub.find_dependents(), Vec::<&EventId>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_filter_or_create() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
let zeros = EventId::all_zeros();
|
||||||
|
let zero = Some(zeros);
|
||||||
|
|
||||||
|
let id1 = tasks.filter_or_create(zero, "newer");
|
||||||
|
assert_eq!(tasks.len(), 1);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 0);
|
||||||
|
assert_eq!(tasks.get_by_id(&id1.unwrap()).unwrap().parent_id(), zero.as_ref());
|
||||||
|
|
||||||
|
tasks.move_to(zero);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 1);
|
||||||
|
let sub = tasks.make_task_unwrapped("test");
|
||||||
|
assert_eq!(tasks.len(), 2);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 2);
|
||||||
|
assert_eq!(tasks.get_by_id(&sub).unwrap().parent_id(), zero.as_ref());
|
||||||
|
|
||||||
|
// Do not substring match invisible subtask
|
||||||
|
let id2 = tasks.filter_or_create(None, "#new-is gold wrapped").unwrap();
|
||||||
|
assert_eq!(tasks.len(), 3);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 2);
|
||||||
|
let new2 = tasks.get_by_id(&id2).unwrap();
|
||||||
|
assert_eq!(new2.props, Default::default());
|
||||||
|
|
||||||
|
tasks.move_up();
|
||||||
|
assert_eq!(tasks.get_matching(tasks.get_position(), "wrapped").len(), 1);
|
||||||
|
assert_eq!(tasks.get_matching(tasks.get_position(), "new-i").len(), 1);
|
||||||
|
tasks.filter_or_create(None, "is gold");
|
||||||
|
assert_position!(tasks, id2);
|
||||||
|
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 3);
|
||||||
|
// Global match
|
||||||
|
assert_eq!(tasks.filter_or_create(None, "newer"), None);
|
||||||
|
assert_position!(tasks, id1.unwrap());
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 4);
|
||||||
|
assert_eq!(tasks.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_history() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
let zero = EventId::all_zeros();
|
||||||
|
|
||||||
|
tasks.track_at(Timestamp::now() - 3, Some(zero));
|
||||||
|
tasks.move_to(None);
|
||||||
|
assert_eq!(tasks.times_tracked(1).len(), 121);
|
||||||
|
let all = tasks.times_tracked(10);
|
||||||
|
assert_eq!(all.len(), 202, "{}", all);
|
||||||
|
assert!(all.contains(" 0000000000000000000000000000000000000000000000000000000000000000"), "{}", all);
|
||||||
|
assert!(all.ends_with(" ---"), "{}", all);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tracking() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
let zero = EventId::all_zeros();
|
||||||
|
|
||||||
|
tasks.track_at(Timestamp::from(0), None);
|
||||||
|
assert_eq!(tasks.history.len(), 0);
|
||||||
|
|
||||||
|
let almost_now: Timestamp = Timestamp::now() - 12u64;
|
||||||
|
tasks.track_at(Timestamp::from(11), Some(zero));
|
||||||
|
tasks.track_at(Timestamp::from(13), Some(zero));
|
||||||
|
assert_position!(tasks, zero);
|
||||||
|
assert!(tasks.time_tracked(zero) > almost_now.as_u64());
|
||||||
|
|
||||||
|
// Because None is backtracked by one to avoid conflicts
|
||||||
|
tasks.track_at(Timestamp::from(22 + 1), None);
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 2);
|
||||||
|
assert_eq!(tasks.time_tracked(zero), 11);
|
||||||
|
tasks.track_at(Timestamp::from(22 + 1), Some(zero));
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 3);
|
||||||
|
assert!(tasks.time_tracked(zero) > 999);
|
||||||
|
|
||||||
|
let some = tasks.make_task_unwrapped("some");
|
||||||
|
tasks.track_at(Timestamp::from(22 + 1), Some(some));
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 4);
|
||||||
|
assert_eq!(tasks.time_tracked(zero), 12);
|
||||||
|
assert!(tasks.time_tracked(some) > 999);
|
||||||
|
|
||||||
|
// TODO test received events
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_depth() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
|
||||||
|
let t1 = tasks.make_note("t1");
|
||||||
|
let activity_t1 = tasks.get_by_id(&t1).unwrap();
|
||||||
|
assert!(!activity_t1.is_task());
|
||||||
|
assert_eq!(tasks.view_depth, 0);
|
||||||
|
assert_eq!(activity_t1.pure_state(), State::Open);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 1);
|
||||||
|
tasks.search_depth = 0;
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 0);
|
||||||
|
tasks.recurse_activities = false;
|
||||||
|
assert_eq!(tasks.filtered_tasks(None, false).len(), 1);
|
||||||
|
|
||||||
|
tasks.move_to(Some(t1));
|
||||||
|
assert_position!(tasks, t1);
|
||||||
|
tasks.search_depth = 2;
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 0);
|
||||||
|
let t11 = tasks.make_task_unwrapped("t11 # tag");
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 1);
|
||||||
|
assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11");
|
||||||
|
assert_eq!(tasks.get_relative_path(t11), "t11");
|
||||||
|
let t12 = tasks.make_task_unwrapped("t12");
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 2);
|
||||||
|
|
||||||
|
tasks.move_to(Some(t11));
|
||||||
|
assert_position!(tasks, t11);
|
||||||
|
assert_eq!(tasks.viewed_tasks().len(), 0);
|
||||||
|
let t111 = tasks.make_task_unwrapped("t111");
|
||||||
|
assert_tasks_view!(tasks, [t111]);
|
||||||
|
assert_eq!(tasks.get_task_path(Some(t111)), "t1>t11>t111");
|
||||||
|
assert_eq!(tasks.get_relative_path(t111), "t111");
|
||||||
|
tasks.view_depth = 2;
|
||||||
|
assert_tasks_view!(tasks, [t111]);
|
||||||
|
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, EventId::all_zeros()).get_all().len(), 1);
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, EventId::all_zeros()).get_depth(0).len(), 1);
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(0).len(), 1);
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(1).len(), 3);
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(2).len(), 4);
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_depth(9).len(), 4);
|
||||||
|
assert_eq!(ChildrenTraversal::from(&tasks, t1).get_all().len(), 4);
|
||||||
|
|
||||||
|
tasks.move_up();
|
||||||
|
assert_position!(tasks, t1);
|
||||||
|
assert_eq!(tasks.get_own_events_history().count(), 3);
|
||||||
|
assert_eq!(tasks.get_relative_path(t111), "t11>t111");
|
||||||
|
assert_eq!(tasks.view_depth, 2);
|
||||||
|
tasks.set_search_depth(1);
|
||||||
|
assert_tasks_view!(tasks, [t111, t12]);
|
||||||
|
tasks.set_view_depth(0);
|
||||||
|
assert_tasks_view!(tasks, [t11, t12]);
|
||||||
|
tasks.set_view(vec![t11]);
|
||||||
|
assert_tasks_view!(tasks, [t11]);
|
||||||
|
tasks.set_view_depth(1);
|
||||||
|
assert_tasks_view!(tasks, [t111]);
|
||||||
|
tasks.set_search_depth(2); // resets view
|
||||||
|
assert_tasks_view!(tasks, [t111, t12]);
|
||||||
|
tasks.set_view_depth(0);
|
||||||
|
assert_tasks_view!(tasks, [t11, t12]);
|
||||||
|
|
||||||
|
tasks.move_to(None);
|
||||||
|
tasks.recurse_activities = true;
|
||||||
|
assert_tasks_view!(tasks, [t11, t12]);
|
||||||
|
tasks.recurse_activities = false;
|
||||||
|
assert_tasks_view!(tasks, [t1]);
|
||||||
|
tasks.view_depth = 1;
|
||||||
|
assert_tasks_view!(tasks, [t11, t12]);
|
||||||
|
tasks.view_depth = 2;
|
||||||
|
assert_tasks_view!(tasks, [t111, t12]);
|
||||||
|
tasks.view_depth = 9;
|
||||||
|
assert_tasks_view!(tasks, [t111, t12]);
|
||||||
|
|
||||||
|
tasks.add_tag("tag");
|
||||||
|
assert_eq!(tasks.get_prompt_suffix(), " #tag");
|
||||||
|
tasks.view_depth = 0;
|
||||||
|
assert_tasks_view!(tasks, [t11]);
|
||||||
|
tasks.search_depth = 0;
|
||||||
|
assert_eq!(tasks.view, []);
|
||||||
|
assert_tasks_view!(tasks, []);
|
||||||
|
|
||||||
|
// Upwards
|
||||||
|
tasks.move_to(Some(t111));
|
||||||
|
assert_eq!(tasks.get_task_path(tasks.get_position()), "t1>t11>t111");
|
||||||
|
assert_eq!(tasks.up_by(1), Some(t11));
|
||||||
|
assert_eq!(tasks.up_by(2), Some(t1));
|
||||||
|
assert_eq!(tasks.up_by(4), None);
|
||||||
|
tasks.move_to(Some(t12));
|
||||||
|
assert_eq!(tasks.up_by(1), Some(t1));
|
||||||
|
assert_eq!(tasks.up_by(2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_task_title_fallback_to_id() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
|
||||||
|
let empty = tasks.make_task_unchecked("", vec![]);
|
||||||
|
let empty_task = tasks.get_by_id(&empty).unwrap();
|
||||||
|
let empty_id = empty_task.get_id().to_string();
|
||||||
|
assert_eq!(empty_task.get_title(), empty_id);
|
||||||
|
assert_eq!(tasks.get_task_path(Some(empty)), empty_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_short_task() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
let str = " # one";
|
||||||
|
assert_eq!(extract_tags(str, &tasks.users), ("".to_string(), vec![to_hashtag_tag("one")]));
|
||||||
|
assert_eq!(tasks.make_task(str), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_task() {
|
||||||
|
let mut tasks = stub_tasks();
|
||||||
|
|
||||||
|
let zero = EventId::all_zeros();
|
||||||
|
assert_eq!(tasks.get_task_path(Some(zero)), zero.to_string());
|
||||||
|
tasks.move_to(Some(zero));
|
||||||
|
let dangling = tasks.make_task_unwrapped("test");
|
||||||
|
assert_eq!(
|
||||||
|
tasks.get_task_path(Some(dangling)),
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000000>test"
|
||||||
|
);
|
||||||
|
assert_eq!(tasks.get_relative_path(dangling), "test");
|
||||||
|
|
||||||
|
tasks.move_to(Some(dangling));
|
||||||
|
assert_eq!(tasks.up_by(0), Some(dangling));
|
||||||
|
assert_eq!(tasks.up_by(1), Some(zero));
|
||||||
|
assert_eq!(tasks.up_by(2), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // #[test]
|
||||||
|
fn test_itertools() {
|
||||||
|
use itertools::Itertools;
|
||||||
|
assert_eq!("test toast".split(' ').collect_vec().len(), 3);
|
||||||
|
assert_eq!("test toast".split_ascii_whitespace().collect_vec().len(), 2);
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue