Compare commits

...

25 commits

Author SHA1 Message Date
xeruf
00bd7a997a feat(main): show all task updates with comma 2024-11-19 17:09:59 +01:00
xeruf
cb1d8ef8fb feat: release new version 0.6.0 2024-11-18 15:02:50 +01:00
xeruf
7561bc0e2f refactor: update to latest nostr sdk version 2024-11-18 14:52:52 +01:00
xeruf
360b44e64e docs: miniscule adjustments 2024-11-18 14:44:07 +01:00
xeruf
adcd35967f refactor: reformat tasks file 2024-11-18 14:43:49 +01:00
xeruf
2400f7c45b fix: parse markers again by reducing EventId referencing 2024-11-18 14:40:50 +01:00
xeruf
e186d034e5 fix: upgrade to nostr sdk development version 2024-11-15 17:19:49 +01:00
xeruf
59b789d5ed fix(tasks): do not recurse activities by default 2024-11-15 17:12:52 +01:00
xeruf
473f26d7a5 fix(task): adjust to inverted ordering of sdk 0.36
https://github.com/rust-nostr/nostr/issues/632
2024-11-14 19:05:58 +01:00
xeruf
43f8a3ebca feat: update to nostr sdk 0.36 2024-11-14 19:05:12 +01:00
xeruf
9a9c30dbb7 feat(main): auto-migrate existing key 2024-11-14 18:50:50 +01:00
xeruf
a0e411d2e9 feat(main): debug print config directory 2024-11-14 18:32:25 +01:00
xeruf
ecc5b7686b test(task): verify state progression 2024-11-14 18:29:47 +01:00
xeruf
d095c65b23 test(tasks): reformat 2024-11-14 14:23:42 +01:00
xeruf
6b8bf29b20 feat(task): extract tags from state updates 2024-11-13 11:12:25 +01:00
xeruf
2cec689bf1 feat: automatically add tags from task properties 2024-11-12 23:03:53 +01:00
xeruf
44feea9894 enhance(kinds): improve tag formatting 2024-11-12 20:33:23 +01:00
xeruf
3fa8df4eaa feat(tasks): add high priority tasks to quick access 2024-11-12 20:23:30 +01:00
xeruf
b9f1d461fb feat(main): save keys in system keychain 2024-11-12 19:52:05 +01:00
xeruf
7cedd980fb enhance(tasks): more transparent task and activity creation 2024-11-11 22:57:58 +01:00
xeruf
55d42fc52c enhance(tasks): inherit displayed priority 2024-11-11 21:56:29 +01:00
xeruf
15a2ffd7e6 style: optimize imports with cargo fix 2024-11-11 14:59:25 +01:00
xeruf
5f8a232bd5 refactor(event_sender): reformat 2024-11-11 13:17:50 +01:00
xeruf
5dfd7a084b refactor: create own file for EventSender 2024-11-11 13:13:15 +01:00
xeruf
ca24693dbb fix(main): auto-create config directory 2024-11-11 12:34:28 +01:00
10 changed files with 1536 additions and 909 deletions

1157
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.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"

View file

@ -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

View file

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

View file

@ -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(),
} }
} }

View file

@ -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![]));
} }

View file

@ -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(),
} }
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)
} }
// 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);
}
}
#[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 {
Keys::from_str(&line).inspect_err(|_| eprintln!())?
};
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)?
} }
}
}; let keys = read_keys(&mut rl)?;
file.write_all(keys.secret_key().unwrap().to_string().as_bytes())?; let relayfile = config_dir.join("relays");
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,18 +385,28 @@ 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) })
}
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; continue 'repl;
} } else {
} info!("With a task selected, use ,NOTE to attach NOTE and , to list all its updates");
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes");
tasks.recurse_activities = !tasks.recurse_activities; tasks.recurse_activities = !tasks.recurse_activities;
info!("Toggled activities recursion to {}", 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 {
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!"); warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
@ -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;

View file

@ -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()
.dedup()
.join(" ") .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);
}
}

File diff suppressed because it is too large Load diff