Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

24 changed files with 1941 additions and 2828 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 }}

4
.gitignore vendored
View file

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

1130
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,7 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
readme = "README.md" readme = "README.md"
license = "GPL 3.0" license = "GPL 3.0"
authors = ["melonion"] authors = ["melonion"]
version = "0.9.2" version = "0.6.0"
rust-version = "1.82" rust-version = "1.82"
edition = "2021" edition = "2021"
default-run = "mostr" default-run = "mostr"
@ -13,30 +13,26 @@ 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"] }
# Basics # Basics
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] }
regex = "1.11" regex = "1.10.6"
# System # System
log = "0.4" log = "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" } rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
# OS-Specific Abstractions # OS-Specific Abstractions
keyring = "3" keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
directories = "5.0" directories = "5.0"
whoami = "1.5"
# slint = "1.8"
# Application Utils # Application Utils
itertools = "0.12" itertools = "0.12"
chrono = "0.4" chrono = "0.4"
parse_datetime = "0.5" parse_datetime = "0.5.0"
interim = { version = "0.1", features = ["chrono"] } interim = { version = "0.1", features = ["chrono"] }
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
[dev-dependencies] [dev-dependencies]
mostr = { path = ".", default-features = false } tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
chrono-english = "0.1"
[features] linefeed = "0.6"
default = ["persistence"]
persistence = ["keyring/apple-native", "keyring/windows-native", "keyring/linux-native-sync-persistent", "keyring/crypto-rust"]

181
README.md
View file

@ -10,9 +10,13 @@ An immutable nested collaborative task manager, powered by nostr!
## Quickstart ## 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: Install rust(up) and run a development build with:
cargo run -- ARGS cargo run
A `relay` list can be placed in a config file A `relay` list can be placed in a config file
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`. under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
@ -27,93 +31,14 @@ Install latest build:
cargo install --path . cargo install --path .
This one-liner can help you stay on the latest version This one-liner can help you stay on the latest version
(optionally add a `cd` to your mostr-directory in front to use it anywhere): (optionally add a `cd` to your mostr-directory in front):
git pull && cargo install --path . && mostr git pull && cargo install --path . && mostr
To exit mostr, press `Ctrl-D`. Creating a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
### Migrating To exit the application, press `Ctrl-D`.
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 +73,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,
@ -213,27 +134,66 @@ If you want to TBC...
- Pin to bookmarks - Pin to bookmarks
- high priority - 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 ### 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
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 ## Plans
- Handle event sending rejections (e.g. permissions) - Handle event sending rejections (e.g. permissions)
@ -249,7 +209,8 @@ To create a test task externally:
### Commands ### Commands
Open Command characters: `_^\=$%~'"`, `{}[]` - Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
### Conceptual ### Conceptual
@ -260,7 +221,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
@ -275,19 +236,13 @@ Suggestions welcome!
- 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 - n8n node
- Webcal Feed: Scheduled (planning) / Tracked (events, time-tracking) with args for how far back/forward - Webcal Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward
Interfaces: Interfaces:
- text-based REPL for terminal and messengers - text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/ - 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 ## Exemplary Workflows - User Stories
- Freelancer - Freelancer

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 +1,2 @@
[toolchain] [toolchain]
channel = "1.84.0" channel = "nightly-2024-11-09"

View file

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

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,3 +1,5 @@
use std::ops::Sub;
use chrono::LocalResult::Single; use chrono::LocalResult::Single;
use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc}; use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc};
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
@ -32,24 +34,15 @@ impl<T: TimeZone> ToTimestamp for DateTime<T> {
} }
} }
/// Parses the hour from a plain number in the String,
/// Parses the hour optionally with minute from a plain number in a String,
/// with max of max_future hours into the future. /// with max of max_future hours into the future.
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> { pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
parse_hour_after(str, Local::now() - TimeDelta::hours(24 - max_future)) str.parse::<u32>().ok().and_then(|hour| {
} let now = Local::now();
/// 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)] #[allow(deprecated)]
after.date().and_hms_opt( now.date().and_hms_opt(hour, 0, 0).map(|time| {
if str.len() > 2 { number / 100 } else { number }, if time - now > TimeDelta::hours(max_future) {
if str.len() > 2 { number % 100 } else { 0 }, time.sub(TimeDelta::days(1))
0,
).map(|time| {
if time < after {
time + TimeDelta::days(1)
} else { } else {
time time
} }
@ -58,15 +51,11 @@ pub fn parse_hour_after<T: TimeZone>(str: &str, after: DateTime<T>) -> Option<Da
} }
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}");
@ -86,16 +75,15 @@ pub fn parse_date_with_ref(str: &str, reference: DateTime<Local>) -> Option<Date
} }
/// Turn a human-readable relative timestamp into a nostr Timestamp. /// Turn a human-readable relative timestamp into a nostr Timestamp.
/// - Plain number as hour after given date, if none 18 hours back or 6 hours forward /// - Plain number as hour, 18 hours back or 6 hours forward
/// - Number with prefix as minute offset /// - Number with prefix as minute offset
/// - Otherwise try to parse a relative date /// - Otherwise try to parse a relative date
pub fn parse_tracking_stamp(str: &str, after: Option<DateTime<Local>>) -> Option<Timestamp> { pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
if let Some(num) = parse_hour_after(str, after.unwrap_or(Local::now() - TimeDelta::hours(18))) { if let Some(num) = parse_hour(str, 6) {
return Some(num.to_timestamp()); 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| {
@ -134,8 +122,10 @@ pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where where
F: Fn(DateTime<Local>) -> String, F: Fn(DateTime<Local>) -> String,
{ {
Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0).earliest() match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
.map_or_else(|| stamp.to_human_datetime().to_string(), formatter) Single(time) => formatter(time),
_ => stamp.to_human_datetime().to_string(),
}
} }
/// Format nostr Timestamp relative to local time /// Format nostr Timestamp relative to local time
@ -156,11 +146,6 @@ pub fn format_timestamp_local(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M") 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 { pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
// Rough difference in days // Rough difference in days
match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 { match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 {
@ -169,49 +154,3 @@ pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) ->
_ => format_timestamp_local(stamp), _ => format_timestamp_local(stamp),
} }
} }
mod test {
use super::*;
use chrono::{FixedOffset, NaiveDate, Timelike};
use interim::datetime::DateTime;
#[test]
fn parse_hours() {
let now = Local::now();
#[allow(deprecated)]
let date = now.date();
if now.hour() > 2 {
assert_eq!(
parse_hour("23", 22).unwrap(),
date.and_hms_opt(23, 0, 0).unwrap()
);
}
if now.hour() < 22 {
assert_eq!(
parse_hour("02", 2).unwrap(),
date.and_hms_opt(2, 0, 0).unwrap()
);
assert_eq!(
parse_hour("2301", 1).unwrap(),
(date - TimeDelta::days(1)).and_hms_opt(23, 01, 0).unwrap()
);
}
let date = NaiveDate::from_ymd_opt(2020, 10, 10).unwrap();
let time = Utc.from_utc_datetime(
&date.and_hms_opt(10, 1,0).unwrap()
);
assert_eq!(parse_hour_after("2201", time).unwrap(), Utc.from_utc_datetime(&date.and_hms_opt(22, 1, 0).unwrap()));
assert_eq!(parse_hour_after("10", time).unwrap(), Utc.from_utc_datetime(&(date + TimeDelta::days(1)).and_hms_opt(10, 0, 0).unwrap()));
// TODO test timezone offset issues
}
#[test]
fn test_timezone() {
assert_eq!(
FixedOffset::east_opt(7200).unwrap().timestamp_millis_opt(1000).unwrap().time(),
NaiveTime::from_hms_opt(2, 0, 1).unwrap()
);
}
}

View file

@ -1,8 +1,7 @@
use crate::task::MARKER_PARENT; use crate::task::MARKER_PARENT;
use crate::tasks::NostrUsers;
use crate::tasks::HIGH_PRIO; use crate::tasks::HIGH_PRIO;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard}; use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
use std::borrow::Cow; use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue; pub const TASK_KIND: Kind = Kind::GitIssue;
@ -42,11 +41,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
@ -80,8 +78,7 @@ where
.tags(id.into_iter().map(Tag::event)) .tags(id.into_iter().map(Tag::event))
} }
/// Formats and joins the tags with commata pub fn join<'a, T>(tags: T) -> String
pub fn join_tags<'a, T>(tags: T) -> String
where where
T: IntoIterator<Item=&'a Tag>, T: IntoIterator<Item=&'a Tag>,
{ {
@ -93,85 +90,80 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
input.split_ascii_whitespace() input.split_ascii_whitespace()
.filter(|s| s.starts_with('#')) .filter(|s| s.starts_with('#'))
.map(|s| s.trim_start_matches('#')) .map(|s| s.trim_start_matches('#'))
.map(to_hashtag_tag) .map(to_hashtag)
} }
/// Extracts everything after a " # " as a list of tags /// Extracts everything after a " # " as a list of tags
/// as well as various embedded tags. /// as well as various embedded tags.
/// ///
/// Expects sanitized input. /// Expects sanitized input.
pub(crate) fn extract_tags(input: &str, users: &NostrUsers) -> (String, Vec<Tag>) { pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
let words = input.split_ascii_whitespace(); let words = input.split_ascii_whitespace();
let mut tags = Vec::with_capacity(4); let mut prio = None;
let result = words.filter(|s| { let result = words.filter(|s| {
if s.starts_with('@') { if s.starts_with('*') {
if let Ok(key) = PublicKey::parse(&s[1..]) {
tags.push(Tag::public_key(key));
return false;
} else if let Some((key, _)) = users.find_user(&s[1..]) {
tags.push(Tag::public_key(*key));
return false;
}
} else if s.starts_with('*') {
if s.len() == 1 { if s.len() == 1 {
tags.push(to_prio_tag(HIGH_PRIO)); prio = Some(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; return false;
} }
return match s[1..].parse::<Prio>() {
Ok(num) => {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
false
}
_ => true,
};
} }
true true
}).collect_vec(); }).collect_vec();
let mut split = result.split(|e| { e == &"#" }); let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" "); let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main) let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag_tag(&s))) .chain(split.flatten().map(|s| to_hashtag(&s)))
.chain(tags) .chain(prio.map(|p| to_prio_tag(p)))
.collect_vec(); .collect_vec();
tags.sort(); tags.sort();
tags.dedup(); tags.dedup();
(main, tags) (main, tags)
} }
pub fn to_hashtag_tag(tag: &str) -> Tag { pub fn to_hashtag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into() TagStandard::Hashtag(tag.to_string()).into()
} }
pub fn format_tag(tag: &Tag) -> String { fn format_tag(tag: &Tag) -> String {
if let Some(et) = match_event_tag(tag) { if let Some(et) = match_event_tag(tag) {
return format!("{}: {:.8}", return format!("{}: {:.8}",
et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()), et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()),
et.id); et.id);
} }
format_tag_basic(tag)
}
pub fn format_tag_basic(tag: &Tag) -> String {
match tag.as_standardized() { match tag.as_standardized() {
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, 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.as_slice().join(" ")
} }
} }
pub fn to_prio_tag(value: Prio) -> Tag { pub(crate) fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
}
pub(crate) fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()]) Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
} }
#[test] #[test]
fn test_extract_tags() { fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it", &Default::default()), assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it"),
("Hello from #mars with #greetings #yeah".to_string(), ("Hello from #mars with #greetings #yeah".to_string(),
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.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)) .chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag)).collect()));
.collect())); assert_eq!(extract_tags("So tagless #"),
assert_eq!(extract_tags("So tagless @hewo #", &Default::default()), ("So tagless".to_string(), vec![]));
("So tagless @hewo".to_string(), vec![]));
} }

View file

View file

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

View file

@ -1,30 +1,20 @@
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; use std::collections::BTreeSet;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::iter::{once, Chain, Once}; use std::iter::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::{Alphabet, Event, EventId, Kind, Tag, Timestamp};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{is_hashtag, match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
pub static MARKER_PARENT: &str = "parent"; pub static MARKER_PARENT: &str = "parent";
pub static MARKER_DEPENDS: &str = "depends"; pub static MARKER_DEPENDS: &str = "depends";
@ -33,7 +23,7 @@ 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 removed
tags: Option<BTreeSet<Tag>>, tags: Option<BTreeSet<Tag>>,
/// Task references derived from the event tags /// Task references derived from the event tags
@ -76,29 +66,20 @@ 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 { pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
self.event.id self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker))
} }
pub(crate) fn get_participants(&self) -> impl Iterator<Item=PublicKey> + '_ { pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.tags() self.find_refs(MARKER_PARENT).next()
.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> { pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
self.get_participants().next() self.find_refs(MARKER_DEPENDS).collect()
}
pub(crate) fn get_owner(&self) -> PublicKey {
self.get_assignee()
.unwrap_or_else(|| self.event.pubkey)
} }
/// Trimmed event content or stringified id /// Trimmed event content or stringified id
@ -107,32 +88,16 @@ impl Task {
.unwrap_or_else(|| self.get_id().to_string()) .unwrap_or_else(|| self.get_id().to_string())
} }
/// Title with leading hashtags removed
pub(crate) fn get_filter_title(&self) -> String { pub(crate) fn get_filter_title(&self) -> String {
self.event.content.trim().trim_start_matches('#').to_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 description_events(&self) -> impl Iterator<Item=&Event> + '_ {
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) self.props.iter().filter(|event| event.kind == Kind::TextNote)
} }
/// Description items, ordered newest to oldest pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ { self.description_events().map(|e| &e.content)
self.description_events()
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty()))
} }
pub(crate) fn is_task_kind(&self) -> bool { pub(crate) fn is_task_kind(&self) -> bool {
@ -158,9 +123,9 @@ impl Task {
}) })
} }
fn states(&self) -> impl DoubleEndedIterator<Item=StateChange> + '_ { fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
self.props.iter().filter_map(|event| { self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| 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,
@ -168,15 +133,16 @@ impl Task {
}) })
} }
pub fn last_state_update(&self) -> Timestamp { pub(crate) fn last_state_update(&self) -> Timestamp {
self.state().map(|s| s.time).unwrap_or(self.event.created_at) self.state().map(|s| s.time).unwrap_or(self.event.created_at)
} }
pub fn state_at(&self, time: Timestamp) -> Option<StateChange> { pub(crate) fn state(&self) -> Option<TaskState> {
let now = now();
// TODO do not iterate constructed state objects // TODO do not iterate constructed state objects
let state = self.states().take_while_inclusive(|ts| ts.time > time); let state = self.states().take_while_inclusive(|ts| ts.time > now);
state.last().map(|ts| { state.last().map(|ts| {
if ts.time <= time { if ts.time <= now {
ts ts
} else { } else {
self.default_state() self.default_state()
@ -184,17 +150,11 @@ impl Task {
}) })
} }
/// 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())
} }
@ -205,24 +165,23 @@ impl 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> + '_ { pub(crate) fn get_hashtags(&self) -> impl Iterator<Item=&Tag> {
self.tags().filter_map(|t| Hashtag::try_from(t).ok()) self.tags().filter(|t| is_hashtag(t))
} }
/// Tags of this task that are not event references, newest to oldest
fn tags(&self) -> impl Iterator<Item=&Tag> { fn tags(&self) -> impl Iterator<Item=&Tag> {
self.props.iter() self.tags.iter().flatten().chain(
.flat_map(|e| e.tags.iter() self.props.iter().flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E))) .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 fn join_tags<P>(&self, predicate: P) -> String
@ -240,7 +199,7 @@ impl Task {
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()), "key" | "pubkey" => Some(self.event.pubkey.to_string()),
@ -249,8 +208,8 @@ impl Task {
// Dynamic // Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()), "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" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
"tags" => Some(self.join_tags(|_| true)), // TODO test these! "tags" => Some(self.join_tags(|_| true)), // TODO test these!
"alltags" => Some(format!("{:?}", self.tags)), "alltags" => Some(format!("{:?}", self.tags)),
@ -262,7 +221,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 +232,145 @@ impl Task {
} }
} }
} }
pub(crate) struct TaskState {
pub(crate) state: State,
name: Option<String>,
pub(crate) time: Timestamp,
}
impl TaskState {
pub(crate) fn get_label_for(state: &State, comment: &str) -> String {
some_non_empty(comment).unwrap_or_else(|| state.to_string())
}
pub(crate) fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string())
}
pub(crate) fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub(crate) fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label)
}
}
impl Display for TaskState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state_str = self.state.to_string();
write!(
f,
"{}",
self.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.eq_ignore_ascii_case(&state_str))
.map_or(state_str, |s| format!("{}: {}", self.state, s))
)
}
}
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
pub(crate) enum State {
/// Actionable
Open = 1630,
/// Completed
Done,
/// Not Actionable (anymore)
Closed,
/// Temporarily not actionable
Pending,
/// Actionable ordered task list
Procedure = PROCEDURE_KIND_ID as isize,
}
impl TryFrom<&str> for State {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_ascii_lowercase().as_str() {
"closed" => Ok(State::Closed),
"done" => Ok(State::Done),
"pending" => Ok(State::Pending),
"proc" | "procedure" | "list" => Ok(State::Procedure),
"open" => Ok(State::Open),
_ => Err(()),
}
}
}
impl TryFrom<Kind> for State {
type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value {
Kind::GitStatusOpen => Ok(State::Open),
Kind::GitStatusApplied => Ok(State::Done),
Kind::GitStatusClosed => Ok(State::Closed),
Kind::GitStatusDraft => Ok(State::Pending),
_ => {
if value == PROCEDURE_KIND {
Ok(State::Procedure)
} else {
Err(())
}
}
}
}
}
impl State {
pub(crate) fn is_open(&self) -> bool {
matches!(self, State::Open | State::Pending | State::Procedure)
}
pub(crate) fn kind(self) -> u16 {
self as u16
}
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self {
State::Open => str.green(),
State::Done => str.bright_black(),
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
}
}
}
impl From<State> for Kind {
fn from(value: State) -> Self {
Kind::from(value.kind())
}
}
impl Display for State {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
#[cfg(test)]
mod tasks_test {
use super::*;
use nostr_sdk::{EventBuilder, Keys};
#[test]
fn test_state() {
let keys = Keys::generate();
let mut task = Task::new(
EventBuilder::new(TASK_KIND, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.get_hashtags().count(), 1);
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
task.props.insert(
EventBuilder::new(State::Open.into(), "").tags([Tag::hashtag("tag2")])
.custom_created_at(Timestamp::from(Timestamp::now() - 2))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
assert_eq!(task.get_hashtags().count(), 2);
task.props.insert(
EventBuilder::new(State::Closed.into(), "")
.custom_created_at(Timestamp::from(Timestamp::now() + 1))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Closed);
}
}

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