Compare commits

..

1 commit

Author SHA1 Message Date
xeruf
2905d35fed try tokio async 2024-08-20 14:05:51 +03:00
25 changed files with 2447 additions and 5101 deletions

View file

@ -1,57 +0,0 @@
on: [push, pull_request, create]
jobs:
build:
env:
CARGO_PROFILE_TEST_BUILD_OVERRIDE_DEBUG: true
CARGO_PROFILE_dev_OPT_LEVEL: 0
RUSTFLAGS: ""
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs
os: [ubuntu-latest, windows-latest, macos-latest]
jdk: [11]
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Install libdbus on Ubuntu
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libdbus-1-dev
- run: cargo test
- run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: mostr_${{ github.sha }}_${{ matrix.os }}
path: |
target/release/mostr
target/release/mostr.exe
#build-arm:
# runs-on: ${{ matrix.os }}
# if: startsWith(github.ref, 'refs/tags/')
# strategy:
# matrix:
# os: [macos-latest-large]
# jdk: [11]
# steps:
# - uses: actions/checkout@v4
# - uses: actions-rust-lang/setup-rust-toolchain@v1
# - run: cargo test --all-features
#release:
# needs: [build, build-arm]
# runs-on: ubuntu-latest
# if: startsWith(github.ref, 'refs/tags/')
# steps:
# - uses: actions/download-artifact@v4 # https://github.com/actions/download-artifact
# with:
# pattern: software-challenge-gui-${{ github.sha }}-*
# path: build
# merge-multiple: true
# - name: Release ${{ github.ref }}
# uses: softprops/action-gh-release@v1 # https://github.com/softprops/action-gh-release
# with:
# files: build/*.jar
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

5
.gitignore vendored
View file

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

2263
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

279
README.md
View file

@ -2,118 +2,35 @@
An immutable nested collaborative task manager, powered by nostr! An immutable nested collaborative task manager, powered by nostr!
> Mostr is beta software.
> Do not entrust it exclusively with your data unless you know what you are doing!
> Intermediate versions might not properly persist all changes.
> A failed relay connection currently looses all intermediate changes.
## Quickstart ## Quickstart
Install rust(up) and run a development build with: First, start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use
cargo run -- ARGS Run development build with:
A `relay` list can be placed in a config file cargo run
A `relay` list and private `key` can be placed in config files
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`. under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
Ideally any project with different collaborators has its own relay. Currently, all relays are fetched and synced to,
separation is planned -
ideally for any project with different collaborators,
an own relay will be used.
If not saved, mostr will ask for a relay url If not saved, mostr will ask for a relay url
(entering none is fine too, but your data will not be persisted between sessions) (entering none is fine too, but your data will not be persisted between sessions)
and a private key, alternatively generating one on the fly. and a private key, alternatively generating one on the fly.
The key is saved in the system keychain. Both are currently saved in plain text to the above files.
Install latest build: Install latest build:
cargo install --path . cargo install --path .
This one-liner can help you stay on the latest version Creating a test task externally:
(optionally add a `cd` to your mostr-directory in front to use it anywhere): `nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
git pull && cargo install --path . && mostr To exit the application, press `Ctrl-D`.
To exit mostr, press `Ctrl-D`.
### Migrating
All data is stored on the relay.
To use mostr on a new device,
the only thing needed is your private key.
To export your password-encrypted key,
run mostr with the `--export` flag on the previous machine,
optionally deleting the key from the system keystore.
You can then import a password-encrypted key
using the `--import` flag.
To change your keypair on an existing machine,
simply delete the current one through the `export` command
and rerun mostr.
There is no harm in using mostr from multiple devices,
though there may be delays in updates if it is used in parallel.
For best user experience,
exit mostr on a device when you are done
to ensure all changes are propagated.
## Reference
### Command Syntax
Uppercased words are placeholders, brackets enclose optional arguments.
`TASK` creation syntax: `NAME #TAG *PRIO @ASSIGNEE # TAG1 TAG2 ...`
- `TASK` - create task
+ prefix with space if you want a task to start with a command character
+ paste text with newlines to create one task per line
- `.` - clear all filters
- `.TASK`
+ activate task by id
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
+ no match: create & activate task
- `.2` - set view depth to the given number (how many subtask levels to show, default is 1)
- `/[TEXT]` - activate task or filter by smart-case substring match (empty: move to root)
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - mark current task as procedure or create a sibling task depending on the current one and move up
- sibling task shortcut?
Dot or slash can be repeated to move to parent tasks before acting.
Append `@TIME` to any task creation or change command to record the action with the given time.
To add tags or edit the priority or assignee, make the change part of a comment or state update:
- `:[IND][PROP]` - add property column PROP at IND or end,
if it already exists remove property column PROP or IND; empty: list properties
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - list tracked times or insert time-tracking with the specified offset (double to view all history)
such as `(20` (for 20:00), `(-1d`, `(-15 minutes`, `(yesterday 17:20`, `(in 2 fortnights`
- TBI: track whole interval in one with dash
- `)[TIME]` - stop time-tracking with optional offset (also convenience helper to move to root)
- `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set status for current task from text and move up; empty: Open
- `!TIME: REASON` - defer (hide) current task until given time
- `,[TEXT]` - list notes or add text (activity / task description)
- TBI: `;[TEXT]` - list comments or comment on task
- TBI: show status history and creation with attribution
- `&` - revert
- with string argument, find first matching task in history
- with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions)
- `*` - (un)bookmark current task or list all bookmarks
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters:
- `#TAG1 TAG2` - set tag filter
- `+TAG` - add tag filter (empty: list all used tags)
- `-TAG` - remove tag filters (by prefix)
- `?STATUS` - set status filter (type or description) - plain `?` to reset, `??` to show all
- `*INT` - set priority filter - `**` to reset
- `@[AUTHOR|TIME]` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks.
## Basic Usage ## Basic Usage
@ -148,10 +65,6 @@ should be grouped with a tag instead.
Similarly for projects which are only sporadically worked on Similarly for projects which are only sporadically worked on
when a specific task comes up, so they do not clutter the list. when a specific task comes up, so they do not clutter the list.
### Task States
> TODO: Mark as Done vs Closed
### Collaboration ### Collaboration
Since everything in mostr is inherently immutable, Since everything in mostr is inherently immutable,
@ -172,84 +85,103 @@ as you work.
The currently active task is automatically time-tracked. The currently active task is automatically time-tracked.
To stop time-tracking completely, simply move to the root of all tasks. To stop time-tracking completely, simply move to the root of all tasks.
Time-tracking by default recursively summarizes
### Priorities ## Reference
Task priorities can be set as any natural number, ### Command Syntax
with higher numbers denoting higher priorities.
The syntax here allows for very convenient incremental usage:
By default, using priorities between 1 and 9 is recommended,
with an exemplary interpretation like this:
* 1 Ideas / "Someday" `TASK` creation syntax: `NAME: TAG1 TAG2 ...`
* 2 Later
* 3 Soon
* 4 Relevant
* 5 Important
* 9 DO NOW
Internally, when giving a single digit, a 0 is appended, - `TASK` - create task (prefix with space if you want a task to start with a command character)
so that the default priorities increment in steps of 10. - `.` - clear filters
So in case you need more than 10 priorities, - `.TASK`
instead of stacking them on top, + activate task by id
you can granularly add them in between. + match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
For example, `12` is in between `1` and `2` + no match: create & activate task
which are equivalent to `10` and `20`, - `.2` - set view depth to the given number (how many subtask levels to show, default is 1)
not above `9` but above `09`! - `/[TEXT]` - activate task or filter by smart-case substring match
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - (un)mark current task as procedure or create a sibling task depending on the current one and move up
By default, only tasks with priority `35` and upward are shown Dot or slash can be repeated to move to parent tasks before acting.
so you can focus on what matters,
but you can temporarily override that using `**PRIO`.
### Quick Access - `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (
1-indexed), empty: list properties
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - list tracked times or insert timetracking with the specified offset
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
- `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set status for current task from text and move up (empty: Open)
- `,[TEXT]` - list notes or add text note (comment / description)
- `;[TEXT]` - list comments or comment on task
- `*[INT]` - set priority - can also be used in task creation, with any digit
- TBI: show status history and creation with attribution
- `&` - undo last action (moving in place or upwards confirms pending actions)
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Paper-based lists are often popular because you can quickly put down a bunch of items. Property Filters:
Mostr offers three useful workflows depending on the use-case:
If you want to TBC...
- temporary task with subtasks (especially handy for progression) - `#TAG1 TAG2` - set tag filter (empty: list all used tags)
- Filter by recently created - `+TAG` - add tag filter
- Pin to bookmarks - `-TAG` - remove tag filters by prefix
- high priority - `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
- `@AUTHOR` - filter by author (`@` for self, id prefix, name prefix)
- TBI: `**INT` - filter by priority
- TBI: Filter by time
Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks.
### Notes ### Notes
- TBI = To Be Implemented - TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one - `. TASK` - create and enter a new task even if the name matches an existing one
# Development and Contributions ## Nostr reference
This Project follows the [Kull Collaboration Convention](https://kull.jfischer.org/), Mostr mainly uses the following NIPs:
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 - Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
+ Tasks have Kind 1621 (originally: git issue - currently no markdown support implemented)
+ TBI: Kind 1622 for task comments
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
- Own Kind 1650 for time-tracking
Start a nostr relay, such as Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/52.md
- https://github.com/coracle-social/bucket for local development - Kind 31922 for GANTT, since it has only Date
- https://github.com/rnostr/rnostr for production use - Kind 31923 for Calendar, since it has a time
To create a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
## Plans ## Plans
- Handle event sending rejections (e.g. permissions)
- Local Database Cache, Negentropy Reconciliation - Local Database Cache, Negentropy Reconciliation
-> Offline Use! -> Offline Use!
- Scheduling
- Remove status filter when moving up? - Remove status filter when moving up?
- Task markdown support? - colored - Task markdown support? - colored
- Calendar Events - make future time-tracking editable -> parametrised replaceable events - Time tracking: Ability to postpone task and add planned timestamps (calendar entry)
- Parse Hashtag tags from task name
- Unified Filter object
-> include subtasks of matched tasks
- Speedup: Offline caching & Expiry (no need to fetch potential years of history) - Speedup: Offline caching & Expiry (no need to fetch potential years of history)
+ Fetch most recent tasks first + Fetch most recent tasks first
+ Relay: compress tracked time for old tasks, filter closed tasks + Relay: compress tracked time for old tasks, filter closed tasks
+ Relay: filter out task status updates within few seconds, also on client side + Relay: filter out task status updates within few seconds, also on client side
### Commands ### Fixes
Open Command characters: `_^\=$%~'"`, `{}[]` - New Relay does not load until next is added
https://github.com/rust-nostr/nostr/issues/533
- Handle event sending rejections (e.g. permissions)
- Recursive filter handling
### Command
- Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
- reassign undo to `&` and use `@` for people
### Conceptual ### Conceptual
@ -260,7 +192,7 @@ Suggestions welcome!
- Queueing tasks - Queueing tasks
- Allow adding new parent via description? - Allow adding new parent via description?
- Special commands: help, exit, tutorial, change log level - Special commands: help, exit, tutorial, change log level
- Duplicate task (subtasks? time-tracking?) - Duplicate task (subtasks? timetracking?)
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense? - What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?) - Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Templates - Templates
@ -270,28 +202,16 @@ Suggestions welcome!
+ Subtask progress immediate/all/leafs + Subtask progress immediate/all/leafs
+ path full / leaf / top + path full / leaf / top
### Interfaces & Integrations ### Interfaces
- TUI: Clear Terminal? Refresh on empty prompt after timeout? - TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar - Kanban, GANTT, Calendar
- n8n node - Web Interface, Messenger integrations
- Webcal Feed: Scheduled (planning) / Tracked (events, time-tracking) with args for how far back/forward
Interfaces: ## Exemplary Workflows
- text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/
### Config Files
- format strings
- thresholds: auto-send message, time-tracking overview interval and count
- global and per-relay: username, key location, tag mappings (i.e. server implies pc, home implies phys) -> also get from relay
## Exemplary Workflows - User Stories
- Freelancer - Freelancer
- Family Chore Management - Family Chore management
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays - Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
+ Permissions via status or assignment (reassignment?) + Permissions via status or assignment (reassignment?)
+ Tasks can be blocked while having a status (e.g. kanban column) + Tasks can be blocked while having a status (e.g. kanban column)
@ -299,29 +219,6 @@ Interfaces:
+ Schedule for multiple people + Schedule for multiple people
- Tracking Daily Routines / Habits - Tracking Daily Routines / Habits
### Freelancer
For a Freelancer, mostr can help structure work times
across different projects
because it can connect to multiple clients,
using their mental state effectively (Mind Management not Time Management).
It also enables transparency for clients
by sharing the tracked time -
but alternatively the freelancer
can track times on their own auxiliary relay
without problems.
### Family
With a mobile client implemented,
mostr can track shopping lists and other chores for a family,
and provide them context-dependently -
allowing you to batch shopping and activities without mental effort.
### Project Team
sharing, assigning, stand-ups, communication
### Contexts ### Contexts
A context is a custom set of filters such as status, tags, assignee A context is a custom set of filters such as status, tags, assignee
@ -331,7 +228,7 @@ since they will automatically take on that context.
By automating these contexts based on triggers, scripts or time, By automating these contexts based on triggers, scripts or time,
relevant tasks can be surfaced automatically. relevant tasks can be surfaced automatically.
#### Vision of Work-Life-Balance for Freelancer #### Example
In the morning, your groggy brain is good at divergent thinking, In the morning, your groggy brain is good at divergent thinking,
and you like to do sports in the morning. and you like to do sports in the morning.

View file

@ -1,22 +0,0 @@
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

@ -1,24 +0,0 @@
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);
}
}
}

View file

@ -1,44 +0,0 @@
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
}
}
}
}

View file

@ -1,50 +0,0 @@
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 +0,0 @@
[toolchain]
channel = "1.84.0"

View file

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

View file

@ -1,99 +0,0 @@
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,107 +1,59 @@
use std::fmt::Display;
use std::io::{stdin, stdout, Write};
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use chrono::LocalResult::Single; use chrono::LocalResult::Single;
use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc};
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::Timestamp; use nostr_sdk::Timestamp;
pub const CHARACTER_THRESHOLD: usize = 3;
pub fn to_string_or_default(arg: Option<impl ToString>) -> String {
arg.map(|arg| arg.to_string()).unwrap_or_default()
}
pub fn some_non_empty(str: &str) -> Option<String> { pub fn some_non_empty(str: &str) -> Option<String> {
if str.is_empty() { None } else { Some(str.to_string()) } if str.is_empty() { None } else { Some(str.to_string()) }
} }
pub fn trim_start_count(str: &str, char: char) -> (&str, usize) { // TODO as macro so that log comes from appropriate module
let len = str.len(); pub fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> {
let result = str.trim_start_matches(char); match result {
let dots = len - result.len(); Ok(value) => Some(value),
(result, dots) Err(error) => {
} warn!("{}", error);
None
pub trait ToTimestamp { }
fn to_timestamp(&self) -> Timestamp;
}
impl<T: TimeZone> ToTimestamp for DateTime<T> {
fn to_timestamp(&self) -> Timestamp {
let stamp = self.to_utc().timestamp();
if let Some(t) = 0u64.checked_add_signed(stamp) {
Timestamp::from(t)
} else { Timestamp::zero() }
} }
} }
pub fn prompt(prompt: &str) -> Option<String> {
/// Parses the hour optionally with minute from a plain number in a String, print!("{} ", prompt);
/// with max of max_future hours into the future. stdout().flush().unwrap();
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> { match stdin().lines().next() {
parse_hour_after(str, Local::now() - TimeDelta::hours(24 - max_future)) Some(Ok(line)) => Some(line),
} _ => None,
/// Parses the hour optionally with minute from a plain number in a String.
pub fn parse_hour_after<T: TimeZone>(str: &str, after: DateTime<T>) -> Option<DateTime<T>> {
str.parse::<u32>().ok().and_then(|number| {
#[allow(deprecated)]
after.date().and_hms_opt(
if str.len() > 2 { number / 100 } else { number },
if str.len() > 2 { number % 100 } else { 0 },
0,
).map(|time| {
if time < after {
time + TimeDelta::days(1)
} else {
time
} }
})
})
} }
pub fn parse_date(str: &str) -> Option<DateTime<Utc>> { pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
parse_date_with_ref(str, Local::now())
}
pub fn parse_date_with_ref(str: &str, reference: DateTime<Local>) -> Option<DateTime<Utc>> {
// Using two libraries for better exhaustiveness, see https://github.com/uutils/parse_datetime/issues/84 // Using two libraries for better exhaustiveness, see https://github.com/uutils/parse_datetime/issues/84
match interim::parse_date_string(str, reference, interim::Dialect::Us) { match interim::parse_date_string(str, Local::now(), interim::Dialect::Us) {
Ok(date) => Some(date.to_utc()), Ok(date) => Some(date.to_utc()),
Err(e) => { Err(e) => {
match parse_datetime::parse_datetime_at_date(reference, str) { match parse_datetime::parse_datetime_at_date(Local::now(), str) {
Ok(date) => Some(date.to_utc()), Ok(date) => Some(date.to_utc()),
Err(_) => { Err(_) => {
warn!("Could not parse date from \"{str}\": {e}"); warn!("Could not parse date from {str}: {e}");
None None
} }
} }
} }
}.map(|time| {
// TODO properly map date without time to day start, also support intervals
if str.chars().any(|c| c.is_numeric()) {
time
} else {
#[allow(deprecated)]
time.date().and_time(NaiveTime::default()).unwrap()
} }
})
} }
/// Turn a human-readable relative timestamp into a nostr Timestamp. pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
/// - Plain number as hour after given date, if none 18 hours back or 6 hours forward
/// - Number with prefix as minute offset
/// - Otherwise try to parse a relative date
pub fn parse_tracking_stamp(str: &str, after: Option<DateTime<Local>>) -> Option<Timestamp> {
if let Some(num) = parse_hour_after(str, after.unwrap_or(Local::now() - TimeDelta::hours(18))) {
return Some(num.to_timestamp());
}
let stripped = str.trim().trim_start_matches('+').trim_start_matches("in "); let stripped = str.trim().trim_start_matches('+').trim_start_matches("in ");
if let Ok(num) = stripped.parse::<i64>() { if let Ok(num) = stripped.parse::<i64>() {
// Complication needed because timestamp can only add u64, but we also want reverse
return Some(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60))); return Some(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
} }
parse_date(str).and_then(|time| { parse_date(str).and_then(|time| {
let stamp = time.to_utc().timestamp(); if time.timestamp() > 0 {
if stamp > 0 { Some(Timestamp::from(time.timestamp() as u64))
Some(Timestamp::from(stamp as u64))
} else { } else {
warn!("Can only track times after 1970!"); warn!("Can only track times after 1970!");
None None
@ -109,109 +61,41 @@ pub fn parse_tracking_stamp(str: &str, after: Option<DateTime<Local>>) -> Option
}) })
} }
/// Format DateTime easily comprehensible for human but unambiguous. // For use in format strings but not possible, so need global find-replace
/// Length may vary. pub const MAX_TIMESTAMP_WIDTH: u8 = 15;
pub fn format_datetime_relative(time: DateTime<Local>) -> String { /// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today
pub fn relative_datetimestamp(stamp: &Timestamp) -> String {
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
Single(time) => {
let date = time.date_naive(); let date = time.date_naive();
let prefix = let prefix = match Local::now()
match Local::now()
.date_naive() .date_naive()
.signed_duration_since(date) .signed_duration_since(date)
.num_days() { .num_days()
{
-1 => "tomorrow ".into(), -1 => "tomorrow ".into(),
0 => "".into(), 0 => "".into(),
1 => "yesterday ".into(), 1 => "yesterday ".into(),
//-3..=3 => date.format("%a ").to_string(), 2..=6 => date.format("last %a ").to_string(),
-10..=10 => date.format("%d. %a ").to_string(), _ => date.format("%y-%m-%d ").to_string(),
-100..=100 => date.format("%a %b %d ").to_string(),
_ => date.format("%y-%m-%d %a ").to_string(),
}; };
format!("{}{}", prefix, time.format("%H:%M")) format!("{}{}", prefix, time.format("%H:%M"))
} }
_ => stamp.to_human_datetime(),
/// Format a nostr timestamp with the given formatting function.
pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where
F: Fn(DateTime<Local>) -> String,
{
Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0).earliest()
.map_or_else(|| stamp.to_human_datetime().to_string(), formatter)
}
/// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today.
pub fn format_timestamp_relative(stamp: &Timestamp) -> String {
format_as_datetime(stamp, format_datetime_relative)
}
/// Format nostr timestamp with the given format.
pub fn format_timestamp(stamp: &Timestamp, format: &str) -> String {
format_as_datetime(stamp, |time| time.format(format).to_string())
}
/// Format nostr timestamp in a sensible comprehensive format with consistent length and consistent sorting.
///
/// Currently: 18 characters
pub fn format_timestamp_local(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M")
}
/// Format nostr timestamp with seconds precision.
pub fn format_timestamp_full(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M:%S")
}
pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
// Rough difference in days
match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 {
0 => format_timestamp(stamp, "%H:%M"),
-3..=3 => format_timestamp(stamp, "%a %H:%M"),
_ => format_timestamp_local(stamp),
} }
} }
mod test { /// Format a nostr timestamp in a sensible comprehensive format
use super::*; pub fn local_datetimestamp(stamp: &Timestamp) -> String {
use chrono::{FixedOffset, NaiveDate, Timelike}; format_stamp(stamp, "%y-%m-%d %a %H:%M")
use interim::datetime::DateTime; }
#[test] /// Format a nostr timestamp with the given format
fn parse_hours() { pub fn format_stamp(stamp: &Timestamp, format: &str) -> String {
let now = Local::now(); match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
#[allow(deprecated)] Single(time) => time.format(format).to_string(),
let date = now.date(); _ => stamp.to_human_datetime(),
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,34 +1,30 @@
use crate::task::MARKER_PARENT;
use crate::tasks::NostrUsers;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard}; use log::info;
use std::borrow::Cow; use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard};
use nostr_sdk::TagStandard::Hashtag;
pub const TASK_KIND: Kind = Kind::GitIssue; use crate::task::{MARKER_PARENT, State};
pub const PROCEDURE_KIND_ID: u16 = 1639;
pub const PROCEDURE_KIND: Kind = Kind::Regular(PROCEDURE_KIND_ID); pub const METADATA_KIND: u16 = 0;
pub const TRACKING_KIND: Kind = Kind::Regular(1650); pub const NOTE_KIND: u16 = 1;
pub const BASIC_KINDS: [Kind; 4] = [ pub const TASK_KIND: u16 = 1621;
Kind::Metadata, pub const TRACKING_KIND: u16 = 1650;
Kind::TextNote, pub const KINDS: [u16; 3] = [
METADATA_KIND,
NOTE_KIND,
TASK_KIND, TASK_KIND,
Kind::Bookmarks,
]; ];
pub const PROP_KINDS: [Kind; 6] = [ pub const PROP_KINDS: [u16; 6] = [
TRACKING_KIND, TRACKING_KIND,
Kind::GitStatusOpen, State::Open as u16,
Kind::GitStatusApplied, State::Done as u16,
Kind::GitStatusClosed, State::Closed as u16,
Kind::GitStatusDraft, State::Pending as u16,
PROCEDURE_KIND, State::Procedure as u16
]; ];
pub type Prio = u16;
pub const PRIO: &str = "priority";
// TODO: use formatting - bold / heading / italics - and generate from code
/// Helper for available properties. /// Helper for available properties.
/// TODO: use formatting - bold / heading / italics - and generate from code
pub const PROPERTY_COLUMNS: &str = pub const PROPERTY_COLUMNS: &str =
"# Available Properties "# Available Properties
Immutable: Immutable:
@ -42,11 +38,10 @@ Task:
- `hashtags` - list of hashtags set for the task - `hashtags` - list of hashtags set for the task
- `tags` - values of all nostr tags associated with the event, except event tags - `tags` - values of all nostr tags associated with the event, except event tags
- `desc` - last note on the task - `desc` - last note on the task
- `description` - all notes on the task - `description` - accumulated notes on the task
- `time` - time tracked on this task by you - `time` - time tracked on this task by you
Utilities: Utilities:
- `state` - indicator of current progress - `state` - indicator of current progress
- `owner` - author or task assignee
- `rtime` - time tracked on this tasks and its subtree by everyone - `rtime` - time tracked on this tasks and its subtree by everyone
- `progress` - recursive subtask completion in percent - `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete - `subtasks` - how many direct subtasks are complete
@ -55,123 +50,75 @@ Utilities:
- TBI `depends` - list all tasks this task depends on before it becomes actionable - TBI `depends` - list all tasks this task depends on before it becomes actionable
Debugging: `kind`, `pubkey`, `props`, `alltags`, `descriptions`"; Debugging: `kind`, `pubkey`, `props`, `alltags`, `descriptions`";
pub struct EventTag {
pub id: EventId,
pub marker: Option<String>,
}
/// Return event tag if existing
pub(crate) fn match_event_tag(tag: &Tag) -> Option<EventTag> {
let mut vec = tag.as_slice().into_iter();
if vec.next() == Some(&"e".to_string()) {
if let Some(id) = vec.next().and_then(|v| EventId::parse(v).ok()) {
vec.next();
return Some(EventTag { id, marker: vec.next().cloned() });
}
}
None
}
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
where where
I: IntoIterator<Item=EventId>, I: IntoIterator<Item=EventId>,
{ {
EventBuilder::new(Kind::from(TRACKING_KIND), "") EventBuilder::new(
.tags(id.into_iter().map(Tag::event)) Kind::from(TRACKING_KIND),
"",
id.into_iter().map(|id| Tag::event(id)),
)
} }
/// Formats and joins the tags with commata /// Build a task with informational output and optional labeled kind
pub fn join_tags<'a, T>(tags: T) -> String pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
where info!("Created {}task \"{name}\" with tags [{}]",
T: IntoIterator<Item=&'a Tag>, kind.map(|k| k.0).unwrap_or_default(),
{ tags.iter().map(|tag| format_tag(tag)).join(", "));
tags.into_iter().map(format_tag).join(", ") EventBuilder::new(kind.map(|k| k.1).unwrap_or(Kind::from(TASK_KIND)), name, tags)
} }
/// Return Hashtags embedded in the string. pub(crate) fn build_prop(
pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ { kind: Kind,
input.split_ascii_whitespace() comment: &str,
.filter(|s| s.starts_with('#')) id: EventId,
.map(|s| s.trim_start_matches('#')) ) -> EventBuilder {
.map(to_hashtag_tag) EventBuilder::new(
kind,
comment,
vec![Tag::event(id)],
)
} }
/// Extracts everything after a " # " as a list of tags /// Expects sanitized input
/// as well as various embedded tags. pub(crate) fn extract_tags(input: &str) -> (&str, Vec<Tag>) {
/// match input.split_once(": ") {
/// Expects sanitized input. None => (input, vec![]),
pub(crate) fn extract_tags(input: &str, users: &NostrUsers) -> (String, Vec<Tag>) { Some(s) => {
let words = input.split_ascii_whitespace(); let tags = s
let mut tags = Vec::with_capacity(4); .1
let result = words.filter(|s| { .split_ascii_whitespace()
if s.starts_with('@') { .map(|t| Hashtag(t.to_string()).into())
if let Ok(key) = PublicKey::parse(&s[1..]) { .collect();
tags.push(Tag::public_key(key)); (s.0, tags)
return false;
} else if let Some((key, _)) = users.find_user(&s[1..]) {
tags.push(Tag::public_key(*key));
return false;
}
} else if s.starts_with('*') {
if s.len() == 1 {
tags.push(to_prio_tag(HIGH_PRIO));
return false;
}
if let Ok(num) = s[1..].parse::<Prio>() {
tags.push(to_prio_tag(num * (if s.len() > 2 { 1 } else { 10 })));
return false;
} }
} }
true
}).collect_vec();
let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(tags)
.collect_vec();
tags.sort();
tags.dedup();
(main, tags)
} }
pub fn to_hashtag_tag(tag: &str) -> Tag { fn format_tag(tag: &Tag) -> String {
TagStandard::Hashtag(tag.to_string()).into()
}
pub fn format_tag(tag: &Tag) -> String {
if let Some(et) = match_event_tag(tag) {
return format!("{}: {:.8}",
et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()),
et.id);
}
format_tag_basic(tag)
}
pub fn format_tag_basic(tag: &Tag) -> String {
match tag.as_standardized() { match tag.as_standardized() {
Some(TagStandard::Event {
event_id,
marker,
..
}) => format!("{}: {:.8}", marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()), event_id),
Some(TagStandard::PublicKey { Some(TagStandard::PublicKey {
public_key, public_key,
alias, alias,
.. ..
}) => format!("Key{}: {:.8}", alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default(), public_key), }) => format!("Key{}: {:.8}", public_key.to_string(), alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default()),
Some(TagStandard::Hashtag(content)) => Some(TagStandard::Hashtag(content)) =>
format!("#{content}"), format!("#{content}"),
_ => tag.as_slice().join(" ") _ => tag.content().map_or_else(
|| format!("Kind {}", tag.kind()),
|content| content.to_string(),
)
} }
} }
pub fn to_prio_tag(value: Prio) -> Tag { pub(crate) fn is_hashtag(tag: &Tag) -> bool {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()]) tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
} }
#[test]
fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it", &Default::default()),
("Hello from #mars with #greetings #yeah".to_string(),
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag))
.collect()));
assert_eq!(extract_tags("So tagless @hewo #", &Default::default()),
("So tagless @hewo".to_string(), vec![]));
}

View file

File diff suppressed because it is too large Load diff

View file

@ -1,43 +1,32 @@
mod state;
#[cfg(test)]
mod tests;
use fmt::Display; use fmt::Display;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::btree_set::Iter; use std::collections::{BTreeSet, HashSet};
use std::collections::BTreeSet;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher};
use std::iter::{once, Chain, Once};
use std::str::FromStr;
use std::string::ToString; use std::string::ToString;
use crate::hashtag::{is_hashtag, Hashtag};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
pub use crate::task::state::State;
pub use crate::task::state::StateChange;
use colored::{ColoredString, Colorize}; use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right}; use itertools::Either::{Left, Right};
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::{Alphabet, Event, EventId, Kind, PublicKey, SingleLetterTag, Tag, TagKind, Timestamp}; use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
use crate::helpers::{local_datetimestamp, some_non_empty};
use crate::kinds::{is_hashtag, TASK_KIND};
pub static MARKER_PARENT: &str = "parent"; pub static MARKER_PARENT: &str = "parent";
pub static MARKER_DEPENDS: &str = "depends"; pub static MARKER_DEPENDS: &str = "depends";
pub static MARKER_PROPERTY: &str = "property";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Task { pub(crate) struct Task {
/// Event that defines this task /// Event that defines this task
pub(super) event: Event, // TODO make private pub(crate) event: Event,
/// Cached sorted tags of the event with references removed /// Cached sorted tags of the event with references remove - do not modify!
tags: Option<BTreeSet<Tag>>, pub(crate) tags: Option<BTreeSet<Tag>>,
/// Task references derived from the event tags /// Task references derived from the event tags
refs: Vec<(String, EventId)>, refs: Vec<(String, EventId)>,
/// Reference to children, populated dynamically
pub(crate) children: HashSet<EventId>,
/// Events belonging to this task, such as state updates and notes /// Events belonging to this task, such as state updates and notes
pub(crate) props: BTreeSet<Event>, pub(crate) props: BTreeSet<Event>,
} }
@ -54,21 +43,15 @@ impl Ord for Task {
} }
} }
impl Hash for Task {
fn hash<H: Hasher>(&self, state: &mut H) {
self.event.id.hash(state);
}
}
impl Task { impl Task {
pub(crate) fn new(event: Event) -> Task { pub(crate) fn new(event: Event) -> Task {
let (refs, tags) = event.tags.iter().partition_map(|tag| if let Some(et) = match_event_tag(tag) { let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
Left((et.marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), et.id)) Some(TagStandard::Event { event_id, marker, .. }) => Left((marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), event_id.clone())),
} else { _ => Right(tag.clone()),
Right(tag.clone())
}); });
// Separate refs for dependencies // Separate refs for dependencies
Task { Task {
children: Default::default(),
props: Default::default(), props: Default::default(),
tags: Some(tags).filter(|t: &BTreeSet<Tag>| !t.is_empty()), tags: Some(tags).filter(|t: &BTreeSet<Tag>| !t.is_empty()),
refs, refs,
@ -76,183 +59,111 @@ impl Task {
} }
} }
/// All Events including the task and its props in chronological order pub(crate) fn get_id(&self) -> &EventId {
pub(crate) fn all_events(&self) -> impl DoubleEndedIterator<Item=&Event> { &self.event.id
once(&self.event).chain(self.props.iter().rev())
}
pub(crate) fn get_id(&self) -> EventId {
self.event.id
}
pub(crate) fn get_participants(&self) -> impl Iterator<Item=PublicKey> + '_ {
self.tags()
.filter(|t| t.kind() == TagKind::p())
.filter_map(|t| t.content()
.and_then(|c| PublicKey::from_str(c).inspect_err(|e| warn!("Unparseable pubkey in {:?}", t)).ok()))
}
pub(crate) fn get_assignee(&self) -> Option<PublicKey> {
self.get_participants().next()
}
pub(crate) fn get_owner(&self) -> PublicKey {
self.get_assignee()
.unwrap_or_else(|| self.event.pubkey)
}
/// Trimmed event content or stringified id
pub(crate) fn get_title(&self) -> String {
some_non_empty(self.event.content.trim())
.unwrap_or_else(|| self.get_id().to_string())
}
/// Title with leading hashtags removed
pub(crate) fn get_filter_title(&self) -> String {
self.event.content.trim().trim_start_matches('#').to_string()
} }
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> { pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
self.refs.iter().filter_map(move |(str, id)| self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker))
Some(id).filter(|_| str == marker))
} }
pub(crate) fn parent_id(&self) -> Option<&EventId> { pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next() self.find_refs(MARKER_PARENT).next()
} }
pub(crate) fn find_dependents(&self) -> Vec<&EventId> { pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect() self.find_refs(MARKER_DEPENDS).collect()
} }
fn description_events(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ { pub(crate) fn get_title(&self) -> String {
self.props.iter().filter(|event| event.kind == Kind::TextNote) Some(self.event.content.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| self.get_id().to_string())
} }
/// Description items, ordered newest to oldest pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ { self.props.iter().filter_map(|event| {
self.description_events() if event.kind == Kind::TextNote {
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty())) Some(event)
} else {
None
} }
pub(crate) fn is_task_kind(&self) -> bool {
self.event.kind == TASK_KIND
}
/// Whether this is an actionable task - false if stateless activity
pub(crate) fn is_task(&self) -> bool {
self.is_task_kind() ||
self.props.iter().any(|event| State::try_from(event.kind).is_ok())
}
pub(crate) fn priority(&self) -> Option<Prio> {
self.priority_raw().and_then(|s| s.parse().ok())
}
pub(crate) fn priority_raw(&self) -> Option<&str> {
self.props.iter()
.chain(once(&self.event))
.find_map(|p| {
p.tags.iter().find_map(|t|
t.content().take_if(|_| { t.kind().to_string() == PRIO }))
}) })
} }
fn states(&self) -> impl DoubleEndedIterator<Item=StateChange> + '_ { pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content)
}
pub(crate) fn is_task(&self) -> bool {
self.event.kind.as_u16() == TASK_KIND ||
self.states().next().is_some()
}
fn states(&self) -> impl Iterator<Item=TaskState> + '_ {
self.props.iter().filter_map(|event| { self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| StateChange { event.kind.try_into().ok().map(|s| TaskState {
name: some_non_empty(&event.content), name: some_non_empty(&event.content),
state: s, state: s,
time: event.created_at, time: event.created_at.clone(),
}) })
}) })
} }
pub fn last_state_update(&self) -> Timestamp { pub(crate) fn state(&self) -> Option<TaskState> {
self.state().map(|s| s.time).unwrap_or(self.event.created_at) self.states().max_by_key(|t| t.time)
}
pub fn state_at(&self, time: Timestamp) -> Option<StateChange> {
// TODO do not iterate constructed state objects
let state = self.states().take_while_inclusive(|ts| ts.time > time);
state.last().map(|ts| {
if ts.time <= time {
ts
} else {
self.default_state()
}
})
}
/// Returns the current state if this is a task rather than an activity
pub fn state(&self) -> Option<StateChange> {
let now = now();
self.state_at(now)
} }
pub(crate) fn pure_state(&self) -> State { pub(crate) fn pure_state(&self) -> State {
State::from(self.state()) self.state().map_or(State::Open, |s| s.state)
} }
pub(crate) fn state_or_default(&self) -> StateChange { pub(crate) fn state_or_default(&self) -> TaskState {
self.state().unwrap_or_else(|| self.default_state()) self.state().unwrap_or_else(|| self.default_state())
} }
/// Returns None for activities. /// Returns None for a stateless task.
pub(crate) fn state_label(&self) -> Option<ColoredString> { pub(crate) fn state_label(&self) -> Option<ColoredString> {
self.state() self.state()
.or_else(|| Some(self.default_state()).filter(|_| self.is_task())) .or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
.map(|state| state.get_colored_label()) .map(|state| state.get_colored_label())
} }
fn default_state(&self) -> StateChange { fn default_state(&self) -> TaskState {
StateChange { TaskState {
name: None, name: None,
state: State::Open, state: State::Open,
time: self.event.created_at, time: self.event.created_at,
} }
} }
pub(crate) fn list_hashtags(&self) -> impl Iterator<Item=Hashtag> + '_ { fn filter_tags<P>(&self, predicate: P) -> Option<String>
self.tags().filter_map(|t| Hashtag::try_from(t).ok())
}
/// Tags of this task that are not event references, newest to oldest
fn tags(&self) -> impl Iterator<Item=&Tag> {
self.props.iter()
.flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
.chain(self.tags.iter().flatten())
}
fn join_tags<P>(&self, predicate: P) -> String
where where
P: FnMut(&&Tag) -> bool, P: FnMut(&&Tag) -> bool,
{ {
self.tags() self.tags.as_ref().map(|tags| {
tags.into_iter()
.filter(predicate) .filter(predicate)
.map(|t| t.content().unwrap().to_string()) .map(|t| format!("{}", t.content().unwrap()))
.sorted_unstable()
.dedup()
.join(" ") .join(" ")
})
} }
pub(crate) fn get(&self, property: &str) -> Option<String> { pub(crate) fn get(&self, property: &str) -> Option<String> {
match property { match property {
// Static // Static
"id" => Some(self.get_id().to_string()), "id" => Some(self.event.id.to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()), "parentid" => self.parent_id().map(|i| i.to_string()),
"name" => Some(self.event.content.clone()), "name" => Some(self.event.content.clone()),
"key" | "pubkey" => Some(self.event.pubkey.to_string()), "pubkey" => Some(self.event.pubkey.to_string()),
"created" => Some(format_timestamp_local(&self.event.created_at)), "created" => Some(local_datetimestamp(&self.event.created_at)),
"kind" => Some(self.event.kind.to_string()), "kind" => Some(self.event.kind.to_string()),
// Dynamic // Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()),
"status" => self.state_label().map(|c| c.to_string()), "status" => self.state_label().map(|c| c.to_string()),
"desc" => self.descriptions().next().cloned(), "desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().rev().join(" ")), "description" => Some(self.descriptions().join(" ")),
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })), "hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
"tags" => Some(self.join_tags(|_| true)), // TODO test these! "tags" => self.filter_tags(|_| true),
"alltags" => Some(format!("{:?}", self.tags)), "alltags" => Some(format!("{:?}", self.tags)),
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())), "refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
"props" => Some(format!( "props" => Some(format!(
@ -262,7 +173,10 @@ impl Task {
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content)) .map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content))
.collect_vec() .collect_vec()
)), )),
"descriptions" => Some(format!("{:?}", self.descriptions().collect_vec())), "descriptions" => Some(format!(
"{:?}",
self.descriptions().collect_vec()
)),
_ => { _ => {
warn!("Unknown task property {}", property); warn!("Unknown task property {}", property);
None None
@ -270,3 +184,110 @@ impl Task {
} }
} }
} }
pub(crate) struct TaskState {
pub(crate) state: State,
name: Option<String>,
pub(crate) time: Timestamp,
}
impl TaskState {
pub(crate) fn get_label_for(state: &State, comment: &str) -> String {
some_non_empty(comment).unwrap_or_else(|| state.to_string())
}
pub(crate) fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string())
}
pub(crate) fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub(crate) fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label)
}
}
impl Display for TaskState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_str = self.state.to_string();
write!(
f,
"{}",
self.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
.map_or(state_str, |s| format!("{}: {}", self.state, s))
)
}
}
pub const PROCEDURE_KIND: u16 = 1639;
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
pub(crate) enum State {
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed,
/// Temporarily not actionable
Pending,
/// Actionable ordered task list
Procedure = PROCEDURE_KIND as isize,
}
impl From<&str> for State {
fn from(value: &str) -> Self {
match value {
"Closed" => State::Closed,
"Done" => State::Done,
"Pending" => State::Pending,
"Proc" | "Procedure" | "List" => State::Procedure,
_ => State::Open,
}
}
}
impl TryFrom<Kind> for State {
type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value.as_u16() {
1630 => Ok(State::Open),
1631 => Ok(State::Done),
1632 => Ok(State::Closed),
1633 => Ok(State::Pending),
PROCEDURE_KIND => Ok(State::Procedure),
_ => Err(()),
}
}
}
impl State {
pub(crate) fn is_open(&self) -> bool {
match self {
State::Open | State::Pending | State::Procedure => true,
_ => false,
}
}
pub(crate) fn kind(self) -> u16 {
self as u16
}
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self {
State::Open => str.green(),
State::Done => str.bright_black(),
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
}
}
}
impl From<State> for Kind {
fn from(value: State) -> Self {
Kind::from(value.kind())
}
}
impl Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}

View file

@ -1,128 +0,0 @@
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)
}
}

View file

@ -1,40 +0,0 @@
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

@ -1,160 +0,0 @@
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();
})
}
}

View file

@ -1,83 +0,0 @@
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
}

View file

@ -1,82 +0,0 @@
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)]));
}

View file

@ -1,495 +0,0 @@
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);
}