forked from janek/mostr
Compare commits
25 commits
ed72bcebcf
...
00bd7a997a
Author | SHA1 | Date | |
---|---|---|---|
|
00bd7a997a | ||
|
cb1d8ef8fb | ||
|
7561bc0e2f | ||
|
360b44e64e | ||
|
adcd35967f | ||
|
2400f7c45b | ||
|
e186d034e5 | ||
|
59b789d5ed | ||
|
473f26d7a5 | ||
|
43f8a3ebca | ||
|
9a9c30dbb7 | ||
|
a0e411d2e9 | ||
|
ecc5b7686b | ||
|
d095c65b23 | ||
|
6b8bf29b20 | ||
|
2cec689bf1 | ||
|
44feea9894 | ||
|
3fa8df4eaa | ||
|
b9f1d461fb | ||
|
7cedd980fb | ||
|
55d42fc52c | ||
|
15a2ffd7e6 | ||
|
5f8a232bd5 | ||
|
5dfd7a084b | ||
|
ca24693dbb |
10 changed files with 1536 additions and 909 deletions
1157
Cargo.lock
generated
1157
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
24
Cargo.toml
24
Cargo.toml
|
@ -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.5.0"
|
version = "0.6.0"
|
||||||
rust-version = "1.82"
|
rust-version = "1.82"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mostr"
|
default-run = "mostr"
|
||||||
|
@ -13,20 +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]
|
||||||
directories = "5.0"
|
# Basics
|
||||||
itertools = "0.12"
|
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
|
regex = "1.10.6"
|
||||||
|
# System
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = "0.4"
|
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
colog = "1.3"
|
colog = "1.3"
|
||||||
colored = "2.1"
|
colored = "2.1"
|
||||||
|
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
|
||||||
|
# OS-Specific Abstractions
|
||||||
|
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
|
||||||
|
directories = "5.0"
|
||||||
|
# Application Utils
|
||||||
|
itertools = "0.12"
|
||||||
|
chrono = "0.4"
|
||||||
parse_datetime = "0.5.0"
|
parse_datetime = "0.5.0"
|
||||||
interim = { version = "0.1", features = ["chrono"] }
|
interim = { version = "0.1", features = ["chrono"] }
|
||||||
nostr-sdk = "0.34" # { git = "https://github.com/rust-nostr/nostr" }
|
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
|
||||||
tokio = { version = "1.40", features = ["rt", "rt-multi-thread", "macros"] }
|
|
||||||
regex = "1.10.6"
|
|
||||||
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
|
||||||
chrono-english = "0.1"
|
chrono-english = "0.1"
|
||||||
linefeed = "0.6"
|
linefeed = "0.6"
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
All used nostr kinds are listed on the top of [kinds.rs](./src/kinds.rs)
|
All used nostr kinds are listed on the top of [kinds.rs](./src/kinds.rs)
|
||||||
|
|
||||||
Mostr mainly uses the following NIPs:
|
Mostr mainly uses the following [NIPs](https://github.com/nostr-protocol/nips):
|
||||||
|
|
||||||
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
|
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
|
||||||
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
|
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
|
||||||
|
|
|
@ -14,17 +14,17 @@ First, start a nostr relay, such as
|
||||||
- https://github.com/coracle-social/bucket for local development
|
- https://github.com/coracle-social/bucket for local development
|
||||||
- https://github.com/rnostr/rnostr for production use
|
- https://github.com/rnostr/rnostr for production use
|
||||||
|
|
||||||
Install rustup and run a development build with:
|
Install rust(up) and run a development build with:
|
||||||
|
|
||||||
cargo run
|
cargo run
|
||||||
|
|
||||||
A `relay` list and private `key` can be placed in config files
|
A `relay` list can be placed in a config file
|
||||||
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
|
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
|
||||||
Ideally any project with different collaborators has its own relay.
|
Ideally any project with different collaborators has its own relay.
|
||||||
If not saved, mostr will ask for a relay url
|
If not saved, mostr will ask for a relay url
|
||||||
(entering none is fine too, but your data will not be persisted between sessions)
|
(entering none is fine too, but your data will not be persisted between sessions)
|
||||||
and a private key, alternatively generating one on the fly.
|
and a private key, alternatively generating one on the fly.
|
||||||
Both are currently saved in plain text to the above files.
|
The key is saved in the system keychain.
|
||||||
|
|
||||||
Install latest build:
|
Install latest build:
|
||||||
|
|
||||||
|
|
107
src/event_sender.rs
Normal file
107
src/event_sender.rs
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ops::Sub;
|
||||||
|
|
||||||
|
use nostr_sdk::prelude::*;
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use crate::kinds::TRACKING_KIND;
|
||||||
|
use crate::tasks;
|
||||||
|
use log::{debug, error, info, trace, warn};
|
||||||
|
use nostr_sdk::Event;
|
||||||
|
|
||||||
|
const UNDO_DELAY: u64 = 60;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
||||||
|
pub(crate) enum MostrMessage {
|
||||||
|
Flush,
|
||||||
|
NewRelay(Url),
|
||||||
|
AddTasks(Url, Vec<Event>),
|
||||||
|
}
|
||||||
|
|
||||||
|
type Events = Vec<Event>;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct EventSender {
|
||||||
|
pub(crate) url: Option<Url>,
|
||||||
|
pub(crate) tx: Sender<MostrMessage>,
|
||||||
|
pub(crate) keys: Keys,
|
||||||
|
pub(crate) queue: RefCell<Events>,
|
||||||
|
}
|
||||||
|
impl EventSender {
|
||||||
|
pub(crate) fn from(url: Option<Url>, tx: &Sender<MostrMessage>, keys: &Keys) -> Self {
|
||||||
|
EventSender {
|
||||||
|
url,
|
||||||
|
tx: tx.clone(),
|
||||||
|
keys: keys.clone(),
|
||||||
|
queue: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO this direly needs testing
|
||||||
|
pub(crate) fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
|
||||||
|
let min = Timestamp::now().sub(UNDO_DELAY);
|
||||||
|
{
|
||||||
|
// Always flush if oldest event older than a minute or newer than now
|
||||||
|
let borrow = self.queue.borrow();
|
||||||
|
if borrow
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.created_at < min || e.created_at > Timestamp::now())
|
||||||
|
{
|
||||||
|
drop(borrow);
|
||||||
|
debug!("Flushing event queue because it is older than a minute");
|
||||||
|
self.force_flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut queue = self.queue.borrow_mut();
|
||||||
|
Ok(event_builder.sign_with_keys(&self.keys).inspect(|event| {
|
||||||
|
if event.kind == TRACKING_KIND
|
||||||
|
&& event.created_at > min
|
||||||
|
&& event.created_at < tasks::now()
|
||||||
|
{
|
||||||
|
// Do not send redundant movements
|
||||||
|
queue.retain(|e| e.kind != TRACKING_KIND);
|
||||||
|
}
|
||||||
|
queue.push(event.clone());
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
/// Sends all pending events
|
||||||
|
fn force_flush(&self) {
|
||||||
|
debug!("Flushing {} events from queue", self.queue.borrow().len());
|
||||||
|
let values = self.clear();
|
||||||
|
self.url.as_ref().map(|url| {
|
||||||
|
self.tx
|
||||||
|
.try_send(MostrMessage::AddTasks(url.clone(), values))
|
||||||
|
.err()
|
||||||
|
.map(|e| {
|
||||||
|
error!(
|
||||||
|
"Nostr communication thread failure, changes will not be persisted: {}",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/// Sends all pending events if there is a non-tracking event
|
||||||
|
pub(crate) fn flush(&self) {
|
||||||
|
if self
|
||||||
|
.queue
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|event| event.kind != TRACKING_KIND)
|
||||||
|
{
|
||||||
|
self.force_flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(crate) fn clear(&self) -> Events {
|
||||||
|
trace!("Cleared queue: {:?}", self.queue.borrow());
|
||||||
|
self.queue.replace(Vec::with_capacity(3))
|
||||||
|
}
|
||||||
|
pub(crate) fn pubkey(&self) -> PublicKey {
|
||||||
|
self.keys.public_key()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for EventSender {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.force_flush();
|
||||||
|
debug!("Dropped {:?}", self);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,10 @@ use nostr_sdk::Timestamp;
|
||||||
|
|
||||||
pub const CHARACTER_THRESHOLD: usize = 3;
|
pub const CHARACTER_THRESHOLD: usize = 3;
|
||||||
|
|
||||||
|
pub fn to_string_or_default(arg: Option<impl ToString>) -> String {
|
||||||
|
arg.map(|arg| arg.to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn some_non_empty(str: &str) -> Option<String> {
|
pub fn some_non_empty(str: &str) -> Option<String> {
|
||||||
if str.is_empty() { None } else { Some(str.to_string()) }
|
if str.is_empty() { None } else { Some(str.to_string()) }
|
||||||
}
|
}
|
||||||
|
@ -120,7 +124,7 @@ where
|
||||||
{
|
{
|
||||||
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
|
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
|
||||||
Single(time) => formatter(time),
|
Single(time) => formatter(time),
|
||||||
_ => stamp.to_human_datetime(),
|
_ => stamp.to_human_datetime().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
79
src/kinds.rs
79
src/kinds.rs
|
@ -1,8 +1,6 @@
|
||||||
use crate::task::{State, MARKER_PARENT};
|
use crate::task::MARKER_PARENT;
|
||||||
use crate::tasks::HIGH_PRIO;
|
use crate::tasks::HIGH_PRIO;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use log::info;
|
|
||||||
use nostr_sdk::TagStandard::Hashtag;
|
|
||||||
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
|
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
@ -55,23 +53,36 @@ Utilities:
|
||||||
- TBI `depends` - list all tasks this task depends on before it becomes actionable
|
- TBI `depends` - list all tasks this task depends on before it becomes actionable
|
||||||
Debugging: `kind`, `pubkey`, `props`, `alltags`, `descriptions`";
|
Debugging: `kind`, `pubkey`, `props`, `alltags`, `descriptions`";
|
||||||
|
|
||||||
|
pub struct EventTag {
|
||||||
|
pub id: EventId,
|
||||||
|
pub marker: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return event tag if existing
|
||||||
|
pub(crate) fn match_event_tag(tag: &Tag) -> Option<EventTag> {
|
||||||
|
let mut vec = tag.as_slice().into_iter();
|
||||||
|
if vec.next() == Some(&"e".to_string()) {
|
||||||
|
if let Some(id) = vec.next().and_then(|v| EventId::parse(v).ok()) {
|
||||||
|
vec.next();
|
||||||
|
return Some(EventTag { id, marker: vec.next().cloned() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
|
pub(crate) fn build_tracking<I>(id: I) -> EventBuilder
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item=EventId>,
|
I: IntoIterator<Item=EventId>,
|
||||||
{
|
{
|
||||||
EventBuilder::new(
|
EventBuilder::new(Kind::from(TRACKING_KIND), "")
|
||||||
Kind::from(TRACKING_KIND),
|
.tags(id.into_iter().map(Tag::event))
|
||||||
"",
|
|
||||||
id.into_iter().map(Tag::event),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a task with informational output and optional labeled kind
|
pub fn join<'a, T>(tags: T) -> String
|
||||||
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
where
|
||||||
info!("Created {} \"{name}\" with tags [{}]",
|
T: IntoIterator<Item=&'a Tag>,
|
||||||
kind.map(|k| k.0).unwrap_or("task"),
|
{
|
||||||
tags.iter().map(format_tag).join(", "));
|
tags.into_iter().map(format_tag).join(", ")
|
||||||
EventBuilder::new(kind.map(|k| k.1).unwrap_or(TASK_KIND), name, tags)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return Hashtags embedded in the string.
|
/// Return Hashtags embedded in the string.
|
||||||
|
@ -93,37 +104,40 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
|
||||||
if s.starts_with('*') {
|
if s.starts_with('*') {
|
||||||
if s.len() == 1 {
|
if s.len() == 1 {
|
||||||
prio = Some(HIGH_PRIO);
|
prio = Some(HIGH_PRIO);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
return match s[1..].parse::<Prio>() {
|
return match s[1..].parse::<Prio>() {
|
||||||
Ok(num) => {
|
Ok(num) => {
|
||||||
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
|
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
|
||||||
false
|
false
|
||||||
},
|
}
|
||||||
_ => true,
|
_ => 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 tags = extract_hashtags(&main)
|
let mut tags = extract_hashtags(&main)
|
||||||
.chain(split.flatten().map(|s| to_hashtag(&s)))
|
.chain(split.flatten().map(|s| to_hashtag(&s)))
|
||||||
.chain(prio.map(|p| to_prio_tag(p))).collect();
|
.chain(prio.map(|p| to_prio_tag(p)))
|
||||||
|
.collect_vec();
|
||||||
|
tags.sort();
|
||||||
|
tags.dedup();
|
||||||
(main, tags)
|
(main, tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_hashtag(tag: &str) -> Tag {
|
pub fn to_hashtag(tag: &str) -> Tag {
|
||||||
Hashtag(tag.to_string()).into()
|
TagStandard::Hashtag(tag.to_string()).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_tag(tag: &Tag) -> String {
|
fn format_tag(tag: &Tag) -> String {
|
||||||
|
if let Some(et) = match_event_tag(tag) {
|
||||||
|
return format!("{}: {:.8}",
|
||||||
|
et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()),
|
||||||
|
et.id);
|
||||||
|
}
|
||||||
match tag.as_standardized() {
|
match tag.as_standardized() {
|
||||||
Some(TagStandard::Event {
|
|
||||||
event_id,
|
|
||||||
marker,
|
|
||||||
..
|
|
||||||
}) => format!("{}: {:.8}", marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()), event_id),
|
|
||||||
Some(TagStandard::PublicKey {
|
Some(TagStandard::PublicKey {
|
||||||
public_key,
|
public_key,
|
||||||
alias,
|
alias,
|
||||||
|
@ -131,10 +145,7 @@ fn format_tag(tag: &Tag) -> String {
|
||||||
}) => format!("Key{}: {:.8}", public_key, alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default()),
|
}) => 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.content().map_or_else(
|
_ => tag.as_slice().join(" ")
|
||||||
|| format!("Kind {}", tag.kind()),
|
|
||||||
|content| content.to_string(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -149,10 +160,10 @@ pub(crate) fn to_prio_tag(value: Prio) -> Tag {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_tags() {
|
fn test_extract_tags() {
|
||||||
assert_eq!(extract_tags("Hello from #mars with #greetings *4 # # yeah done-it"),
|
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it"),
|
||||||
("Hello from #mars with #greetings".to_string(),
|
("Hello from #mars with #greetings #yeah".to_string(),
|
||||||
["mars", "greetings", "yeah", "done-it"].into_iter().map(to_hashtag)
|
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))
|
||||||
.chain(std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))).collect()));
|
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag)).collect()));
|
||||||
assert_eq!(extract_tags("So tagless #"),
|
assert_eq!(extract_tags("So tagless #"),
|
||||||
("So tagless".to_string(), vec![]));
|
("So tagless".to_string(), vec![]));
|
||||||
}
|
}
|
229
src/main.rs
229
src/main.rs
|
@ -1,19 +1,24 @@
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::env::{args, var};
|
use std::env::{args, var};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::ops::Sub;
|
use std::iter::once;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
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 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 log::{debug, error, info, trace, warn, LevelFilter};
|
use log::{debug, error, info, trace, warn, LevelFilter};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use nostr_sdk::TagStandard::Hashtag;
|
use nostr_sdk::TagStandard::Hashtag;
|
||||||
|
@ -22,21 +27,15 @@ use rustyline::config::Configurer;
|
||||||
use rustyline::error::ReadlineError;
|
use rustyline::error::ReadlineError;
|
||||||
use rustyline::DefaultEditor;
|
use rustyline::DefaultEditor;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Sender;
|
|
||||||
use tokio::time::error::Elapsed;
|
use tokio::time::error::Elapsed;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use crate::helpers::*;
|
|
||||||
use crate::kinds::{Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
|
|
||||||
use crate::task::{State, Task, TaskState};
|
|
||||||
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay};
|
|
||||||
|
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod task;
|
mod task;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod kinds;
|
mod kinds;
|
||||||
|
mod event_sender;
|
||||||
|
|
||||||
const UNDO_DELAY: u64 = 60;
|
|
||||||
const INACTVITY_DELAY: u64 = 200;
|
const INACTVITY_DELAY: u64 = 200;
|
||||||
const LOCAL_RELAY_NAME: &str = "TEMP";
|
const LOCAL_RELAY_NAME: &str = "TEMP";
|
||||||
|
|
||||||
|
@ -62,95 +61,36 @@ macro_rules! or_warn {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Events = Vec<Event>;
|
fn read_keys(readline: &mut DefaultEditor) -> Result<Keys> {
|
||||||
|
let keys_entry = Entry::new("mostr", "keys")?;
|
||||||
#[derive(Debug, Clone)]
|
if let Ok(pass) = keys_entry.get_secret() {
|
||||||
struct EventSender {
|
return Ok(SecretKey::from_slice(&pass).map(|s| Keys::new(s))
|
||||||
url: Option<Url>,
|
.inspect_err(|e| eprintln!("Invalid key in keychain: {e}"))?);
|
||||||
tx: Sender<MostrMessage>,
|
|
||||||
keys: Keys,
|
|
||||||
queue: RefCell<Events>,
|
|
||||||
}
|
|
||||||
impl EventSender {
|
|
||||||
fn from(url: Option<Url>, tx: &Sender<MostrMessage>, keys: &Keys) -> Self {
|
|
||||||
EventSender {
|
|
||||||
url,
|
|
||||||
tx: tx.clone(),
|
|
||||||
keys: keys.clone(),
|
|
||||||
queue: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO this direly needs testing
|
|
||||||
fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
|
|
||||||
let min = Timestamp::now().sub(UNDO_DELAY);
|
|
||||||
{
|
|
||||||
// Always flush if oldest event older than a minute or newer than now
|
|
||||||
let borrow = self.queue.borrow();
|
|
||||||
if borrow.iter().any(|e| e.created_at < min || e.created_at > Timestamp::now()) {
|
|
||||||
drop(borrow);
|
|
||||||
debug!("Flushing event queue because it is older than a minute");
|
|
||||||
self.force_flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut queue = self.queue.borrow_mut();
|
|
||||||
Ok(event_builder.to_event(&self.keys).inspect(|event| {
|
|
||||||
if event.kind == TRACKING_KIND && event.created_at > min && event.created_at < tasks::now() {
|
|
||||||
// Do not send redundant movements
|
|
||||||
queue.retain(|e| e.kind != TRACKING_KIND);
|
|
||||||
}
|
|
||||||
queue.push(event.clone());
|
|
||||||
})?)
|
|
||||||
}
|
|
||||||
/// Sends all pending events
|
|
||||||
fn force_flush(&self) {
|
|
||||||
debug!("Flushing {} events from queue", self.queue.borrow().len());
|
|
||||||
let values = self.clear();
|
|
||||||
self.url.as_ref().map(|url| {
|
|
||||||
self.tx.try_send(MostrMessage::AddTasks(url.clone(), values)).err().map(|e| {
|
|
||||||
error!("Nostr communication thread failure, changes will not be persisted: {}", e)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/// Sends all pending events if there is a non-tracking event
|
|
||||||
fn flush(&self) {
|
|
||||||
if self.queue.borrow().iter().any(|event| event.kind != TRACKING_KIND) {
|
|
||||||
self.force_flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn clear(&self) -> Events {
|
|
||||||
trace!("Cleared queue: {:?}", self.queue.borrow());
|
|
||||||
self.queue.replace(Vec::with_capacity(3))
|
|
||||||
}
|
|
||||||
pub(crate) fn pubkey(&self) -> PublicKey {
|
|
||||||
self.keys.public_key()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Drop for EventSender {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.force_flush();
|
|
||||||
debug!("Dropped {:?}", self);
|
|
||||||
}
|
}
|
||||||
|
let line = readline.readline("Secret key? (leave blank to generate and save a new keypair) ")?;
|
||||||
|
let keys = if line.is_empty() {
|
||||||
|
info!("Generating and persisting new key");
|
||||||
|
Keys::generate()
|
||||||
|
} else {
|
||||||
|
Keys::from_str(&line)
|
||||||
|
.inspect_err(|e| eprintln!("Invalid key provided: {e}"))?
|
||||||
|
};
|
||||||
|
or_warn!(keys_entry.set_secret(keys.secret_key().as_secret_bytes()),
|
||||||
|
"Could not persist keys");
|
||||||
|
Ok(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
||||||
pub(crate) enum MostrMessage {
|
|
||||||
Flush,
|
|
||||||
NewRelay(Url),
|
|
||||||
AddTasks(Url, Vec<Event>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let mut rl = DefaultEditor::new()?;
|
println!("Running Mostr Version {}", env!("CARGO_PKG_VERSION"));
|
||||||
rl.set_auto_add_history(true);
|
|
||||||
|
|
||||||
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") {
|
||||||
args.next();
|
args.next();
|
||||||
let mut builder = Builder::new();
|
let mut builder = Builder::new();
|
||||||
builder.filter(None, LevelFilter::Debug)
|
builder.filter(None, LevelFilter::Debug)
|
||||||
//.filter(Some("mostr"), LevelFilter::Trace)
|
.filter(Some("mostr"), LevelFilter::Trace)
|
||||||
.parse_default_env();
|
.parse_default_env();
|
||||||
builder
|
builder
|
||||||
} else {
|
} else {
|
||||||
|
@ -159,9 +99,13 @@ async fn main() -> Result<()> {
|
||||||
//.filter(Some("nostr-relay-pool::relay::internal"), LevelFilter::Off)
|
//.filter(Some("nostr-relay-pool::relay::internal"), LevelFilter::Off)
|
||||||
builder
|
builder
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
||||||
|
// Without this filter at least at Info, the program hangs
|
||||||
.filter(Some("rustyline"), LevelFilter::Warn)
|
.filter(Some("rustyline"), LevelFilter::Warn)
|
||||||
.write_style(WriteStyle::Always)
|
.write_style(WriteStyle::Always)
|
||||||
.target(Target::Pipe(wr)))
|
.target(Target::Pipe(wr)))
|
||||||
|
@ -170,43 +114,34 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
let config_dir =
|
let config_dir =
|
||||||
ProjectDirs::from("", "", "mostr")
|
ProjectDirs::from("", "", "mostr")
|
||||||
.map(|p| p.config_dir().to_path_buf())
|
.map(|p| {
|
||||||
.unwrap_or(PathBuf::new());
|
let config = p.config_dir();
|
||||||
let keysfile = config_dir.join("key");
|
debug!("Config Directory: {:?}", config);
|
||||||
let relayfile = config_dir.join("relays");
|
or_warn!(fs::create_dir_all(config), "Could not create config directory '{:?}'", config);
|
||||||
|
config.to_path_buf()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
warn!("Could not determine config directory, using current directory");
|
||||||
|
PathBuf::new()
|
||||||
|
});
|
||||||
|
|
||||||
let keys = if let Ok(Ok(keys)) = fs::read_to_string(&keysfile).map(|s| Keys::from_str(&s)) {
|
let key_file = config_dir.join("key");
|
||||||
keys
|
if let Ok(Some(keys)) = fs::read_to_string(key_file.as_path()).map(|s| or_warn!(Keys::from_str(&s.trim()))) {
|
||||||
} else {
|
info!("Migrating private key from plaintext file {}", key_file.to_string_lossy());
|
||||||
warn!("Could not read keys from {}", keysfile.to_string_lossy());
|
or_warn!(Entry::new("mostr", "keys")
|
||||||
let line = rl.readline("Secret key? (leave blank to generate and save a new keypair) ")?;
|
.and_then(|e| e.set_secret(keys.secret_key().as_secret_bytes()))
|
||||||
let keys = if line.is_empty() {
|
.inspect(|_| { or_warn!(fs::remove_file(key_file)); }));
|
||||||
info!("Generating and persisting new key");
|
}
|
||||||
Keys::generate()
|
|
||||||
} else {
|
let keys = read_keys(&mut rl)?;
|
||||||
Keys::from_str(&line).inspect_err(|_| eprintln!())?
|
let relayfile = config_dir.join("relays");
|
||||||
};
|
|
||||||
let mut file = match File::create_new(&keysfile) {
|
|
||||||
Ok(file) => file,
|
|
||||||
Err(e) => {
|
|
||||||
let line = rl.readline(&format!("Overwrite {}? (enter anything to abort) ", keysfile.to_string_lossy()))?;
|
|
||||||
if line.is_empty() {
|
|
||||||
File::create(&keysfile)?
|
|
||||||
} else {
|
|
||||||
eprintln!();
|
|
||||||
Err(e)?
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
file.write_all(keys.secret_key().unwrap().to_string().as_bytes())?;
|
|
||||||
keys
|
|
||||||
};
|
|
||||||
|
|
||||||
let client = ClientBuilder::new()
|
let client = ClientBuilder::new()
|
||||||
.opts(Options::new()
|
.opts(Options::new()
|
||||||
.automatic_authentication(true)
|
.automatic_authentication(true)
|
||||||
.pool(RelayPoolOptions::new().notification_channel_size(8192)))
|
.notification_channel_size(8192)
|
||||||
.signer(&keys)
|
)
|
||||||
|
.signer(keys.clone())
|
||||||
.build();
|
.build();
|
||||||
info!("My public key: {}", keys.public_key());
|
info!("My public key: {}", keys.public_key());
|
||||||
|
|
||||||
|
@ -301,7 +236,7 @@ async fn main() -> Result<()> {
|
||||||
queue = Some((queue_url, queue_events));
|
queue = Some((queue_url, queue_events));
|
||||||
} else {
|
} else {
|
||||||
info!("Sending {} events to {queue_url} due to relay change", queue_events.len());
|
info!("Sending {} events to {queue_url} due to relay change", queue_events.len());
|
||||||
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
|
client.batch_event_to(vec![queue_url], queue_events).await;
|
||||||
queue = None;
|
queue = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -313,7 +248,7 @@ async fn main() -> Result<()> {
|
||||||
Ok(Some(MostrMessage::Flush)) | Err(Elapsed { .. }) => if let Some((url, events)) = queue {
|
Ok(Some(MostrMessage::Flush)) | Err(Elapsed { .. }) => if let Some((url, events)) = queue {
|
||||||
info!("Sending {} events to {url} due to {}", events.len(),
|
info!("Sending {} events to {url} due to {}", events.len(),
|
||||||
result_received.map_or("inactivity", |_| "flush message"));
|
result_received.map_or("inactivity", |_| "flush message"));
|
||||||
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
|
client.batch_event_to(vec![url], events).await;
|
||||||
queue = None;
|
queue = None;
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
|
@ -324,7 +259,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
if let Some((url, events)) = queue {
|
if let Some((url, events)) = queue {
|
||||||
info!("Sending {} events to {url} before exiting", events.len());
|
info!("Sending {} events to {url} before exiting", events.len());
|
||||||
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
|
client.batch_event_to(vec![url], events).await;
|
||||||
}
|
}
|
||||||
info!("Shutting down nostr communication thread");
|
info!("Shutting down nostr communication thread");
|
||||||
});
|
});
|
||||||
|
@ -365,7 +300,7 @@ async fn main() -> Result<()> {
|
||||||
{
|
{
|
||||||
debug!(
|
debug!(
|
||||||
"At {} found {} kind {} content \"{}\" tags {:?}",
|
"At {} found {} kind {} content \"{}\" tags {:?}",
|
||||||
event.created_at, event.id, event.kind, event.content, event.tags.iter().map(|tag| tag.as_vec()).collect_vec()
|
event.created_at, event.id, event.kind, event.content, event.tags.iter().map(|tag| tag.as_slice()).collect_vec()
|
||||||
);
|
);
|
||||||
match relays.get_mut(&Some(relay_url.clone())) {
|
match relays.get_mut(&Some(relay_url.clone())) {
|
||||||
Some(tasks) => tasks.add(*event),
|
Some(tasks) => tasks.add(*event),
|
||||||
|
@ -450,17 +385,27 @@ 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() {
|
||||||
let mut desc = task.description_events().peekable();
|
for e in once(&task.event).chain(task.props.iter().rev()) {
|
||||||
if desc.peek().is_some() {
|
let content = match State::try_from(e.kind) {
|
||||||
println!("{}",
|
Ok(state) => {
|
||||||
desc.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
|
format!("State: {state}{}",
|
||||||
.join("\n"));
|
if e.content.is_empty() { String::new() } else { format!(" - {}", e.content) })
|
||||||
continue 'repl;
|
}
|
||||||
|
Err(_) => {
|
||||||
|
e.content.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!("{} {} [{}]",
|
||||||
|
format_timestamp_local(&e.created_at),
|
||||||
|
content,
|
||||||
|
join(e.tags.iter().filter(|t| match_event_tag(t).unwrap().marker.is_none_or(|m| m != MARKER_PROPERTY))));
|
||||||
}
|
}
|
||||||
|
continue 'repl;
|
||||||
|
} else {
|
||||||
|
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its updates");
|
||||||
|
tasks.recurse_activities = !tasks.recurse_activities;
|
||||||
|
info!("Toggled activities recursion to {}", tasks.recurse_activities);
|
||||||
}
|
}
|
||||||
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes");
|
|
||||||
tasks.recurse_activities = !tasks.recurse_activities;
|
|
||||||
info!("Toggled activities recursion to {}", tasks.recurse_activities);
|
|
||||||
}
|
}
|
||||||
Some(arg) => {
|
Some(arg) => {
|
||||||
if arg.len() < CHARACTER_THRESHOLD {
|
if arg.len() < CHARACTER_THRESHOLD {
|
||||||
|
@ -707,7 +652,7 @@ async fn main() -> Result<()> {
|
||||||
let pos = tasks.up_by(dots - 1);
|
let pos = tasks.up_by(dots - 1);
|
||||||
|
|
||||||
if remaining.is_empty() {
|
if remaining.is_empty() {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos);
|
||||||
if dots > 1 {
|
if dots > 1 {
|
||||||
info!("Moving up {} tasks", dots - 1)
|
info!("Moving up {} tasks", dots - 1)
|
||||||
} else {
|
} else {
|
||||||
|
@ -716,13 +661,13 @@ async fn main() -> Result<()> {
|
||||||
} else {
|
} else {
|
||||||
match remaining.parse::<usize>() {
|
match remaining.parse::<usize>() {
|
||||||
Ok(depth) if depth < 10 => {
|
Ok(depth) if depth < 10 => {
|
||||||
if pos != tasks.get_position_ref() {
|
if pos != tasks.get_position() {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos);
|
||||||
}
|
}
|
||||||
tasks.set_view_depth(depth);
|
tasks.set_view_depth(depth);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
tasks.filter_or_create(pos.cloned().as_ref(), &remaining).map(|id| tasks.move_to(Some(id)));
|
tasks.filter_or_create(pos, &remaining).map(|id| tasks.move_to(Some(id)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -735,13 +680,13 @@ async fn main() -> Result<()> {
|
||||||
let pos = tasks.up_by(dots - 1);
|
let pos = tasks.up_by(dots - 1);
|
||||||
|
|
||||||
if remaining.is_empty() {
|
if remaining.is_empty() {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos);
|
||||||
if dots > 1 {
|
if dots > 1 {
|
||||||
info!("Moving up {} tasks", dots - 1)
|
info!("Moving up {} tasks", dots - 1)
|
||||||
}
|
}
|
||||||
} else if let Ok(depth) = remaining.parse::<usize>() {
|
} else if let Ok(depth) = remaining.parse::<usize>() {
|
||||||
if pos != tasks.get_position_ref() {
|
if pos != tasks.get_position() {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos);
|
||||||
}
|
}
|
||||||
tasks.set_search_depth(depth);
|
tasks.set_search_depth(depth);
|
||||||
} else {
|
} else {
|
||||||
|
@ -755,13 +700,13 @@ async fn main() -> Result<()> {
|
||||||
let filtered =
|
let filtered =
|
||||||
tasks.get_filtered(pos, |t| {
|
tasks.get_filtered(pos, |t| {
|
||||||
transform(&t.event.content).contains(&remaining) ||
|
transform(&t.event.content).contains(&remaining) ||
|
||||||
t.tags.iter().flatten().any(
|
t.get_hashtags().any(
|
||||||
|tag| tag.content().is_some_and(|s| transform(s).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());
|
||||||
} else {
|
} else {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos);
|
||||||
if !tasks.set_view(filtered) {
|
if !tasks.set_view(filtered) {
|
||||||
continue 'repl;
|
continue 'repl;
|
||||||
}
|
}
|
||||||
|
@ -794,7 +739,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
tasks.filter_or_create(tasks.get_position().as_ref(), &command);
|
tasks.filter_or_create(tasks.get_position(), &command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tasks.custom_time = None;
|
tasks.custom_time = None;
|
||||||
|
|
85
src/task.rs
85
src/task.rs
|
@ -10,10 +10,11 @@ use colored::{ColoredString, Colorize};
|
||||||
use itertools::Either::{Left, Right};
|
use itertools::Either::{Left, Right};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use log::{debug, error, info, trace, warn};
|
use log::{debug, error, info, trace, warn};
|
||||||
use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
|
use nostr_sdk::{Alphabet, Event, EventId, Kind, Tag, Timestamp};
|
||||||
|
|
||||||
use crate::helpers::{format_timestamp_local, some_non_empty};
|
use crate::helpers::{format_timestamp_local, some_non_empty};
|
||||||
use crate::kinds::{is_hashtag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
|
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";
|
||||||
|
@ -23,8 +24,8 @@ pub static MARKER_PROPERTY: &str = "property";
|
||||||
pub(crate) struct Task {
|
pub(crate) struct Task {
|
||||||
/// Event that defines this task
|
/// Event that defines this task
|
||||||
pub(crate) event: Event,
|
pub(crate) event: Event,
|
||||||
/// Cached sorted tags of the event with references remove - do not modify!
|
/// Cached sorted tags of the event with references removed
|
||||||
pub(crate) tags: Option<BTreeSet<Tag>>,
|
tags: Option<BTreeSet<Tag>>,
|
||||||
/// Task references derived from the event tags
|
/// Task references derived from the event tags
|
||||||
refs: Vec<(String, EventId)>,
|
refs: Vec<(String, EventId)>,
|
||||||
/// Events belonging to this task, such as state updates and notes
|
/// Events belonging to this task, such as state updates and notes
|
||||||
|
@ -51,10 +52,10 @@ impl Hash for Task {
|
||||||
|
|
||||||
impl Task {
|
impl Task {
|
||||||
pub(crate) fn new(event: Event) -> Task {
|
pub(crate) fn new(event: Event) -> Task {
|
||||||
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
|
let (refs, tags) = event.tags.iter().partition_map(|tag| if let Some(et) = match_event_tag(tag) {
|
||||||
Some(TagStandard::Event { event_id, marker, .. }) =>
|
Left((et.marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), et.id))
|
||||||
Left((marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), *event_id)),
|
} else {
|
||||||
_ => Right(tag.clone()),
|
Right(tag.clone())
|
||||||
});
|
});
|
||||||
// Separate refs for dependencies
|
// Separate refs for dependencies
|
||||||
Task {
|
Task {
|
||||||
|
@ -114,7 +115,7 @@ impl Task {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn priority_raw(&self) -> Option<&str> {
|
pub(crate) fn priority_raw(&self) -> Option<&str> {
|
||||||
self.props.iter().rev()
|
self.props.iter()
|
||||||
.chain(once(&self.event))
|
.chain(once(&self.event))
|
||||||
.find_map(|p| {
|
.find_map(|p| {
|
||||||
p.tags.iter().find_map(|t|
|
p.tags.iter().find_map(|t|
|
||||||
|
@ -137,9 +138,9 @@ impl Task {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn state(&self) -> Option<TaskState> {
|
pub(crate) fn state(&self) -> Option<TaskState> {
|
||||||
let now = Timestamp::now();
|
let now = now();
|
||||||
// TODO do not iterate constructed state objects
|
// TODO do not iterate constructed state objects
|
||||||
let state = self.states().rev().take_while_inclusive(|ts| ts.time > now);
|
let state = self.states().take_while_inclusive(|ts| ts.time > now);
|
||||||
state.last().map(|ts| {
|
state.last().map(|ts| {
|
||||||
if ts.time <= now {
|
if ts.time <= now {
|
||||||
ts
|
ts
|
||||||
|
@ -172,16 +173,27 @@ impl Task {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn filter_tags<P>(&self, predicate: P) -> Option<String>
|
pub(crate) fn get_hashtags(&self) -> impl Iterator<Item=&Tag> {
|
||||||
|
self.tags().filter(|t| is_hashtag(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags(&self) -> impl Iterator<Item=&Tag> {
|
||||||
|
self.tags.iter().flatten().chain(
|
||||||
|
self.props.iter().flat_map(|e| e.tags.iter()
|
||||||
|
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn join_tags<P>(&self, predicate: P) -> String
|
||||||
where
|
where
|
||||||
P: FnMut(&&Tag) -> bool,
|
P: FnMut(&&Tag) -> bool,
|
||||||
{
|
{
|
||||||
self.tags.as_ref().map(|tags| {
|
self.tags()
|
||||||
tags.iter()
|
.filter(predicate)
|
||||||
.filter(predicate)
|
.map(|t| t.content().unwrap().to_string())
|
||||||
.map(|t| t.content().unwrap().to_string())
|
.sorted_unstable()
|
||||||
.join(" ")
|
.dedup()
|
||||||
})
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get(&self, property: &str) -> Option<String> {
|
pub(crate) fn get(&self, property: &str) -> Option<String> {
|
||||||
|
@ -190,7 +202,7 @@ impl Task {
|
||||||
"id" => Some(self.event.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()),
|
||||||
"pubkey" => Some(self.event.pubkey.to_string()),
|
"key" | "pubkey" => Some(self.event.pubkey.to_string()),
|
||||||
"created" => Some(format_timestamp_local(&self.event.created_at)),
|
"created" => Some(format_timestamp_local(&self.event.created_at)),
|
||||||
"kind" => Some(self.event.kind.to_string()),
|
"kind" => Some(self.event.kind.to_string()),
|
||||||
// Dynamic
|
// Dynamic
|
||||||
|
@ -198,8 +210,8 @@ impl Task {
|
||||||
"status" => self.state_label().map(|c| c.to_string()),
|
"status" => self.state_label().map(|c| c.to_string()),
|
||||||
"desc" => self.descriptions().last().cloned(),
|
"desc" => self.descriptions().last().cloned(),
|
||||||
"description" => Some(self.descriptions().join(" ")),
|
"description" => Some(self.descriptions().join(" ")),
|
||||||
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
|
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
|
||||||
"tags" => self.filter_tags(|_| true),
|
"tags" => Some(self.join_tags(|_| true)), // TODO test these!
|
||||||
"alltags" => Some(format!("{:?}", self.tags)),
|
"alltags" => Some(format!("{:?}", self.tags)),
|
||||||
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
|
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
|
||||||
"props" => Some(format!(
|
"props" => Some(format!(
|
||||||
|
@ -331,3 +343,34 @@ impl Display for State {
|
||||||
fmt::Debug::fmt(self, f)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
750
src/tasks.rs
750
src/tasks.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue