Compare commits

...
Sign in to create a new pull request.

86 commits
main ... main

Author SHA1 Message Date
xeruf
29c104b96b release: 0.9.2 - assignees 2025-01-29 23:00:15 +01:00
xeruf
220d713675 refactor(tasks): debug outputs for task creation 2025-01-29 22:59:01 +01:00
xeruf
fd7819e979 feat: implement more explicit assignee logic 2025-01-29 22:58:18 +01:00
xeruf
fe6ac592be feat(tasks): set new task owner from context 2025-01-25 08:03:45 +01:00
xeruf
cbeba49bb3 enhance: use adaptive relative timestamp parsing for ) 2025-01-25 06:44:08 +01:00
xeruf
7e3039ef1a enhance(main): adapt relative timestamp parsing to allow backdating for recent hours 2025-01-25 06:42:41 +01:00
xeruf
0de4e2e55d fix(nostr_users): only keep latest user metadata 2025-01-23 15:25:56 +01:00
xeruf
3ed60c3457 feat(main): allow setting active key with MOSTR_ID 2025-01-23 15:23:35 +01:00
xeruf
7acdede38c fix(nostr_users): don't attach key to username
Somehow filtering consistently shows all tasks of same-named users anyways
2025-01-23 15:00:40 +01:00
xeruf
8c1902c1d3 enhance: sort users by newest first for name matching 2025-01-23 14:59:26 +01:00
xeruf
942e2fca75 docs(readme): some cli hints 2025-01-23 10:18:52 +01:00
xeruf
0d11c0a361 feat(main): use nip49 encryption for export and import and document migration 2025-01-22 15:18:40 +01:00
xeruf
d6d9a876a3 release: 0.9.0 - import, export, rust upgrade 2025-01-21 23:07:53 +01:00
xeruf
eb1bafad2d enhance(main): track relatively with @ 2025-01-21 22:54:23 +01:00
xeruf
828114f5de feat(main): export and import encrypted secret key 2025-01-20 23:02:18 +01:00
xeruf
2e76250edc fix(main): stopping past tracking with hour while stopped 2025-01-20 21:45:25 +01:00
xeruf
86010962a2 release: 0.8.0 - better tracking and error handling 2025-01-16 00:28:34 +01:00
xeruf
fb3a479147 feat(main): elaborately log failed event sends 2025-01-16 00:27:59 +01:00
xeruf
dcc7778815 fix: remove deprecated event send batching 2025-01-16 00:26:40 +01:00
xeruf
9ea491a301 fix: update to nostr sdk 0.38 2025-01-15 23:16:44 +01:00
xeruf
50503f7f66 refactor(tasks): rename current_pos to pos_at 2025-01-15 22:09:16 +01:00
xeruf
984e4f129d enhance(main): double notification channel size once more 2025-01-15 22:08:41 +01:00
xeruf
ee33086824 fix(helpers): interpretation of plain numbers as time
- leading zeroes are not ignored anymore
- no odd jump between 59 and 60
2025-01-15 22:08:15 +01:00
xeruf
a1347def62 fix(event_sender): undo of custom time event creation and entering
previously only the tracking would be undone after entering .NEW@TIME
2025-01-15 22:06:59 +01:00
xeruf
4769c12336 feat: relative hourstamp parsing 2024-12-23 02:48:31 +01:00
xeruf
1b065c434f feat(helpers): parse HHMM timestamps 2024-12-18 14:22:13 +01:00
xeruf
ae4a678d77 feat: improve times_tracked history display utility 2024-12-14 12:45:52 +01:00
xeruf
660d7b1815 build(ci): only capture exe on windows for upload 2024-12-07 01:10:28 +01:00
xeruf
d85ff3ac8d refactor(tasks): extract more modules 2024-12-06 23:28:06 +01:00
xeruf
77bc359d8a build(ci): recognize windows exe 2024-12-06 22:30:13 +01:00
xeruf
ced5c4b3ef style(main): rename relays_file variable 2024-12-06 22:14:09 +01:00
xeruf
932d07b893 test(examples): track various experiments 2024-12-06 22:12:05 +01:00
xeruf
76baed51e2 build: extract unused examples 2024-12-06 22:10:14 +01:00
xeruf
e8312959c3 build(ci): allow disabling keyring features 2024-12-06 21:40:26 +01:00
xeruf
1df75055df build(ci): add artifact upload 2024-12-06 21:16:39 +01:00
xeruf
cdf3d3a805 build(ci): add github ci 2024-12-06 20:57:47 +01:00
xeruf
29ef9634a4 docs(readme): add collaboration convention 2024-12-06 20:52:11 +01:00
xeruf
cf04d4d852 refactor(tasks): simplify up_to back 2024-12-06 20:47:47 +01:00
xeruf
0c6ad19600 test(tasks): extract tests from tasks 2024-12-06 20:45:58 +01:00
xeruf
6f2a7951d5 refactor: modularize task 2024-12-06 13:52:01 +01:00
xeruf
03fd79ad95 refactor(tasks): more descriptive variable names 2024-12-06 12:01:00 +01:00
xeruf
6362718aa7 docs(readme): reorder for gentler introduction 2024-12-06 12:00:17 +01:00
xeruf
cdf75cda24 release: 0.7.1 - improved user key interactivity 2024-12-05 23:04:57 +01:00
xeruf
e1c1b1d4f6 fix(main): consistent history label italics 2024-12-05 23:02:22 +01:00
xeruf
6fc8b42bcc fix(tasks): never create empty task 2024-12-05 23:00:45 +01:00
xeruf
0dba23bcc6 style: reformat code 2024-12-05 14:56:26 +01:00
xeruf
db11b54220 fix(kinds): key tag formatting 2024-12-05 14:34:27 +01:00
xeruf
df598efdc3 fix(tasks): view pubkey tracking history reliably 2024-12-05 13:18:21 +01:00
xeruf
d159004340 feat: also match user filter to hashtag and position better in prompt 2024-11-25 14:23:05 +01:00
xeruf
591adafd6e feat(task): list all participants 2024-11-25 14:07:08 +01:00
xeruf
ca263b50d2 fix(tasks): filter by owner instead of creator 2024-11-25 10:44:25 +01:00
xeruf
e95a14ae89 release: 0.7.0 - task reassignment, user interaction and more helpful feedback 2024-11-25 02:33:32 +01:00
xeruf
a3eeb38e5f fix(tasks): fallback upon invalid regex 2024-11-25 02:30:56 +01:00
xeruf
3a4588b45d feat: display task owner 2024-11-25 02:29:23 +01:00
xeruf
6ef5c47e98 feat: assign users to task 2024-11-25 02:15:18 +01:00
xeruf
87392fccb6 feat: guidance for moving backward 2024-11-25 01:45:18 +01:00
xeruf
78438696ac refactor: create own struct for nostr relay users 2024-11-24 23:42:47 +01:00
xeruf
654f273ad9 fix(tasks): fix pubkey filtering to show all activities instead of all tasks 2024-11-24 23:14:35 +01:00
xeruf
cb15fbaac5 enhance(tasks): feedback about ask movement with custom time 2024-11-24 23:11:19 +01:00
xeruf
a8fb3f919d enhance(tasks): improve state labels 2024-11-24 08:51:54 +01:00
xeruf
044c853993 enhance(tasks): show displayname rather than username where appropriate 2024-11-24 08:47:57 +01:00
xeruf
b26d64646c fix(main): do not create empty notes 2024-11-23 12:11:31 +01:00
xeruf
7ecfa6e810 feat(task): get state at specific time 2024-11-23 08:47:54 +01:00
xeruf
fe0b59ef65 feat(main): use whoami crate to get full name, also on windows 2024-11-22 13:37:19 +01:00
xeruf
031d9a3b69 release: 0.6.3 - enhance quick access 2024-11-22 11:24:40 +01:00
xeruf
58117b901a style: clean up code formatting and add notes 2024-11-22 11:22:28 +01:00
xeruf
29476e60ad fix: flush more liberally 2024-11-22 11:21:04 +01:00
xeruf
1a7b65fe1c fix(tasks): priority filtering for quick access with exhaustive tests 2024-11-22 11:20:13 +01:00
xeruf
94976905d3 fix(tasks): properly test quick access bookmarks and view 2024-11-22 10:06:50 +01:00
xeruf
0cf354942e feat(tasks): show activities to everyone 2024-11-22 09:29:50 +01:00
xeruf
a6b611312b fix(tasks): correct task hints when showing sole details 2024-11-22 09:28:46 +01:00
xeruf
bd32e61212 refactor: revamp visible task algorithm 2024-11-22 00:04:19 +01:00
xeruf
5cd82e8581 enhance: display more accurate time tracking prefixes 2024-11-21 23:59:30 +01:00
xeruf
eea8511a6e feat: enable finding user by partial key and name 2024-11-21 21:18:26 +01:00
xeruf
5032b4db93 refactor(tasks): omit empty descriptions
preparation for state update notes
2024-11-21 10:56:52 +01:00
xeruf
fc97b513c4 release: 0.6.2 - case-insensitive hashtags and pubkey filtering 2024-11-21 09:48:04 +01:00
xeruf
9c92a19cde fix: make hashtag behaviour more consistent 2024-11-21 09:47:14 +01:00
xeruf
0a7685d907 feat: make hashtags case-insensitive 2024-11-21 09:17:56 +01:00
xeruf
20fc8f9a3a refactor: rename set_filter_from to since 2024-11-20 23:28:06 +01:00
xeruf
1f13c45831 feat: easy reset to own pubkey filter 2024-11-20 23:28:06 +01:00
xeruf
e320523fc0 feat: enable setting pubkey as context and auto-filter for own 2024-11-20 23:28:06 +01:00
xeruf
b87970d4e2 release: version 0.6.1 back to rust 1.82.0 2024-11-20 23:27:29 +01:00
xeruf
2ce5801925 fix: task descriptions ordering 2024-11-20 23:10:28 +01:00
xeruf
ca50bdf3bb feat: enhance display of task history 2024-11-20 19:34:40 +01:00
xeruf
9eb6138852 enhance(tasks): current task description 2024-11-20 19:06:32 +01:00
xeruf
88ecd68eb8 refactor: rename and document a few task methods 2024-11-20 19:05:33 +01:00
24 changed files with 2847 additions and 1960 deletions

57
.github/workflows/ci.yml vendored Normal file
View 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 }}

4
.gitignore vendored
View file

@ -1,7 +1,3 @@
/target
/examples
/.idea
relays
keys
*.html

1166
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
readme = "README.md"
license = "GPL 3.0"
authors = ["melonion"]
version = "0.6.0"
version = "0.9.2"
rust-version = "1.82"
edition = "2021"
default-run = "mostr"
@ -13,26 +13,30 @@ default-run = "mostr"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
nostr-sdk = { version = "0.38", features = ["nip49"] }
# Basics
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] }
regex = "1.10.6"
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
regex = "1.11"
# System
log = "0.4"
env_logger = "0.11"
colog = "1.3"
colored = "2.1"
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
colored = "2.2"
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "5364854" }
# OS-Specific Abstractions
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
keyring = "3"
directories = "5.0"
whoami = "1.5"
# slint = "1.8"
# Application Utils
itertools = "0.12"
chrono = "0.4"
parse_datetime = "0.5.0"
parse_datetime = "0.5"
interim = { version = "0.1", features = ["chrono"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
[dev-dependencies]
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
chrono-english = "0.1"
linefeed = "0.6"
mostr = { path = ".", default-features = false }
[features]
default = ["persistence"]
persistence = ["keyring/apple-native", "keyring/windows-native", "keyring/linux-native-sync-persistent", "keyring/crypto-rust"]

181
README.md
View file

@ -10,13 +10,9 @@ An immutable nested collaborative task manager, powered by nostr!
## Quickstart
First, start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use
Install rust(up) and run a development build with:
cargo run
cargo run -- ARGS
A `relay` list can be placed in a config file
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
@ -31,14 +27,93 @@ Install latest build:
cargo install --path .
This one-liner can help you stay on the latest version
(optionally add a `cd` to your mostr-directory in front):
(optionally add a `cd` to your mostr-directory in front to use it anywhere):
git pull && cargo install --path . && mostr
Creating a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
To exit mostr, press `Ctrl-D`.
To exit the application, 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
@ -73,6 +148,10 @@ should be grouped with a tag instead.
Similarly for projects which are only sporadically worked on
when a specific task comes up, so they do not clutter the list.
### Task States
> TODO: Mark as Done vs Closed
### Collaboration
Since everything in mostr is inherently immutable,
@ -134,66 +213,27 @@ If you want to TBC...
- Pin to bookmarks
- high priority
## Reference
### Command Syntax
`TASK` creation syntax: `NAME #TAG *PRIO # 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.
- `:[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 timetracking with the specified offset (double to view all history)
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
- `!TIME: REASON` - defer current task to date
- `,[TEXT]` - list notes or add text (activity / task description)
- TBI: `;[TEXT]` - list comments or comment on task
- TBI: show status history and creation with attribution
- `&` - revert
- with string argument, find first matching task in history
- with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions)
- `*` - (un)bookmark current task or list all bookmarks
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters:
- `#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.
### Notes
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one
# Development and Contributions
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.
## Local Development Tools
Start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- 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
- Handle event sending rejections (e.g. permissions)
@ -209,8 +249,7 @@ An active tag or status filter will also set that attribute for newly created ta
### Commands
- Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
Open Command characters: `_^\=$%~'"`, `{}[]`
### Conceptual
@ -221,7 +260,7 @@ Suggestions welcome!
- Queueing tasks
- Allow adding new parent via description?
- 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?
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Templates
@ -236,13 +275,19 @@ Suggestions welcome!
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar
- n8n node
- Webcal Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward
- Webcal Feed: Scheduled (planning) / Tracked (events, time-tracking) with args for how far back/forward
Interfaces:
- text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/
### 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

22
examples/question.rs Normal file
View 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);
}
}

View 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
View 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
View 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(())
}

View file

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

View file

@ -13,22 +13,21 @@ const UNDO_DELAY: u64 = 60;
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum MostrMessage {
Flush,
NewRelay(Url),
AddTasks(Url, Vec<Event>),
NewRelay(RelayUrl),
SendTask(RelayUrl, Event),
}
type Events = Vec<Event>;
#[derive(Debug, Clone)]
pub(crate) struct EventSender {
pub(crate) url: Option<Url>,
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<Url>, tx: &Sender<MostrMessage>, keys: &Keys) -> Self {
pub(crate) fn from(url: Option<RelayUrl>, tx: &Sender<MostrMessage>, keys: &Keys) -> Self {
EventSender {
url,
tx: tx.clone(),
@ -39,45 +38,38 @@ impl EventSender {
// TODO this direly needs testing
pub(crate) fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
let min = Timestamp::now().sub(UNDO_DELAY);
let event = event_builder.sign_with_keys(&self.keys)?;
let time = event.created_at;
{
// Always flush if oldest event older than a minute or newer than now
// 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 < min || e.created_at > Timestamp::now())
.any(|e| e.created_at < time.sub(UNDO_DELAY) || e.created_at > time)
{
drop(borrow);
debug!("Flushing event queue because it is older than a minute");
debug!("Flushing event queue because it is offset from the current event");
self.force_flush();
}
}
let mut queue = self.queue.borrow_mut();
Ok(event_builder.sign_with_keys(&self.keys).inspect(|event| {
if event.kind == TRACKING_KIND
&& event.created_at > min
&& event.created_at < tasks::now()
{
// Do not send redundant movements
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
fn force_flush(&self) {
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| {
self.tx
.try_send(MostrMessage::AddTasks(url.clone(), values))
.err()
.map(|e| {
error!(
"Nostr communication thread failure, changes will not be persisted: {}",
e
)
})
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

99
src/hashtag.rs Normal file
View 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());
}

View file

@ -1,5 +1,3 @@
use std::ops::Sub;
use chrono::LocalResult::Single;
use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc};
use log::{debug, error, info, trace, warn};
@ -34,15 +32,24 @@ impl<T: TimeZone> ToTimestamp for DateTime<T> {
}
}
/// Parses the hour from a plain number in the String,
/// Parses the hour optionally with minute from a plain number in a String,
/// with max of max_future hours into the future.
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
str.parse::<u32>().ok().and_then(|hour| {
let now = Local::now();
parse_hour_after(str, Local::now() - TimeDelta::hours(24 - max_future))
}
/// 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)]
now.date().and_hms_opt(hour, 0, 0).map(|time| {
if time - now > TimeDelta::hours(max_future) {
time.sub(TimeDelta::days(1))
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
}
@ -51,11 +58,15 @@ pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
}
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
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()),
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()),
Err(_) => {
warn!("Could not parse date from \"{str}\": {e}");
@ -75,15 +86,16 @@ pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
}
/// Turn a human-readable relative timestamp into a nostr Timestamp.
/// - Plain number as hour, 18 hours back or 6 hours forward
/// - 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) -> Option<Timestamp> {
if let Some(num) = parse_hour(str, 6) {
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 ");
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)));
}
parse_date(str).and_then(|time| {
@ -122,10 +134,8 @@ pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where
F: Fn(DateTime<Local>) -> String,
{
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
Single(time) => formatter(time),
_ => stamp.to_human_datetime().to_string(),
}
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
@ -146,6 +156,11 @@ 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 {
@ -154,3 +169,49 @@ pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) ->
_ => format_timestamp_local(stamp),
}
}
mod test {
use super::*;
use chrono::{FixedOffset, NaiveDate, Timelike};
use interim::datetime::DateTime;
#[test]
fn parse_hours() {
let now = Local::now();
#[allow(deprecated)]
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()
);
}
}

View file

@ -1,7 +1,8 @@
use crate::task::MARKER_PARENT;
use crate::tasks::NostrUsers;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools;
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard};
use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue;
@ -41,10 +42,11 @@ Task:
- `hashtags` - list of hashtags set for the task
- `tags` - values of all nostr tags associated with the event, except event tags
- `desc` - last note on the task
- `description` - accumulated notes on the task
- `description` - all notes on the task
- `time` - time tracked on this task by you
Utilities:
- `state` - indicator of current progress
- `owner` - author or task assignee
- `rtime` - time tracked on this tasks and its subtree by everyone
- `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete
@ -78,7 +80,8 @@ where
.tags(id.into_iter().map(Tag::event))
}
pub fn join<'a, T>(tags: T) -> String
/// Formats and joins the tags with commata
pub fn join_tags<'a, T>(tags: T) -> String
where
T: IntoIterator<Item=&'a Tag>,
{
@ -90,80 +93,85 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
input.split_ascii_whitespace()
.filter(|s| s.starts_with('#'))
.map(|s| s.trim_start_matches('#'))
.map(to_hashtag)
.map(to_hashtag_tag)
}
/// Extracts everything after a " # " as a list of tags
/// as well as various embedded tags.
///
/// Expects sanitized input.
pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
pub(crate) fn extract_tags(input: &str, users: &NostrUsers) -> (String, Vec<Tag>) {
let words = input.split_ascii_whitespace();
let mut prio = None;
let mut tags = Vec::with_capacity(4);
let result = words.filter(|s| {
if s.starts_with('*') {
if s.len() == 1 {
prio = Some(HIGH_PRIO);
if s.starts_with('@') {
if let Ok(key) = PublicKey::parse(&s[1..]) {
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;
}
return match s[1..].parse::<Prio>() {
Ok(num) => {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
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,
};
}
true
}).collect_vec();
let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag(&s)))
.chain(prio.map(|p| to_prio_tag(p)))
.chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(tags)
.collect_vec();
tags.sort();
tags.dedup();
(main, tags)
}
pub fn to_hashtag(tag: &str) -> Tag {
pub fn to_hashtag_tag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into()
}
fn format_tag(tag: &Tag) -> String {
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() {
Some(TagStandard::PublicKey {
public_key,
alias,
..
}) => format!("Key{}: {:.8}", public_key, 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)) =>
format!("#{content}"),
_ => tag.as_slice().join(" ")
}
}
pub(crate) fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
}
pub(crate) fn to_prio_tag(value: Prio) -> Tag {
pub fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
}
#[test]
fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it"),
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)).collect()));
assert_eq!(extract_tags("So tagless #"),
("So tagless".to_string(), vec![]));
.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
View file

View file

@ -1,31 +1,36 @@
use std::collections::{HashMap, VecDeque};
use std::env::{args, var};
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::iter::once;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use crate::event_sender::MostrMessage;
use crate::hashtag::Hashtag;
use crate::helpers::*;
use crate::kinds::{join, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS};
use crate::task::{State, Task, TaskState, MARKER_PROPERTY};
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay};
use chrono::Local;
use crate::kinds::{format_tag_basic, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS};
use crate::task::{State, StateChange, Task, MARKER_PROPERTY};
use crate::tasks::{referenced_event, PropertyCollection, StateFilter, TasksRelay};
use chrono::{DateTime, Local, TimeDelta, TimeZone, Utc};
use colored::Colorize;
use directories::ProjectDirs;
use env_logger::{Builder, Target, WriteStyle};
use itertools::Itertools;
use keyring::Entry;
use keyring::Error::NoEntry;
use log::{debug, error, info, trace, warn, LevelFilter};
use nostr_sdk::bitcoin::hex::DisplayHex;
use nostr_sdk::prelude::*;
use nostr_sdk::TagStandard::Hashtag;
use nostr_sdk::serde_json::Serializer;
use regex::Regex;
use rustyline::config::Configurer;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::collections::{HashMap, VecDeque};
use std::env;
use std::env::{args};
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::iter::once;
use std::ops::Add;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::error::Elapsed;
use tokio::time::timeout;
@ -35,8 +40,8 @@ mod task;
mod tasks;
mod kinds;
mod event_sender;
mod hashtag;
const INACTVITY_DELAY: u64 = 200;
const LOCAL_RELAY_NAME: &str = "TEMP";
/// Turn a Result into an Option, showing a warning on error with optional prefix
@ -61,13 +66,13 @@ macro_rules! or_warn {
}
}
fn read_keys(readline: &mut DefaultEditor) -> Result<Keys> {
let keys_entry = Entry::new("mostr", "keys")?;
fn read_keys(keys_entry: &Entry, readline: &mut DefaultEditor) -> Result<Keys> {
if let Ok(pass) = keys_entry.get_secret() {
return Ok(SecretKey::from_slice(&pass).map(|s| Keys::new(s))
.inspect_err(|e| eprintln!("Invalid key in keychain: {e}"))?);
}
let line = readline.readline("Secret key? (leave blank to generate and save a new keypair) ")?;
let line = read_password(readline, "Secret key? (leave blank to generate and save a new keypair) ")?;
let keys = if line.is_empty() {
info!("Generating and persisting new key");
Keys::generate()
@ -75,18 +80,30 @@ fn read_keys(readline: &mut DefaultEditor) -> Result<Keys> {
Keys::from_str(&line)
.inspect_err(|e| eprintln!("Invalid key provided: {e}"))?
};
or_warn!(keys_entry.set_secret(keys.secret_key().as_secret_bytes()),
"Could not persist keys");
if let Err(e) = keys_entry.set_secret(keys.secret_key().as_secret_bytes()) {
if line.is_empty() {
return Err(e.into());
} else {
warn!("Could not persist keys: {}", e)
}
}
Ok(keys)
}
fn read_password(readline: &mut DefaultEditor, prompt: &str) -> Result<String> {
let line = readline.readline(prompt)?;
Ok(line)
}
#[tokio::main]
async fn main() -> Result<()> {
println!("Running Mostr Version {}", env!("CARGO_PKG_VERSION"));
let mut debug = false;
let mut args = args().skip(1).peekable();
let mut builder = if args.peek().is_some_and(|arg| arg == "--debug") {
debug = true;
args.next();
let mut builder = Builder::new();
builder.filter(None, LevelFilter::Debug)
@ -101,7 +118,6 @@ async fn main() -> Result<()> {
};
let mut rl = DefaultEditor::new()?;
rl.set_auto_add_history(true);
or_warn!(
rl.create_external_writer().map(
|wr| builder
@ -133,24 +149,53 @@ async fn main() -> Result<()> {
.inspect(|_| { or_warn!(fs::remove_file(key_file)); }));
}
let keys = read_keys(&mut rl)?;
let relayfile = config_dir.join("relays");
let keys_entry = Entry::new("mostr", &env::var("MOSTR_ID").unwrap_or("keys".to_string()))?;
let keys =
if args.peek().is_some_and(|arg| arg.trim_start_matches('-') == "import") {
args.next();
let key = rl.readline("Enter your encrypted secret key: ")?;
let sanitized_key: String = key.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let encrypted_key = EncryptedSecretKey::from_bech32(&sanitized_key)?;
let enc_pwd = read_password(&mut rl, "Please enter the encryption password you used: ")?;
let keys = Keys::new(encrypted_key.to_secret_key(&enc_pwd)?);
if keys_entry.get_secret().is_err_and(|e| matches!(e, NoEntry)) ||
rl.readline(&format!("Override stored key with given keypair, public key: {} (y/n)? ", keys.public_key()))? == "y" {
keys_entry.set_secret(keys.secret_key().as_secret_bytes())?;
}
keys
} else {
read_keys(&keys_entry, &mut rl)?
};
info!("My active public key: {}", keys.public_key());
if args.peek().is_some_and(|arg| arg.trim_start_matches('-') == "export") {
let enc_pwd = read_password(&mut rl, "Please enter an encryption password for your secret key: ")?;
println!("Your encrypted key: {}", EncryptedSecretKey::new(keys.secret_key(), enc_pwd, 9, KeySecurity::Unknown)?.to_bech32()?);
if rl.readline("Do you want to erase your stored secret keys (y/n)? ")? == "y" {
keys_entry.delete_credential()?;
}
// TODO optionally delete
return Ok(());
}
let client = ClientBuilder::new()
.opts(Options::new()
.automatic_authentication(true)
.notification_channel_size(8192)
.notification_channel_size(16384)
)
.signer(keys.clone())
.build();
info!("My public key: {}", keys.public_key());
let relays_file = config_dir.join("relays");
// TODO use NewRelay message for all relays
match var("MOSTR_RELAY") {
match env::var("MOSTR_RELAY") {
Ok(relay) => {
or_warn!(client.add_relay(relay).await);
}
_ => match File::open(&relayfile).map(|f| BufReader::new(f).lines().flatten()) {
_ => match File::open(&relays_file).map(|f| BufReader::new(f).lines().flatten()) {
Ok(lines) => {
for line in lines {
or_warn!(client.add_relay(line).await);
@ -166,7 +211,7 @@ async fn main() -> Result<()> {
};
or_warn!(client.add_relay(url.clone()).await).map(|bool| {
if bool {
or_warn!(fs::write(&relayfile, url));
or_warn!(fs::write(&relays_file, url));
}
});
};
@ -199,26 +244,22 @@ async fn main() -> Result<()> {
}
}
let metadata = var("USER").ok().map(
|user| Metadata::new().name(user));
let moved_metadata = metadata.clone();
let metadata = Metadata::new()
.name(whoami::username())
.display_name(whoami::realname());
let metadata_clone = metadata.clone();
let (tx, mut rx) = mpsc::channel::<MostrMessage>(64);
let tasks_for_url = |url: Option<Url>| TasksRelay::from(url, &tx, &keys, metadata.clone());
let mut relays: HashMap<Option<Url>, TasksRelay> =
let tasks_for_url = |url: Option<RelayUrl>| TasksRelay::from(url, &tx, &keys, Some(metadata.clone()));
let mut relays: HashMap<Option<RelayUrl>, TasksRelay> =
client.relays().await.into_keys().map(|url| (Some(url.clone()), tasks_for_url(Some(url)))).collect();
let sender = tokio::spawn(async move {
let mut queue: Option<(Url, Vec<Event>)> = None;
or_warn!(client.set_metadata(&metadata_clone).await, "Unable to set metadata");
if let Some(meta) = moved_metadata.as_ref() {
or_warn!(client.set_metadata(meta).await, "Unable to set metadata");
}
'repl: loop {
let result_received = timeout(Duration::from_secs(INACTVITY_DELAY), rx.recv()).await;
match result_received {
Ok(Some(MostrMessage::NewRelay(url))) => {
'receiver: loop {
match rx.recv().await {
Some(MostrMessage::NewRelay(url)) => {
if client.add_relay(&url).await.unwrap() {
match client.connect_relay(&url).await {
Ok(()) => info!("Connected to {url}"),
@ -228,48 +269,39 @@ async fn main() -> Result<()> {
warn!("Relay {url} already added");
}
}
Ok(Some(MostrMessage::AddTasks(url, mut events))) => {
trace!("Queueing {:?}", &events);
if let Some((queue_url, mut queue_events)) = queue {
if queue_url == url {
queue_events.append(&mut events);
queue = Some((queue_url, queue_events));
} else {
info!("Sending {} events to {queue_url} due to relay change", queue_events.len());
client.batch_event_to(vec![queue_url], queue_events).await;
queue = None;
Some(MostrMessage::SendTask(url, event)) => {
trace!("Sending {:?}", &event);
let id = event.id;
let url_str = url.as_str_without_trailing_slash().to_string();
if let Err(e) = client.send_event_to(vec![url], event.clone()).await {
let url_s = url_str.split("//").last().map(ToString::to_string).unwrap_or(url_str);
if debug {
debug!("Error sending event: {:?}", e);
continue 'receiver;
}
let path = format!("failed-events-{}/", url_s);
let dir = fs::create_dir_all(&path).map(|_| path).unwrap_or("".to_string());
let filename = dir.to_string() + &id.to_string();
match File::create(&filename).and_then(|mut f|
f.write_all(or_warn!(serde_json::to_string_pretty(&event), "Failed serializing event for file writing").unwrap_or(String::new()).as_bytes())) {
Ok(_) => error!("Failed sending update, saved a copy at {filename}: {:?}", e),
Err(fe) => error!("Failed sending update {:?} and saving copy of event {:?}", e, fe),
}
}
if queue.is_none() {
events.reserve(events.len() + 10);
queue = Some((url, events))
}
}
Ok(Some(MostrMessage::Flush)) | Err(Elapsed { .. }) => if let Some((url, events)) = queue {
info!("Sending {} events to {url} due to {}", events.len(),
result_received.map_or("inactivity", |_| "flush message"));
client.batch_event_to(vec![url], events).await;
queue = None;
}
Ok(None) => {
None => {
debug!("Finalizing nostr communication thread because communication channel was closed");
break 'repl;
break 'receiver;
}
}
}
if let Some((url, events)) = queue {
info!("Sending {} events to {url} before exiting", events.len());
client.batch_event_to(vec![url], events).await;
}
info!("Shutting down nostr communication thread");
});
if relays.is_empty() {
relays.insert(None, tasks_for_url(None));
}
let mut selected_relay: Option<Url> = relays.keys()
.find_or_first(|url| url.as_ref().is_some_and(|u| u.scheme() == "wss"))
.unwrap().clone();
let mut selected_relay: Option<RelayUrl> = relays.keys().next().unwrap().clone();
{
let tasks = relays.get_mut(&selected_relay).unwrap();
@ -278,12 +310,14 @@ async fn main() -> Result<()> {
}
}
rl.set_auto_add_history(true);
'repl: loop {
println!();
let tasks = relays.get(&selected_relay).unwrap();
let prompt = format!(
"{} {}{}{}",
"{}{} {}{}{}",
selected_relay.as_ref().map_or(LOCAL_RELAY_NAME.to_string(), |url| url.to_string()).dimmed(),
tasks.pubkey_str().map_or(String::new(), |s| format!(" @{s}")),
tasks.get_task_path(tasks.get_position()).bold(),
tasks.get_prompt_suffix().italic(),
" ".dimmed()
@ -327,12 +361,12 @@ async fn main() -> Result<()> {
continue 'repl;
}
Some('@') => {}
Some(_) => {
Some(_) =>
if let Some((left, arg)) = command.split_once("@") {
if let Some(time) = parse_hour(arg, 20)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local))) {
if !arg.contains(|s: char| s.is_alphabetic()) {
if let Some(time) = tasks.parse_tracking_stamp_relative(arg) {
command = left.to_string();
tasks.custom_time = Some(time.to_timestamp());
tasks.custom_time = Some(time);
}
}
}
@ -385,8 +419,11 @@ async fn main() -> Result<()> {
match arg {
None => {
if let Some(task) = tasks.get_current_task() {
for e in once(&task.event).chain(task.props.iter().rev()) {
let content = match State::try_from(e.kind) {
println!("Change History for {}:", task.get_id());
for e in task.all_events() {
println!("{} {} [{}]",
format_timestamp_full(&e.created_at),
match State::try_from(e.kind) {
Ok(state) => {
format!("State: {state}{}",
if e.content.is_empty() { String::new() } else { format!(" - {}", e.content) })
@ -394,11 +431,17 @@ async fn main() -> Result<()> {
Err(_) => {
e.content.to_string()
}
};
println!("{} {} [{}]",
format_timestamp_local(&e.created_at),
content,
join(e.tags.iter().filter(|t| match_event_tag(t).unwrap().marker.is_none_or(|m| m != MARKER_PROPERTY))));
},
e.tags.iter().filter_map(|t| {
match match_event_tag(t) {
Some(et) =>
Some(et).take_if(|et| et.marker.as_ref().is_some_and(|m| m != MARKER_PROPERTY))
.map(|et| format!("{}: {}", et.marker.as_ref().unwrap(), tasks.get_relative_path(et.id))),
None =>
Some(format_tag_basic(t)),
}
}).join(", ")
)
}
continue 'repl;
} else {
@ -408,8 +451,8 @@ async fn main() -> Result<()> {
}
}
Some(arg) => {
if arg.len() < CHARACTER_THRESHOLD {
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
if arg.trim().len() < 2 {
warn!("Needs at least 2 characters!");
continue 'repl;
}
tasks.make_note(arg);
@ -429,7 +472,28 @@ async fn main() -> Result<()> {
Some('&') => {
match arg {
None => tasks.undo(),
Some(text) => match text.parse::<u8>() {
Some(text) => {
if text == "&" {
println!(
"My History:\n{}",
tasks.history_before_now()
.take(9)
.enumerate()
.dropping(1)
.map(|(c, e)| {
format!("({}) {}",
c,
match referenced_event(e) {
Some(target) => tasks.get_task_path(Some(target)),
None => "---".to_string(),
},
)
})
.join("\n")
);
continue 'repl;
}
match text.parse::<u8>() {
Ok(int) => {
tasks.move_back_by(int as usize);
}
@ -442,39 +506,37 @@ async fn main() -> Result<()> {
}
}
}
}
Some('@') => {
let success = match arg {
match arg {
None => {
let today = Timestamp::now() - 80_000;
info!("Filtering for tasks from the last 22 hours");
tasks.set_filter_from(today)
if !tasks.set_filter_since(today) {
continue 'repl;
}
}
Some(arg) => {
if arg == "@" {
info!("Filtering for own tasks");
tasks.set_filter_author(keys.public_key())
} else if let Ok(key) = PublicKey::from_str(arg) {
let author = tasks.get_username(&key);
info!("Filtering for tasks by {author}");
tasks.set_filter_author(key)
} else if let Some((key, meta)) = tasks.find_user(arg) {
info!("Filtering for tasks by {}", meta.display_name.as_ref().unwrap_or(meta.name.as_ref().unwrap_or(&key.to_string())));
tasks.set_filter_author(key.clone())
tasks.reset_key_filter()
} else if let Some((key, name)) = tasks.find_user(arg) {
info!("Showing {}'s tasks", name);
tasks.set_key_filter(key)
} else {
parse_hour(arg, 1)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
if parse_hour(arg, 1)
.or_else(|| parse_date(arg)
.map(|utc| utc.with_timezone(&Local)))
.map(|time| {
info!("Filtering for tasks from {}", format_datetime_relative(time));
tasks.set_filter_from(time.to_timestamp())
tasks.set_filter_since(time.to_timestamp())
})
.unwrap_or(false)
.is_none_or(|b| !b) {
continue 'repl;
}
}
}
};
if !success {
continue 'repl;
}
}
Some('*') => {
@ -538,7 +600,7 @@ async fn main() -> Result<()> {
match tasks.get_position() {
None => {
warn!("First select a task to set its state!");
info!("Usage: ![(Open|Procedure|Pending|Done|Closed): ][Statename]");
info!("Usage: ![(Open|Procedure|Pending|Done|Closed): ][Statename] OR Time: Reason");
}
Some(id) => {
'block: {
@ -555,8 +617,8 @@ async fn main() -> Result<()> {
tasks.set_state_for(id, right, State::Pending);
tasks.custom_time = Some(stamp);
tasks.set_state_for(id,
&state.as_ref().map(TaskState::get_label).unwrap_or_default(),
state.map(|ts| ts.state).unwrap_or(State::Open));
&state.as_ref().map(StateChange::get_label).unwrap_or_default(),
State::from(state));
break 'block;
}
}
@ -568,14 +630,14 @@ async fn main() -> Result<()> {
}
Some('#') => {
if !tasks.update_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())) {
if !tasks.update_tags(arg_default.split_whitespace().map(Hashtag::from)) {
continue;
}
}
Some('+') =>
match arg {
Some(arg) => tasks.add_tag(arg.to_string()),
Some(arg) => tasks.add_tag(arg),
None => {
tasks.print_hashtags();
if tasks.has_tag_filter() {
@ -602,32 +664,17 @@ async fn main() -> Result<()> {
Err(e) => warn!("Ignoring extra {:?}: {}\nSyntax: ((INT", remaining, e),
}
}
let (label, times) = tasks.times_tracked();
let vec = times.rev().take(max).collect_vec();
println!("{}\n{}",
if vec.is_empty() {
label
} else {
format!("{} {}",
if max == usize::MAX { "All".to_string() } else { format!("Latest {max} entries of") },
label)
},
vec.iter().rev().join("\n"));
} else if let Ok(key) = PublicKey::parse(arg) { // TODO also match name
println!("{}", tasks.times_tracked(max));
} else if let Some((key, _)) = tasks.find_user(arg) {
let (label, mut times) = tasks.times_tracked_for(&key);
println!("{}\n{}", label.italic(),
times.join("\n"));
println!("{}\n{}", label.italic(), times.join("\n"));
} else {
if tasks.track_from(arg) {
let (label, times) = tasks.times_tracked();
println!("{}\n{}", label.italic(),
times.rev().take(15).collect_vec().iter().rev().join("\n"));
println!("{}", tasks.times_tracked(15));
}
}
} else {
let (label, times) = tasks.times_tracked();
println!("{}\n{}", label.italic(),
times.rev().take(80).collect_vec().iter().rev().join("\n"));
println!("{}", tasks.times_tracked(60));
}
continue 'repl;
}
@ -636,10 +683,9 @@ async fn main() -> Result<()> {
match arg {
None => tasks.move_to(None),
Some(arg) => {
if parse_tracking_stamp(arg).and_then(|stamp| tasks.track_at(stamp, None)).is_some() {
let (label, times) = tasks.times_tracked();
println!("{}\n{}", label.italic(),
times.rev().take(15).collect_vec().iter().rev().join("\n"));
if tasks.parse_tracking_stamp_relative(arg)
.and_then(|stamp| tasks.track_at(stamp, None)).is_some() {
println!("{}", tasks.times_tracked(15));
}
// So the error message is not covered up
continue 'repl;
@ -667,7 +713,8 @@ async fn main() -> Result<()> {
tasks.set_view_depth(depth);
}
_ => {
tasks.filter_or_create(pos, &remaining).map(|id| tasks.move_to(Some(id)));
tasks.filter_or_create(pos, &remaining)
.map(|id| tasks.move_to(Some(id)));
}
}
}
@ -699,9 +746,9 @@ async fn main() -> Result<()> {
let filtered =
tasks.get_filtered(pos, |t| {
transform(&t.event.content).contains(&remaining) ||
t.get_hashtags().any(
|tag| tag.content().is_some_and(|s| transform(s).contains(&remaining)))
transform(&t.get_title()).contains(&remaining) ||
t.list_hashtags().any(
|tag| tag.contains(&remaining))
});
if filtered.len() == 1 {
tasks.move_to(filtered.into_iter().next());
@ -721,7 +768,7 @@ async fn main() -> Result<()> {
println!("{}", tasks);
continue 'repl;
}
or_warn!(Url::parse(&command), "Failed to parse url {}", command).map(|url| {
or_warn!(RelayUrl::parse(&command), "Failed to parse url {}", command).map(|url| {
match tx.try_send(MostrMessage::NewRelay(url.clone())) {
Err(e) => error!("Nostr communication thread failure, cannot add relay \"{url}\": {e}"),
Ok(_) => {

View file

@ -1,20 +1,30 @@
mod state;
#[cfg(test)]
mod tests;
use fmt::Display;
use std::cmp::Ordering;
use std::collections::btree_set::Iter;
use std::collections::BTreeSet;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::iter::once;
use std::iter::{once, Chain, Once};
use std::str::FromStr;
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 itertools::Either::{Left, Right};
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
use nostr_sdk::{Alphabet, Event, EventId, Kind, Tag, Timestamp};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{is_hashtag, match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
use nostr_sdk::{Alphabet, Event, EventId, Kind, PublicKey, SingleLetterTag, Tag, TagKind, Timestamp};
pub static MARKER_PARENT: &str = "parent";
pub static MARKER_DEPENDS: &str = "depends";
@ -23,7 +33,7 @@ pub static MARKER_PROPERTY: &str = "property";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct 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 removed
tags: Option<BTreeSet<Tag>>,
/// Task references derived from the event tags
@ -66,20 +76,29 @@ impl Task {
}
}
pub(crate) fn get_id(&self) -> &EventId {
&self.event.id
/// All Events including the task and its props in chronological order
pub(crate) fn all_events(&self) -> impl DoubleEndedIterator<Item=&Event> {
once(&self.event).chain(self.props.iter().rev())
}
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))
pub(crate) fn get_id(&self) -> EventId {
self.event.id
}
pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next()
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_dependendees(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect()
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
@ -88,16 +107,32 @@ impl Task {
.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 description_events(&self) -> impl Iterator<Item=&Event> + '_ {
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))
}
pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next()
}
pub(crate) fn find_dependents(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect()
}
fn description_events(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
self.props.iter().filter(|event| event.kind == Kind::TextNote)
}
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content)
/// Description items, ordered newest to oldest
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ {
self.description_events()
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty()))
}
pub(crate) fn is_task_kind(&self) -> bool {
@ -123,9 +158,9 @@ impl Task {
})
}
fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
fn states(&self) -> impl DoubleEndedIterator<Item=StateChange> + '_ {
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),
state: s,
time: event.created_at,
@ -133,16 +168,15 @@ impl Task {
})
}
pub(crate) fn last_state_update(&self) -> Timestamp {
pub fn last_state_update(&self) -> Timestamp {
self.state().map(|s| s.time).unwrap_or(self.event.created_at)
}
pub(crate) fn state(&self) -> Option<TaskState> {
let now = now();
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 > now);
let state = self.states().take_while_inclusive(|ts| ts.time > time);
state.last().map(|ts| {
if ts.time <= now {
if ts.time <= time {
ts
} else {
self.default_state()
@ -150,11 +184,17 @@ impl Task {
})
}
pub(crate) fn pure_state(&self) -> State {
self.state().map_or(State::Open, |s| s.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 state_or_default(&self) -> TaskState {
pub(crate) fn pure_state(&self) -> State {
State::from(self.state())
}
pub(crate) fn state_or_default(&self) -> StateChange {
self.state().unwrap_or_else(|| self.default_state())
}
@ -165,23 +205,24 @@ impl Task {
.map(|state| state.get_colored_label())
}
fn default_state(&self) -> TaskState {
TaskState {
fn default_state(&self) -> StateChange {
StateChange {
name: None,
state: State::Open,
time: self.event.created_at,
}
}
pub(crate) fn get_hashtags(&self) -> impl Iterator<Item=&Tag> {
self.tags().filter(|t| is_hashtag(t))
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.tags.iter().flatten().chain(
self.props.iter().flat_map(|e| e.tags.iter()
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
@ -199,7 +240,7 @@ impl Task {
pub(crate) fn get(&self, property: &str) -> Option<String> {
match property {
// Static
"id" => Some(self.event.id.to_string()),
"id" => Some(self.get_id().to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()),
"name" => Some(self.event.content.clone()),
"key" | "pubkey" => Some(self.event.pubkey.to_string()),
@ -208,8 +249,8 @@ impl Task {
// Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()),
"status" => self.state_label().map(|c| c.to_string()),
"desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().join(" ")),
"desc" => self.descriptions().next().cloned(),
"description" => Some(self.descriptions().rev().join(" ")),
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
"tags" => Some(self.join_tags(|_| true)), // TODO test these!
"alltags" => Some(format!("{:?}", self.tags)),
@ -221,10 +262,7 @@ impl Task {
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content))
.collect_vec()
)),
"descriptions" => Some(format!(
"{:?}",
self.descriptions().collect_vec()
)),
"descriptions" => Some(format!("{:?}", self.descriptions().collect_vec())),
_ => {
warn!("Unknown task property {}", property);
None
@ -232,145 +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))
)
}
}
#[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_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)
}
}
#[cfg(test)]
mod tasks_test {
use super::*;
use nostr_sdk::{EventBuilder, Keys};
#[test]
fn test_state() {
let keys = Keys::generate();
let mut task = Task::new(
EventBuilder::new(TASK_KIND, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.get_hashtags().count(), 1);
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
task.props.insert(
EventBuilder::new(State::Open.into(), "").tags([Tag::hashtag("tag2")])
.custom_created_at(Timestamp::from(Timestamp::now() - 2))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
assert_eq!(task.get_hashtags().count(), 2);
task.props.insert(
EventBuilder::new(State::Closed.into(), "")
.custom_created_at(Timestamp::from(Timestamp::now() + 1))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Closed);
}
}

128
src/task/state.rs Normal file
View 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
View 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,
}));
}

File diff suppressed because it is too large Load diff

View 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
View 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
View 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
View 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);
}