Compare commits

...

15 commits

Author SHA1 Message Date
xeruf
660d7b1815 build(ci): only capture exe on windows for upload 2024-12-07 01:10:28 +01:00
xeruf
d85ff3ac8d refactor(tasks): extract more modules 2024-12-06 23:28:06 +01:00
xeruf
77bc359d8a build(ci): recognize windows exe 2024-12-06 22:30:13 +01:00
xeruf
ced5c4b3ef style(main): rename relays_file variable 2024-12-06 22:14:09 +01:00
xeruf
932d07b893 test(examples): track various experiments 2024-12-06 22:12:05 +01:00
xeruf
76baed51e2 build: extract unused examples 2024-12-06 22:10:14 +01:00
xeruf
e8312959c3 build(ci): allow disabling keyring features 2024-12-06 21:40:26 +01:00
xeruf
1df75055df build(ci): add artifact upload 2024-12-06 21:16:39 +01:00
xeruf
cdf3d3a805 build(ci): add github ci 2024-12-06 20:57:47 +01:00
xeruf
29ef9634a4 docs(readme): add collaboration convention 2024-12-06 20:52:11 +01:00
xeruf
cf04d4d852 refactor(tasks): simplify up_to back 2024-12-06 20:47:47 +01:00
xeruf
0c6ad19600 test(tasks): extract tests from tasks 2024-12-06 20:45:58 +01:00
xeruf
6f2a7951d5 refactor: modularize task 2024-12-06 13:52:01 +01:00
xeruf
03fd79ad95 refactor(tasks): more descriptive variable names 2024-12-06 12:01:00 +01:00
xeruf
6362718aa7 docs(readme): reorder for gentler introduction 2024-12-06 12:00:17 +01:00
19 changed files with 1284 additions and 4321 deletions

57
.github/workflows/ci.yml vendored Normal file
View file

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

4
.gitignore vendored
View file

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

3378
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ default-run = "mostr"
[dependencies]
# Basics
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] }
tokio = { version = "1.42", features = ["rt", "rt-multi-thread", "macros"] }
regex = "1.10.6"
# System
log = "0.4"
@ -23,10 +23,10 @@ colog = "1.3"
colored = "2.1"
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
# OS-Specific Abstractions
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
keyring = "3"
directories = "5.0"
whoami = "1.5"
slint = "1.8"
# slint = "1.8"
# Application Utils
itertools = "0.12"
chrono = "0.4"
@ -35,6 +35,8 @@ interim = { version = "0.1", features = ["chrono"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
[dev-dependencies]
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
chrono-english = "0.1"
linefeed = "0.6"
mostr = { path = ".", default-features = false }
[features]
default = ["persistence"]
persistence = ["keyring/apple-native", "keyring/windows-native", "keyring/linux-native-sync-persistent", "keyring/crypto-rust"]

156
README.md
View file

@ -10,10 +10,6 @@ An immutable nested collaborative task manager, powered by nostr!
## Quickstart
First, start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use
Install rust(up) and run a development build with:
cargo run
@ -31,15 +27,71 @@ Install latest build:
cargo install --path .
This one-liner can help you stay on the latest version
(optionally add a `cd` to your mostr-directory in front):
(optionally add a `cd` to your mostr-directory in front to use it anywhere):
git pull && cargo install --path . && mostr
Creating a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
To exit the application, press `Ctrl-D`.
## 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
### Navigation and Nesting
@ -73,6 +125,10 @@ should be grouped with a tag instead.
Similarly for projects which are only sporadically worked on
when a specific task comes up, so they do not clutter the list.
### Task States
> TODO: Mark as Done vs Closed
### Collaboration
Since everything in mostr is inherently immutable,
@ -134,66 +190,27 @@ If you want to TBC...
- Pin to bookmarks
- high priority
## Reference
### Command Syntax
`TASK` creation syntax: `NAME #TAG *PRIO # TAG1 TAG2 ...`
- `TASK` - create task
+ prefix with space if you want a task to start with a command character
+ paste text with newlines to create one task per line
- `.` - clear all filters
- `.TASK`
+ activate task by id
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
+ no match: create & activate task
- `.2` - set view depth to the given number (how many subtask levels to show, default is 1)
- `/[TEXT]` - activate task or filter by smart-case substring match (empty: move to root)
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - mark current task as procedure or create a sibling task depending on the current one and move up
- sibling task shortcut?
Dot or slash can be repeated to move to parent tasks before acting.
Append `@TIME` to any task creation or change command to record the action with the given time.
- `:[IND][PROP]` - add property column PROP at IND or end,
if it already exists remove property column PROP or IND; empty: list properties
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - list tracked times or insert timetracking with the specified offset (double to view all history)
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
- `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set status for current task from text and move up; empty: Open
- `!TIME: REASON` - defer current task to date
- `,[TEXT]` - list notes or add text (activity / task description)
- TBI: `;[TEXT]` - list comments or comment on task
- TBI: show status history and creation with attribution
- `&` - revert
- with string argument, find first matching task in history
- with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions)
- `*` - (un)bookmark current task or list all bookmarks
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters:
- `#TAG1 TAG2` - set tag filter
- `+TAG` - add tag filter (empty: list all used tags)
- `-TAG` - remove tag filters (by prefix)
- `?STATUS` - set status filter (type or description) - plain `?` to reset, `??` to show all
- `*INT` - set priority filter - `**` to reset
- `@[AUTHOR|TIME]` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks.
### Notes
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one
# Development and Contributions
This Project follows the [Kull Collaboration Convention](https://kull.jfischer.org/),
especially the commit message format.
Currently a separate dev branch is maintained because users regularly receive updates via the main branch.
Once proper packaging is in place, this can be simplified.
## Local Development Tools
Start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use
To create a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
## Plans
- Handle event sending rejections (e.g. permissions)
@ -209,8 +226,7 @@ An active tag or status filter will also set that attribute for newly created ta
### Commands
- Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
Open Command characters: `_^\=$%~'"`, `{}[]`
### Conceptual
@ -221,7 +237,7 @@ Suggestions welcome!
- Queueing tasks
- Allow adding new parent via description?
- Special commands: help, exit, tutorial, change log level
- Duplicate task (subtasks? timetracking?)
- Duplicate task (subtasks? time-tracking?)
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
- Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Templates
@ -236,13 +252,19 @@ Suggestions welcome!
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar
- n8n node
- Webcal Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward
- Webcal Feed: Scheduled (planning) / Tracked (events, time-tracking) with args for how far back/forward
Interfaces:
- text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/
### Config Files
- format strings
- thresholds: auto-send message, time-tracking overview interval and count
- global and per-relay: username, key location, tag mappings (i.e. server implies pc, home implies phys) -> also get from relay
## Exemplary Workflows - User Stories
- Freelancer

22
examples/question.rs Normal file
View file

@ -0,0 +1,22 @@
use std::collections::HashMap;
fn main() {
let mut map: HashMap<usize, String> = HashMap::new();
let add_string = |map: &mut HashMap<usize, String>, string: String| {
map.insert(string.len(), string);
};
add_string(&mut map, "hi".to_string());
add_string(&mut map, "ho".to_string());
map.add_string("hi".to_string());
map.add_string("ho".to_string());
map.get(&1);
}
trait InsertString {
fn add_string(&mut self, event: String);
}
impl InsertString for HashMap<usize, String> {
fn add_string(&mut self, event: String) {
self.insert(event.len(), event);
}
}

View file

@ -0,0 +1,24 @@
use std::time::Duration;
use nostr_sdk::prelude::*;
#[tokio::main]
async fn main() {
//tracing_subscriber::fmt::init();
let client = Client::new(Keys::generate());
let result = client.subscribe(vec![Filter::new()], None).await;
println!("subscribe: {:?}", result);
let result = client.add_relay("ws://localhost:4736").await;
println!("add relay: {:?}", result);
client.connect().await;
let mut notifications = client.notifications();
let _thread = tokio::spawn(async move {
client.send_event_builder(EventBuilder::new(Kind::TextNote, "test")).await;
tokio::time::sleep(Duration::from_secs(20)).await;
});
while let Ok(notification) = notifications.recv().await {
if let RelayPoolNotification::Event { event, .. } = notification {
println!("At {} found {} kind {} content \"{}\"", event.created_at, event.id, event.kind, event.content);
}
}
}

44
examples/relay-test.rs Normal file
View file

@ -0,0 +1,44 @@
use nostr_sdk::prelude::*;
#[tokio::main]
async fn main() {
//tracing_subscriber::fmt::init();
let client = Client::new(Keys::generate());
//let result = client.subscribe(vec![Filter::new()], None).await;
//println!("{:?}", result);
let mut notifications = client.notifications();
let result = client.add_relay("ws://localhost:3333").await;
println!("{:?}", result);
let result = client.connect_relay("ws://localhost:3333").await;
println!("{:?}", result);
//let _thread = tokio::spawn(async move {
// let result = client.add_relay("ws://localhost:4736").await;
// println!("{:?}", result);
// let result = client.connect_relay("ws://localhost:4736").await;
// println!("{:?}", result);
// // Block b
// //let result = client.add_relay("ws://localhost:54736").await;
// //println!("{:?}", result);
// //let result = client.connect_relay("ws://localhost:54736").await;
// //println!("{:?}", result);
// tokio::time::sleep(Duration::from_secs(20)).await;
//});
loop {
match notifications.recv().await {
Ok(notification) => {
if let RelayPoolNotification::Event { event, .. } = notification {
println!("At {} found {} kind {} content \"{}\"", event.created_at, event.id, event.kind, event.content);
}
}
Err(e) => {
println!("Aborting due to {:?}", e);
break
}
}
}
}

50
examples/rustyline.rs Normal file
View file

@ -0,0 +1,50 @@
use rustyline::error::ReadlineError;
use rustyline::{Cmd, ConditionalEventHandler, DefaultEditor, Event, EventContext, EventHandler, KeyEvent, Movement, RepeatCount, Result};
struct CtrlCHandler;
impl ConditionalEventHandler for CtrlCHandler {
fn handle(&self, evt: &Event, n: RepeatCount, positive: bool, ctx: &EventContext) -> Option<Cmd> {
Some(if !ctx.line().is_empty() {
Cmd::Kill(Movement::WholeLine)
} else {
Cmd::Interrupt
})
}
}
fn main() -> Result<()> {
// `()` can be used when no completer is required
let mut rl = DefaultEditor::new()?;
rl.bind_sequence(
KeyEvent::ctrl('c'),
EventHandler::Conditional(Box::from(CtrlCHandler)));
#[cfg(feature = "with-file-history")]
if rl.load_history("history.txt").is_err() {
println!("No previous history.");
}
loop {
let readline = rl.readline(">> ");
match readline {
Ok(line) => {
rl.add_history_entry(line.as_str());
println!("Line: {}", line);
},
Err(ReadlineError::Interrupted) => {
println!("CTRL-C");
break
},
Err(ReadlineError::Eof) => {
println!("CTRL-D");
break
},
Err(err) => {
println!("Error: {:?}", err);
break
}
}
}
#[cfg(feature = "with-file-history")]
rl.save_history("history.txt");
Ok(())
}

View file

@ -1,5 +1,5 @@
use crate::task::MARKER_PARENT;
use crate::tasks::nostr_users::NostrUsers;
use crate::tasks::NostrUsers;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools;
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard};

0
src/lib.rs Normal file
View file

View file

@ -12,7 +12,7 @@ use crate::event_sender::MostrMessage;
use crate::hashtag::Hashtag;
use crate::helpers::*;
use crate::kinds::{format_tag_basic, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS};
use crate::task::{State, Task, TaskState, MARKER_PROPERTY};
use crate::task::{State, StateChange, Task, MARKER_PROPERTY};
use crate::tasks::{referenced_event, PropertyCollection, StateFilter, TasksRelay};
use chrono::Local;
use colored::Colorize;
@ -135,7 +135,7 @@ async fn main() -> Result<()> {
}
let keys = read_keys(&mut rl)?;
let relayfile = config_dir.join("relays");
let relays_file = config_dir.join("relays");
let client = ClientBuilder::new()
.opts(Options::new()
@ -151,7 +151,7 @@ async fn main() -> Result<()> {
Ok(relay) => {
or_warn!(client.add_relay(relay).await);
}
_ => match File::open(&relayfile).map(|f| BufReader::new(f).lines().flatten()) {
_ => match File::open(&relays_file).map(|f| BufReader::new(f).lines().flatten()) {
Ok(lines) => {
for line in lines {
or_warn!(client.add_relay(line).await);
@ -167,7 +167,7 @@ async fn main() -> Result<()> {
};
or_warn!(client.add_relay(url.clone()).await).map(|bool| {
if bool {
or_warn!(fs::write(&relayfile, url));
or_warn!(fs::write(&relays_file, url));
}
});
};
@ -387,7 +387,7 @@ async fn main() -> Result<()> {
None => {
if let Some(task) = tasks.get_current_task() {
println!("Change History for {}:", task.get_id());
for e in once(&task.event).chain(task.props.iter().rev()) {
for e in task.all_events() {
println!("{} {} [{}]",
format_timestamp_full(&e.created_at),
match State::try_from(e.kind) {
@ -567,7 +567,7 @@ async fn main() -> Result<()> {
match tasks.get_position() {
None => {
warn!("First select a task to set its state!");
info!("Usage: ![(Open|Procedure|Pending|Done|Closed): ][Statename]");
info!("Usage: ![(Open|Procedure|Pending|Done|Closed): ][Statename] OR Time: Reason");
}
Some(id) => {
'block: {
@ -584,8 +584,8 @@ async fn main() -> Result<()> {
tasks.set_state_for(id, right, State::Pending);
tasks.custom_time = Some(stamp);
tasks.set_state_for(id,
&state.as_ref().map(TaskState::get_label).unwrap_or_default(),
state.map(|ts| ts.state).unwrap_or(State::Open));
&state.as_ref().map(StateChange::get_label).unwrap_or_default(),
State::from(state));
break 'block;
}
}
@ -728,7 +728,7 @@ async fn main() -> Result<()> {
let filtered =
tasks.get_filtered(pos, |t| {
transform(&t.event.content).contains(&remaining) ||
transform(&t.get_title()).contains(&remaining) ||
t.list_hashtags().any(
|tag| tag.contains(&remaining))
});

View file

@ -1,9 +1,14 @@
mod state;
#[cfg(test)]
mod tests;
use fmt::Display;
use std::cmp::Ordering;
use std::collections::btree_set::Iter;
use std::collections::BTreeSet;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::iter::once;
use std::iter::{once, Chain, Once};
use std::str::FromStr;
use std::string::ToString;
@ -12,6 +17,9 @@ use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
pub use crate::task::state::State;
pub use crate::task::state::StateChange;
use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right};
use itertools::Itertools;
@ -25,7 +33,7 @@ pub static MARKER_PROPERTY: &str = "property";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Task {
/// Event that defines this task
pub(crate) event: Event,
pub(super) event: Event, // TODO make private
/// Cached sorted tags of the event with references removed
tags: Option<BTreeSet<Tag>>,
/// Task references derived from the event tags
@ -68,8 +76,13 @@ impl Task {
}
}
pub(crate) fn get_id(&self) -> &EventId {
&self.event.id
/// All Events including the task and its props in chronological order
pub(crate) fn all_events(&self) -> impl DoubleEndedIterator<Item=&Event> {
once(&self.event).chain(self.props.iter().rev())
}
pub(crate) fn get_id(&self) -> EventId {
self.event.id
}
pub(crate) fn get_participants(&self) -> impl Iterator<Item=PublicKey> + '_ {
@ -84,28 +97,30 @@ impl Task {
.unwrap_or_else(|| self.event.pubkey)
}
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker))
}
pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next()
}
pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect()
}
/// 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> {
self.refs.iter().filter_map(move |(str, id)|
Some(id).filter(|_| str == marker))
}
pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.find_refs(MARKER_PARENT).next()
}
pub(crate) fn find_dependents(&self) -> Vec<&EventId> {
self.find_refs(MARKER_DEPENDS).collect()
}
fn description_events(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
self.props.iter().filter(|event| event.kind == Kind::TextNote)
}
@ -139,9 +154,9 @@ impl Task {
})
}
fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
fn states(&self) -> impl DoubleEndedIterator<Item=StateChange> + '_ {
self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| TaskState {
event.kind.try_into().ok().map(|s| StateChange {
name: some_non_empty(&event.content),
state: s,
time: event.created_at,
@ -153,7 +168,7 @@ impl Task {
self.state().map(|s| s.time).unwrap_or(self.event.created_at)
}
pub fn state_at(&self, time: Timestamp) -> Option<TaskState> {
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| {
@ -166,16 +181,16 @@ impl Task {
}
/// Returns the current state if this is a task rather than an activity
pub fn state(&self) -> Option<TaskState> {
pub fn state(&self) -> Option<StateChange> {
let now = now();
self.state_at(now)
}
pub(crate) fn pure_state(&self) -> State {
self.state().map_or(State::Open, |s| s.state)
State::from(self.state())
}
pub(crate) fn state_or_default(&self) -> TaskState {
pub(crate) fn state_or_default(&self) -> StateChange {
self.state().unwrap_or_else(|| self.default_state())
}
@ -186,8 +201,8 @@ impl Task {
.map(|state| state.get_colored_label())
}
fn default_state(&self) -> TaskState {
TaskState {
fn default_state(&self) -> StateChange {
StateChange {
name: None,
state: State::Open,
time: self.event.created_at,
@ -221,7 +236,7 @@ impl Task {
pub(crate) fn get(&self, property: &str) -> Option<String> {
match property {
// Static
"id" => Some(self.event.id.to_string()),
"id" => Some(self.get_id().to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()),
"name" => Some(self.event.content.clone()),
"key" | "pubkey" => Some(self.event.pubkey.to_string()),
@ -251,159 +266,3 @@ impl Task {
}
}
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub(crate) struct TaskState {
pub(crate) state: State,
name: Option<String>,
pub(crate) time: Timestamp,
}
impl TaskState {
pub(crate) fn get_label_for(state: &State, comment: &str) -> String {
some_non_empty(comment).unwrap_or_else(|| state.to_string())
}
pub(crate) fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string())
}
pub(crate) fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub(crate) fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label)
}
}
impl Display for TaskState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_str = self.state.to_string();
write!(
f,
"{}",
self.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
.map_or(state_str, |s| format!("{}: {}", self.state, s))
)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub(crate) enum State {
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed,
/// Temporarily not actionable
Pending,
/// Actionable ordered task list
Procedure = PROCEDURE_KIND_ID as isize,
}
impl TryFrom<&str> for State {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_ascii_lowercase().as_str() {
"closed" => Ok(State::Closed),
"done" => Ok(State::Done),
"pending" => Ok(State::Pending),
"proc" | "procedure" | "list" => Ok(State::Procedure),
"open" => Ok(State::Open),
_ => Err(()),
}
}
}
impl TryFrom<Kind> for State {
type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value {
Kind::GitStatusOpen => Ok(State::Open),
Kind::GitStatusApplied => Ok(State::Done),
Kind::GitStatusClosed => Ok(State::Closed),
Kind::GitStatusDraft => Ok(State::Pending),
_ => {
if value == PROCEDURE_KIND {
Ok(State::Procedure)
} else {
Err(())
}
}
}
}
}
impl State {
pub(crate) fn is_open(&self) -> bool {
matches!(self, State::Open | State::Pending | State::Procedure)
}
pub(crate) fn kind(self) -> u16 {
self as u16
}
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self {
State::Open => str.green(),
State::Done => str.bright_black(),
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
}
}
}
impl From<State> for Kind {
fn from(value: State) -> Self {
Kind::from(value.kind())
}
}
impl Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
#[cfg(test)]
mod tasks_test {
use super::*;
use nostr_sdk::{EventBuilder, Keys};
#[test]
fn test_state() {
let keys = Keys::generate();
let mut task = Task::new(
EventBuilder::new(TASK_KIND, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.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(TaskState {
state: State::Done,
name: None,
time: now,
}));
assert_eq!(task.state_at(now - 1), Some(TaskState {
state: State::Open,
name: Some("Ready".to_string()),
time: now - 2,
}));
}
}

128
src/task/state.rs Normal file
View file

@ -0,0 +1,128 @@
use crate::helpers::some_non_empty;
use crate::kinds::{PROCEDURE_KIND, PROCEDURE_KIND_ID};
use colored::{ColoredString, Colorize};
use nostr_sdk::{Kind, Timestamp};
use std::fmt;
use std::fmt::Display;
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub struct StateChange {
pub(super) state: State,
pub(super) name: Option<String>,
pub(super) time: Timestamp,
}
impl StateChange {
pub fn get_label_for(state: &State, comment: &str) -> String {
some_non_empty(comment).unwrap_or_else(|| state.to_string())
}
pub fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string())
}
pub fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label)
}
pub fn get_timestamp(&self) -> Timestamp {
self.time
}
}
impl Display for StateChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_str = self.state.to_string();
write!(
f,
"{}",
self.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
.map_or(state_str, |s| format!("{}: {}", self.state, s))
)
}
}
impl From<Option<StateChange>> for State {
fn from(value: Option<StateChange>) -> Self {
value.map_or(State::Open, |s| s.state)
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub enum State {
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed,
/// Temporarily not actionable
Pending,
/// Ordered task list
Procedure = PROCEDURE_KIND_ID as isize,
}
impl TryFrom<&str> for State {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_ascii_lowercase().as_str() {
"closed" => Ok(State::Closed),
"done" => Ok(State::Done),
"pending" => Ok(State::Pending),
"proc" | "procedure" | "list" => Ok(State::Procedure),
"open" => Ok(State::Open),
_ => Err(()),
}
}
}
impl TryFrom<Kind> for State {
type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value {
Kind::GitStatusOpen => Ok(State::Open),
Kind::GitStatusApplied => Ok(State::Done),
Kind::GitStatusClosed => Ok(State::Closed),
Kind::GitStatusDraft => Ok(State::Pending),
_ => {
if value == PROCEDURE_KIND {
Ok(State::Procedure)
} else {
Err(())
}
}
}
}
}
impl State {
pub(crate) fn is_open(&self) -> bool {
matches!(self, State::Open | State::Pending | State::Procedure)
}
pub(crate) fn kind(self) -> u16 {
self as u16
}
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self {
State::Open => str.green(),
State::Done => str.bright_black(),
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
}
}
}
impl From<State> for Kind {
fn from(value: State) -> Self {
Kind::from(value.kind())
}
}
impl Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}

40
src/task/tests.rs Normal file
View file

@ -0,0 +1,40 @@
use super::*;
use nostr_sdk::{EventBuilder, Keys, Tag, Timestamp};
#[test]
fn test_state() {
let keys = Keys::generate();
let mut task = Task::new(
EventBuilder::new(Kind::GitIssue, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.list_hashtags().count(), 1);
let now = Timestamp::now();
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.custom_created_at(now)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
task.props.insert(
EventBuilder::new(State::Open.into(), "Ready").tags([Tag::hashtag("tag2")])
.custom_created_at(now - 2)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
assert_eq!(task.list_hashtags().count(), 2);
task.props.insert(
EventBuilder::new(State::Closed.into(), "")
.custom_created_at(now + 9)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Closed);
assert_eq!(task.state_at(now), Some(StateChange {
state: State::Done,
name: None,
time: now,
}));
assert_eq!(task.state_at(now - 1), Some(StateChange {
state: State::Open,
name: Some("Ready".to_string()),
time: now - 2,
}));
}

View file

@ -1,9 +1,13 @@
pub(crate) mod nostr_users;
mod nostr_users;
#[cfg(test)]
mod tests;
mod children_traversal;
mod durations;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fmt::{Display, Formatter};
use std::iter::{empty, once, FusedIterator};
use std::ops::{Div, Rem};
use std::ops::{Deref, Div, Rem};
use std::str::FromStr;
use std::time::Duration;
@ -14,8 +18,10 @@ use crate::helpers::{
parse_tracking_stamp, some_non_empty, to_string_or_default, CHARACTER_THRESHOLD,
};
use crate::kinds::*;
use crate::task::{State, Task, TaskState, MARKER_DEPENDS, MARKER_PARENT, MARKER_PROPERTY};
use crate::tasks::nostr_users::NostrUsers;
use crate::task::{State, StateChange, Task, MARKER_DEPENDS, MARKER_PARENT, MARKER_PROPERTY};
use crate::tasks::children_traversal::ChildrenTraversal;
use crate::tasks::durations::{referenced_events, timestamps, Durations};
pub use crate::tasks::nostr_users::NostrUsers;
use colored::Colorize;
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
@ -37,21 +43,20 @@ pub(crate) fn now() -> Timestamp {
}
type TaskMap = HashMap<EventId, Task>;
trait TaskMapMethods {
pub(super) trait TaskMapMethods {
fn children_of<'a>(&'a self, task: &'a Task) -> impl Iterator<Item=&Task> + 'a;
fn children_for<'a>(&'a self, id: Option<EventId>) -> impl Iterator<Item=&Task> + 'a;
fn children_ids_for<'a>(&'a self, id: EventId) -> impl Iterator<Item=&EventId> + 'a;
fn children_ids_for<'a>(&'a self, id: EventId) -> impl Iterator<Item=EventId> + 'a;
}
impl TaskMapMethods for TaskMap {
fn children_of<'a>(&'a self, task: &'a Task) -> impl Iterator<Item=&Task> + 'a {
self.children_for(Some(task.event.id))
self.children_for(Some(task.get_id().clone()))
}
fn children_for<'a>(&'a self, id: Option<EventId>) -> impl Iterator<Item=&Task> + 'a {
self.values().filter(move |t| t.parent_id() == id.as_ref())
}
fn children_ids_for<'a>(&'a self, id: EventId) -> impl Iterator<Item=&EventId> + 'a {
fn children_ids_for<'a>(&'a self, id: EventId) -> impl Iterator<Item=EventId> + 'a {
self.children_for(Some(id)).map(|t| t.get_id())
}
}
@ -79,6 +84,8 @@ pub(crate) struct TasksRelay {
search_depth: usize,
view_depth: usize,
pub(crate) recurse_activities: bool,
// Last position used in interface - needs auto-update
//position: Option<EventId>,
/// Currently active tags
tags: BTreeSet<Hashtag>,
@ -365,7 +372,7 @@ impl TasksRelay {
fn total_time_tracked(&self, id: EventId) -> u64 {
let mut total = 0;
let children = ChildIterator::from(&self, id).get_all();
let children = ChildrenTraversal::from(&self, id).get_all();
for user in self.history.values() {
total += Durations::from(user.values(), &children)
.sum::<Duration>()
@ -374,15 +381,15 @@ impl TasksRelay {
total
}
fn total_progress(&self, id: &EventId) -> Option<f32> {
self.get_by_id(id).and_then(|task| match task.pure_state() {
fn total_progress(&self, id: EventId) -> Option<f32> {
self.get_by_id(&id).and_then(|task| match task.pure_state() {
State::Closed => None,
State::Done => Some(1.0),
_ => {
let mut sum = 0f32;
let mut count = 0;
for prog in self.tasks
.children_ids_for(task.event.id)
.children_ids_for(task.get_id())
.filter_map(|e| self.total_progress(e))
{
sum += prog;
@ -395,6 +402,7 @@ impl TasksRelay {
// Parents
/// Move up `count` parent tasks from current position
pub(crate) fn up_by(&self, count: usize) -> Option<EventId> {
let pos = self.get_position();
let mut result = pos.as_ref();
@ -448,7 +456,7 @@ impl TasksRelay {
pub(crate) fn get_relative_path(&self, id: EventId) -> String {
join_tasks(
self.traverse_up_from(Some(id))
.take_while(|t| Some(t.event.id) != self.get_position()),
.take_while(|t| Some(t.get_id()) != self.get_position()),
false,
).unwrap_or(id.to_string())
}
@ -596,9 +604,9 @@ impl TasksRelay {
}
}
fn quick_access_raw(&self) -> impl Iterator<Item=&EventId> {
fn quick_access_raw(&self) -> impl Iterator<Item=EventId> + '_ {
// TODO add recent tasks (most time tracked + recently created)
self.bookmarks.iter()
self.bookmarks.iter().cloned()
.chain(
// Latest
self.tasks.values()
@ -616,13 +624,13 @@ impl TasksRelay {
fn bookmarked_tasks_deduped(&self, visible: &[&Task]) -> impl Iterator<Item=&Task> {
let tree = visible.iter()
.flat_map(|task| self.traverse_up_from(Some(task.event.id)))
.flat_map(|task| self.traverse_up_from(Some(task.get_id())))
.unique();
let pos = self.get_position();
let ids: HashSet<&EventId> = tree.map(|t| t.get_id()).chain(pos.as_ref()).collect();
let ids: HashSet<EventId> = tree.map(|t| t.get_id()).chain(pos).collect();
self.quick_access_raw()
.filter(|id| !ids.contains(id))
.filter_map(|id| self.get_by_id(id))
.filter_map(|id| self.get_by_id(&id))
.filter(|t| self.filter(t))
.sorted_by_cached_key(|t| self.sorting_key(t))
.dedup()
@ -653,7 +661,7 @@ impl TasksRelay {
}
"state" => {
if let Some(task) = task
.get_dependendees()
.find_dependents()
.iter()
.filter_map(|id| self.get_by_id(id))
.find(|t| t.pure_state().is_open())
@ -674,7 +682,7 @@ impl TasksRelay {
"owner" => format!("{:.6}", self.users.get_username(&task.get_owner())),
"author" | "creator" => format!("{:.6}", self.users.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment
"prio" => self
.traverse_up_from(Some(task.event.id))
.traverse_up_from(Some(task.get_id()))
.find_map(Task::priority_raw)
.map(|p| p.to_string())
.unwrap_or_else(|| {
@ -684,11 +692,11 @@ impl TasksRelay {
"".to_string()
}
}),
"path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.get_relative_path(task.event.id),
"path" => self.get_task_path(Some(task.get_id())),
"rpath" => self.get_relative_path(task.get_id()),
// TODO format strings configurable
"time" => display_time("MMMm", self.time_tracked(*task.get_id())),
"rtime" => display_time("HH:MM", self.total_time_tracked(*task.get_id())),
"time" => display_time("MMMm", self.time_tracked(task.get_id())),
"rtime" => display_time("HH:MM", self.total_time_tracked(task.get_id())),
prop => task.get(prop).unwrap_or_default(),
}
}
@ -747,7 +755,7 @@ impl TasksRelay {
self.filtered_tasks(position, false)
.into_iter()
.filter(predicate)
.map(|t| t.event.id)
.map(|t| t.get_id())
.collect()
}
@ -879,22 +887,22 @@ impl TasksRelay {
let content = task.get_filter_title();
let lowercase = content.to_ascii_lowercase();
if lowercase == lowercase_arg {
return vec![task.event.id];
return vec![task.get_id()];
} else if content.starts_with(arg) {
filtered.push(task.event.id)
filtered.push(task.get_id())
} else if regex.as_ref()
.map(|r| r.is_match(lowercase.as_bytes()))
.unwrap_or_else(|_| lowercase.starts_with(&lowercase_arg)) {
filtered_fuzzy.push(task.event.id)
filtered_fuzzy.push(task.get_id())
}
}
// Find global exact match
for task in self.tasks.values() {
if task.get_filter_title().to_ascii_lowercase() == lowercase_arg &&
// exclude closed tasks and their subtasks
!self.traverse_up_from(Some(*task.get_id())).any(|t| !t.pure_state().is_open())
!self.traverse_up_from(Some(task.get_id())).any(|t| !t.pure_state().is_open())
{
return vec![task.event.id];
return vec![task.get_id()];
}
}
@ -1284,7 +1292,7 @@ impl TasksRelay {
.find(|e| {
referenced_event(e)
.and_then(|id| self.get_by_id(&id))
.is_some_and(|t| t.event.content.to_ascii_lowercase().contains(&lower))
.is_some_and(|t| t.get_title().to_ascii_lowercase().contains(&lower))
});
if let Some(event) = found {
self.move_to(referenced_event(event));
@ -1329,7 +1337,7 @@ impl TasksRelay {
let ids =
if state == State::Closed {
// Close whole subtree
ChildIterator::from(self, id).get_all()
ChildrenTraversal::from(self, id).get_all()
} else {
vec![id]
};
@ -1341,7 +1349,7 @@ impl TasksRelay {
.tags(tags);
info!(
"Task status {} set for \"{}\"{}{}",
TaskState::get_label_for(&state, comment),
StateChange::get_label_for(&state, comment),
self.get_task_title(&id),
self.custom_time
.map(|ts| format!(" at {}", format_timestamp_relative(&ts)))
@ -1422,7 +1430,7 @@ impl Display for TasksRelay {
let state = t.state_or_default();
let now = &now();
let mut tracking_stamp: Option<Timestamp> = None;
for elem in timestamps(self.get_own_events_history(), &[t.event.id]).map(|(e, _)| e) {
for elem in timestamps(self.get_own_events_history(), &[t.get_id()]).map(|(e, _)| e) {
if tracking_stamp.is_some() && elem > now {
break;
}
@ -1432,9 +1440,9 @@ impl Display for TasksRelay {
lock,
"Active from {} (total tracked time {}m) - {} since {}",
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
self.time_tracked(*t.get_id()) / 60,
self.time_tracked(t.get_id()) / 60,
state,
format_timestamp_relative(&state.time)
format_timestamp_relative(&state.get_timestamp())
)?;
for d in t.descriptions().rev() { writeln!(lock, "{}", d)?; }
writeln!(lock)?;
@ -1481,7 +1489,7 @@ impl Display for TasksRelay {
.map(|p| self.get_property(task, p.as_str()))
.join(" \t")
)?;
total_time += self.total_time_tracked(task.event.id) // TODO include parent if it matches
total_time += self.total_time_tracked(task.get_id()) // TODO include parent if it matches
}
writeln!(lock,
@ -1492,7 +1500,7 @@ impl Display for TasksRelay {
}
}
pub trait PropertyCollection<T> {
pub(super) trait PropertyCollection<T> {
fn remove_at(&mut self, index: usize);
fn add_or_remove(&mut self, value: T);
fn add_or_remove_at(&mut self, value: T, index: usize);
@ -1570,226 +1578,10 @@ pub(crate) fn join_tasks<'a>(
})
}
fn referenced_events(event: &Event) -> impl Iterator<Item=EventId> + '_ {
event.tags.iter().filter_map(|tag| match_event_tag(tag).map(|t| t.id))
}
pub fn referenced_event(event: &Event) -> Option<EventId> {
referenced_events(event).next()
}
/// 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
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
struct Durations<'a> {
events: Box<dyn Iterator<Item=&'a Event> + 'a>,
ids: &'a [EventId],
threshold: Option<Timestamp>,
}
impl Durations<'_> {
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)))
}
}
#[derive(Clone, Debug, PartialEq)]
enum ChildIteratorFilter {
Reject = 0b00,
TakeSelf = 0b01,
TakeChildren = 0b10,
Take = 0b11,
}
impl ChildIteratorFilter {
fn takes_children(&self) -> bool {
self == &ChildIteratorFilter::Take ||
self == &ChildIteratorFilter::TakeChildren
}
fn takes_self(&self) -> bool {
self == &ChildIteratorFilter::Take ||
self == &ChildIteratorFilter::TakeSelf
}
}
/// Breadth-First Iterator over Tasks and recursive children
struct ChildIterator<'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> ChildIterator<'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.event.id)
.collect_vec()
);
Self::with_queue(tasks, queue)
}
fn with_queue(tasks: &'a TaskMap, queue: Vec<EventId>) -> Self {
ChildIterator {
tasks: &tasks,
next_depth_at: queue.len(),
index: 0,
depth: 1,
queue,
}
}
fn from(tasks: &'a TasksRelay, id: EventId) -> Self {
let mut queue = Vec::with_capacity(64);
queue.push(id);
ChildIterator {
tasks: &tasks.tasks,
queue,
index: 0,
depth: 0,
next_depth_at: 1,
}
}
/// Process until the given depth
/// Returns true if that depth was reached
fn process_depth(&mut self, depth: usize) -> bool {
while self.depth < depth {
if self.next().is_none() {
return false;
}
}
true
}
/// Get all children
fn get_all(mut self) -> Vec<EventId> {
while self.next().is_some() {}
self.queue
}
/// Get all tasks until the specified depth
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) -> ChildIteratorFilter,
{
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.event.id));
}
}
impl FusedIterator for ChildIterator<'_> {}
impl<'a> Iterator for ChildIterator<'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.event.id);
}
}
}
Some(task) => {
self.queue_children_of(&task);
}
}
self.check_depth();
})
}
}
struct ParentIterator<'a> {
tasks: &'a TaskMap,
current: Option<EventId>,
@ -1804,452 +1596,4 @@ impl<'a> Iterator for ParentIterator<'a> {
})
}
}
#[cfg(test)]
mod tasks_test {
use super::*;
use std::collections::HashSet;
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 {
($left:expr, $right:expr $(,)?) => {
assert_eq!($left.get_position(), Some($right))
};
}
macro_rules! assert_tasks_visible {
($left:expr, $right:expr $(,)?) => {
let tasks = $left.visible_tasks();
assert_tasks!($left, tasks, $right,
"\nQuick Access: {:?}",
$left.quick_access_raw().map(|id| $left.get_relative_path(*id)).collect_vec());
};
}
macro_rules! assert_tasks_view {
($left:expr, $right:expr $(,)?) => {
let tasks = $left.viewed_tasks();
assert_tasks!($left, tasks, $right, "");
};
}
macro_rules! assert_tasks {
($left:expr, $tasks:expr, $right:expr $(, $($arg:tt)*)?) => {
assert_eq!(
$tasks
.iter()
.map(|t| t.event.id)
.collect::<HashSet<EventId>>(),
HashSet::from_iter($right.clone()),
"Tasks Visible: {:?}\nExpected: {:?}{}",
$tasks.iter().map(|t| t.event.id).map(|id| $left.get_relative_path(id)).collect_vec(),
$right.into_iter().map(|id| $left.get_relative_path(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.event.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]);
tasks.pubkey = Some(Keys::generate().public_key);
}
#[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.get_dependendees(), 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
let idagain = tasks.filter_or_create(None, "newer");
assert_eq!(idagain, None);
assert_position!(tasks, id1.unwrap());
assert_eq!(tasks.get_own_events_history().count(), 4);
assert_eq!(tasks.len(), 3);
}
#[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]
#[ignore]
fn test_timestamps() {
let mut tasks = 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
}
#[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);
debug!("{:?}", tasks);
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!(ChildIterator::from(&tasks, EventId::all_zeros()).get_all().len(), 1);
assert_eq!(ChildIterator::from(&tasks, EventId::all_zeros()).get_depth(0).len(), 1);
assert_eq!(ChildIterator::from(&tasks, t1).get_depth(0).len(), 1);
assert_eq!(ChildIterator::from(&tasks, t1).get_depth(1).len(), 3);
assert_eq!(ChildIterator::from(&tasks, t1).get_depth(2).len(), 4);
assert_eq!(ChildIterator::from(&tasks, t1).get_depth(9).len(), 4);
assert_eq!(ChildIterator::from(&tasks, t1).get_all().len(), 4);
tasks.move_to(Some(t1));
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");
tasks.view_depth = 0;
assert_tasks_view!(tasks, [t11]);
tasks.search_depth = 0;
assert_eq!(tasks.view, []);
assert_tasks_view!(tasks, []);
}
#[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.event.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");
}
#[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);
}
}
impl FusedIterator for ParentIterator<'_> {}

View file

@ -0,0 +1,160 @@
use std::iter::FusedIterator;
use itertools::Itertools;
use nostr_sdk::EventId;
use crate::task::Task;
use crate::tasks::{TaskMap, TaskMapMethods, TasksRelay};
#[derive(Clone, Debug, PartialEq)]
enum TraversalFilter {
Reject = 0b00,
TakeSelf = 0b01,
TakeChildren = 0b10,
Take = 0b11,
}
impl TraversalFilter {
fn takes_children(&self) -> bool {
self == &TraversalFilter::Take ||
self == &TraversalFilter::TakeChildren
}
fn takes_self(&self) -> bool {
self == &TraversalFilter::Take ||
self == &TraversalFilter::TakeSelf
}
}
/// Breadth-First Iterator over tasks with recursive children
pub(super) struct ChildrenTraversal<'a> {
tasks: &'a TaskMap,
/// Found Events
queue: Vec<EventId>,
/// Index of the next element in the queue
index: usize,
/// Depth of the next element
depth: usize,
/// Element with the next depth boundary
next_depth_at: usize,
}
impl<'a> ChildrenTraversal<'a> {
fn rooted(tasks: &'a TaskMap, id: Option<&EventId>) -> Self {
let mut queue = Vec::with_capacity(tasks.len());
queue.append(
&mut tasks
.values()
.filter(move |t| t.parent_id() == id)
.map(|t| t.get_id())
.collect_vec()
);
Self::with_queue(tasks, queue)
}
fn with_queue(tasks: &'a TaskMap, queue: Vec<EventId>) -> Self {
ChildrenTraversal {
tasks: &tasks,
next_depth_at: queue.len(),
index: 0,
depth: 1,
queue,
}
}
pub(super) fn from(tasks: &'a TasksRelay, id: EventId) -> Self {
let mut queue = Vec::with_capacity(64);
queue.push(id);
ChildrenTraversal {
tasks: &tasks.tasks,
queue,
index: 0,
depth: 0,
next_depth_at: 1,
}
}
/// Process until the given depth
/// Returns true if that depth was reached
pub(super) fn process_depth(&mut self, depth: usize) -> bool {
while self.depth < depth {
if self.next().is_none() {
return false;
}
}
true
}
/// Get all children
pub(super) fn get_all(mut self) -> Vec<EventId> {
while self.next().is_some() {}
self.queue
}
/// Get all tasks until the specified depth
pub(super) fn get_depth(mut self, depth: usize) -> Vec<EventId> {
self.process_depth(depth);
self.queue
}
fn check_depth(&mut self) {
if self.next_depth_at == self.index {
self.depth += 1;
self.next_depth_at = self.queue.len();
}
}
/// Get next id and advance, without adding children
fn next_task(&mut self) -> Option<EventId> {
if self.index >= self.queue.len() {
return None;
}
let id = self.queue[self.index];
self.index += 1;
Some(id)
}
/// Get the next known task and run it through the filter
fn next_filtered<F>(&mut self, filter: &F) -> Option<&'a Task>
where
F: Fn(&Task) -> TraversalFilter,
{
self.next_task().and_then(|id| {
if let Some(task) = self.tasks.get(&id) {
let take = filter(task);
if take.takes_children() {
self.queue_children_of(&task);
}
if take.takes_self() {
self.check_depth();
return Some(task);
}
}
self.check_depth();
self.next_filtered(filter)
})
}
fn queue_children_of(&mut self, task: &'a Task) {
self.queue.extend(self.tasks.children_ids_for(task.get_id()));
}
}
impl FusedIterator for ChildrenTraversal<'_> {}
impl<'a> Iterator for ChildrenTraversal<'a> {
type Item = EventId;
fn next(&mut self) -> Option<Self::Item> {
self.next_task().inspect(|id| {
match self.tasks.get(id) {
None => {
// Unknown task, might still find children, just slower
for task in self.tasks.values() {
if task.parent_id().is_some_and(|i| i == id) {
self.queue.push(task.get_id());
}
}
}
Some(task) => {
self.queue_children_of(&task);
}
}
self.check_depth();
})
}
}

83
src/tasks/durations.rs Normal file
View file

@ -0,0 +1,83 @@
use std::time::Duration;
use itertools::Itertools;
use nostr_sdk::{Event, EventId, Timestamp};
use crate::kinds::match_event_tag;
pub(super) fn referenced_events(event: &Event) -> impl Iterator<Item=EventId> + '_ {
event.tags.iter().filter_map(|tag| match_event_tag(tag).map(|t| t.id))
}
/// Returns the id of a referenced event if it is contained in the provided ids list.
fn matching_tag_id<'a>(event: &'a Event, ids: &'a [EventId]) -> Option<EventId> {
referenced_events(event).find(|id| ids.contains(id))
}
/// Filters out event timestamps to those that start or stop one of the given events
pub(super) fn timestamps<'a>(
events: impl Iterator<Item=&'a Event>,
ids: &'a [EventId],
) -> impl Iterator<Item=(&Timestamp, Option<EventId>)> {
events
.map(|event| (&event.created_at, matching_tag_id(event, ids)))
.dedup_by(|(_, e1), (_, e2)| e1 == e2)
.skip_while(|element| element.1.is_none())
}
/// Iterates Events to accumulate times tracked
/// Expects a sorted iterator
pub(super) struct Durations<'a> {
events: Box<dyn Iterator<Item=&'a Event> + 'a>,
ids: &'a [EventId],
threshold: Option<Timestamp>,
}
impl Durations<'_> {
pub(super) fn from<'b>(
events: impl IntoIterator<Item=&'b Event> + 'b,
ids: &'b [EventId],
) -> Durations<'b> {
Durations {
events: Box::new(events.into_iter()),
ids,
threshold: Some(Timestamp::now()), // TODO consider offset?
}
}
}
impl Iterator for Durations<'_> {
type Item = Duration;
fn next(&mut self) -> Option<Self::Item> {
let mut start: Option<u64> = None;
while let Some(event) = self.events.next() {
if matching_tag_id(event, self.ids).is_some() {
if self.threshold.is_some_and(|th| event.created_at > th) {
continue;
}
start = start.or(Some(event.created_at.as_u64()))
} else {
if let Some(stamp) = start {
return Some(Duration::from_secs(event.created_at.as_u64() - stamp));
}
}
}
let now = self.threshold.unwrap_or(Timestamp::now()).as_u64();
start.filter(|t| t < &now)
.map(|stamp| Duration::from_secs(now.saturating_sub(stamp)))
}
}
#[test]
#[ignore]
fn test_timestamps() {
let mut tasks = crate::tasks::tests::stub_tasks();
let zero = EventId::all_zeros();
tasks.track_at(Timestamp::now() + 100, Some(zero));
assert_eq!(
timestamps(tasks.get_own_events_history(), &[zero])
.collect_vec()
.len(),
2
)
// TODO Does not show both future and current tracking properly, need to split by current time
}

452
src/tasks/tests.rs Normal file
View file

@ -0,0 +1,452 @@
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]);
tasks.pubkey = Some(Keys::generate().public_key);
}
#[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_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);
}