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
22
Cargo.toml
22
Cargo.toml
|
@ -5,7 +5,7 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
|
|||
readme = "README.md"
|
||||
license = "GPL 3.0"
|
||||
authors = ["melonion"]
|
||||
version = "0.5.0"
|
||||
version = "0.6.0"
|
||||
rust-version = "1.82"
|
||||
edition = "2021"
|
||||
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
|
||||
|
||||
[dependencies]
|
||||
directories = "5.0"
|
||||
itertools = "0.12"
|
||||
# Basics
|
||||
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
regex = "1.10.6"
|
||||
# System
|
||||
log = "0.4"
|
||||
chrono = "0.4"
|
||||
env_logger = "0.11"
|
||||
colog = "1.3"
|
||||
colored = "2.1"
|
||||
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
|
||||
# OS-Specific Abstractions
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
|
||||
directories = "5.0"
|
||||
# Application Utils
|
||||
itertools = "0.12"
|
||||
chrono = "0.4"
|
||||
parse_datetime = "0.5.0"
|
||||
interim = { version = "0.1", features = ["chrono"] }
|
||||
nostr-sdk = "0.34" # { git = "https://github.com/rust-nostr/nostr" }
|
||||
tokio = { version = "1.40", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
regex = "1.10.6"
|
||||
rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
|
||||
chrono-english = "0.1"
|
||||
linefeed = "0.6"
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
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)
|
||||
- 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/rnostr/rnostr for production use
|
||||
|
||||
Install rustup and run a development build with:
|
||||
Install rust(up) and run a development build with:
|
||||
|
||||
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/`.
|
||||
Ideally any project with different collaborators has its own relay.
|
||||
If not saved, mostr will ask for a relay url
|
||||
(entering none is fine too, but your data will not be persisted between sessions)
|
||||
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:
|
||||
|
||||
|
|
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 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> {
|
||||
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) {
|
||||
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 itertools::Itertools;
|
||||
use log::info;
|
||||
use nostr_sdk::TagStandard::Hashtag;
|
||||
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
|
||||
use std::borrow::Cow;
|
||||
|
||||
|
@ -55,23 +53,36 @@ Utilities:
|
|||
- TBI `depends` - list all tasks this task depends on before it becomes actionable
|
||||
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
|
||||
where
|
||||
I: IntoIterator<Item=EventId>,
|
||||
{
|
||||
EventBuilder::new(
|
||||
Kind::from(TRACKING_KIND),
|
||||
"",
|
||||
id.into_iter().map(Tag::event),
|
||||
)
|
||||
EventBuilder::new(Kind::from(TRACKING_KIND), "")
|
||||
.tags(id.into_iter().map(Tag::event))
|
||||
}
|
||||
|
||||
/// Build a task with informational output and optional labeled kind
|
||||
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
||||
info!("Created {} \"{name}\" with tags [{}]",
|
||||
kind.map(|k| k.0).unwrap_or("task"),
|
||||
tags.iter().map(format_tag).join(", "));
|
||||
EventBuilder::new(kind.map(|k| k.1).unwrap_or(TASK_KIND), name, tags)
|
||||
pub fn join<'a, T>(tags: T) -> String
|
||||
where
|
||||
T: IntoIterator<Item=&'a Tag>,
|
||||
{
|
||||
tags.into_iter().map(format_tag).join(", ")
|
||||
}
|
||||
|
||||
/// 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.len() == 1 {
|
||||
prio = Some(HIGH_PRIO);
|
||||
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();
|
||||
let mut split = result.split(|e| { e == &"#" });
|
||||
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(prio.map(|p| to_prio_tag(p))).collect();
|
||||
.chain(prio.map(|p| to_prio_tag(p)))
|
||||
.collect_vec();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
(main, tags)
|
||||
}
|
||||
|
||||
fn to_hashtag(tag: &str) -> Tag {
|
||||
Hashtag(tag.to_string()).into()
|
||||
pub fn to_hashtag(tag: &str) -> Tag {
|
||||
TagStandard::Hashtag(tag.to_string()).into()
|
||||
}
|
||||
|
||||
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() {
|
||||
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 {
|
||||
public_key,
|
||||
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()),
|
||||
Some(TagStandard::Hashtag(content)) =>
|
||||
format!("#{content}"),
|
||||
_ => tag.content().map_or_else(
|
||||
|| format!("Kind {}", tag.kind()),
|
||||
|content| content.to_string(),
|
||||
)
|
||||
_ => tag.as_slice().join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -149,10 +160,10 @@ pub(crate) fn to_prio_tag(value: Prio) -> Tag {
|
|||
|
||||
#[test]
|
||||
fn test_extract_tags() {
|
||||
assert_eq!(extract_tags("Hello from #mars with #greetings *4 # # yeah done-it"),
|
||||
("Hello from #mars with #greetings".to_string(),
|
||||
["mars", "greetings", "yeah", "done-it"].into_iter().map(to_hashtag)
|
||||
.chain(std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))).collect()));
|
||||
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it"),
|
||||
("Hello from #mars with #greetings #yeah".to_string(),
|
||||
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))
|
||||
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag)).collect()));
|
||||
assert_eq!(extract_tags("So tagless #"),
|
||||
("So tagless".to_string(), vec![]));
|
||||
}
|
223
src/main.rs
223
src/main.rs
|
@ -1,19 +1,24 @@
|
|||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::env::{args, var};
|
||||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::ops::Sub;
|
||||
use std::iter::once;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
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 colored::Colorize;
|
||||
use directories::ProjectDirs;
|
||||
use env_logger::{Builder, Target, WriteStyle};
|
||||
use itertools::Itertools;
|
||||
use keyring::Entry;
|
||||
use log::{debug, error, info, trace, warn, LevelFilter};
|
||||
use nostr_sdk::prelude::*;
|
||||
use nostr_sdk::TagStandard::Hashtag;
|
||||
|
@ -22,21 +27,15 @@ use rustyline::config::Configurer;
|
|||
use rustyline::error::ReadlineError;
|
||||
use rustyline::DefaultEditor;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::time::error::Elapsed;
|
||||
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 task;
|
||||
mod tasks;
|
||||
mod kinds;
|
||||
mod event_sender;
|
||||
|
||||
const UNDO_DELAY: u64 = 60;
|
||||
const INACTVITY_DELAY: u64 = 200;
|
||||
const LOCAL_RELAY_NAME: &str = "TEMP";
|
||||
|
||||
|
@ -62,95 +61,36 @@ macro_rules! or_warn {
|
|||
}
|
||||
}
|
||||
|
||||
type Events = Vec<Event>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct EventSender {
|
||||
url: Option<Url>,
|
||||
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);
|
||||
fn read_keys(readline: &mut DefaultEditor) -> Result<Keys> {
|
||||
let keys_entry = Entry::new("mostr", "keys")?;
|
||||
if let Ok(pass) = keys_entry.get_secret() {
|
||||
return Ok(SecretKey::from_slice(&pass).map(|s| Keys::new(s))
|
||||
.inspect_err(|e| eprintln!("Invalid key in keychain: {e}"))?);
|
||||
}
|
||||
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]
|
||||
async fn main() -> Result<()> {
|
||||
let mut rl = DefaultEditor::new()?;
|
||||
rl.set_auto_add_history(true);
|
||||
println!("Running Mostr Version {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let mut args = args().skip(1).peekable();
|
||||
let mut builder = if args.peek().is_some_and(|arg| arg == "--debug") {
|
||||
args.next();
|
||||
let mut builder = Builder::new();
|
||||
builder.filter(None, LevelFilter::Debug)
|
||||
//.filter(Some("mostr"), LevelFilter::Trace)
|
||||
.filter(Some("mostr"), LevelFilter::Trace)
|
||||
.parse_default_env();
|
||||
builder
|
||||
} else {
|
||||
|
@ -159,9 +99,13 @@ async fn main() -> Result<()> {
|
|||
//.filter(Some("nostr-relay-pool::relay::internal"), LevelFilter::Off)
|
||||
builder
|
||||
};
|
||||
|
||||
let mut rl = DefaultEditor::new()?;
|
||||
rl.set_auto_add_history(true);
|
||||
or_warn!(
|
||||
rl.create_external_writer().map(
|
||||
|wr| builder
|
||||
// Without this filter at least at Info, the program hangs
|
||||
.filter(Some("rustyline"), LevelFilter::Warn)
|
||||
.write_style(WriteStyle::Always)
|
||||
.target(Target::Pipe(wr)))
|
||||
|
@ -170,43 +114,34 @@ async fn main() -> Result<()> {
|
|||
|
||||
let config_dir =
|
||||
ProjectDirs::from("", "", "mostr")
|
||||
.map(|p| p.config_dir().to_path_buf())
|
||||
.unwrap_or(PathBuf::new());
|
||||
let keysfile = config_dir.join("key");
|
||||
let relayfile = config_dir.join("relays");
|
||||
.map(|p| {
|
||||
let config = p.config_dir();
|
||||
debug!("Config Directory: {:?}", config);
|
||||
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)) {
|
||||
keys
|
||||
} else {
|
||||
warn!("Could not read keys from {}", keysfile.to_string_lossy());
|
||||
let line = rl.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(|_| 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 key_file = config_dir.join("key");
|
||||
if let Ok(Some(keys)) = fs::read_to_string(key_file.as_path()).map(|s| or_warn!(Keys::from_str(&s.trim()))) {
|
||||
info!("Migrating private key from plaintext file {}", key_file.to_string_lossy());
|
||||
or_warn!(Entry::new("mostr", "keys")
|
||||
.and_then(|e| e.set_secret(keys.secret_key().as_secret_bytes()))
|
||||
.inspect(|_| { or_warn!(fs::remove_file(key_file)); }));
|
||||
}
|
||||
}
|
||||
};
|
||||
file.write_all(keys.secret_key().unwrap().to_string().as_bytes())?;
|
||||
keys
|
||||
};
|
||||
|
||||
let keys = read_keys(&mut rl)?;
|
||||
let relayfile = config_dir.join("relays");
|
||||
|
||||
let client = ClientBuilder::new()
|
||||
.opts(Options::new()
|
||||
.automatic_authentication(true)
|
||||
.pool(RelayPoolOptions::new().notification_channel_size(8192)))
|
||||
.signer(&keys)
|
||||
.notification_channel_size(8192)
|
||||
)
|
||||
.signer(keys.clone())
|
||||
.build();
|
||||
info!("My public key: {}", keys.public_key());
|
||||
|
||||
|
@ -301,7 +236,7 @@ async fn main() -> Result<()> {
|
|||
queue = Some((queue_url, queue_events));
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -313,7 +248,7 @@ async fn main() -> Result<()> {
|
|||
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, RelaySendOptions::new()).await;
|
||||
client.batch_event_to(vec![url], events).await;
|
||||
queue = None;
|
||||
}
|
||||
Ok(None) => {
|
||||
|
@ -324,7 +259,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
if let Some((url, events)) = queue {
|
||||
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");
|
||||
});
|
||||
|
@ -365,7 +300,7 @@ async fn main() -> Result<()> {
|
|||
{
|
||||
debug!(
|
||||
"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())) {
|
||||
Some(tasks) => tasks.add(*event),
|
||||
|
@ -450,18 +385,28 @@ async fn main() -> Result<()> {
|
|||
match arg {
|
||||
None => {
|
||||
if let Some(task) = tasks.get_current_task() {
|
||||
let mut desc = task.description_events().peekable();
|
||||
if desc.peek().is_some() {
|
||||
println!("{}",
|
||||
desc.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
|
||||
.join("\n"));
|
||||
for e in once(&task.event).chain(task.props.iter().rev()) {
|
||||
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!("{} {} [{}]",
|
||||
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;
|
||||
}
|
||||
}
|
||||
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes");
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
Some(arg) => {
|
||||
if arg.len() < CHARACTER_THRESHOLD {
|
||||
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
|
||||
|
@ -707,7 +652,7 @@ async fn main() -> Result<()> {
|
|||
let pos = tasks.up_by(dots - 1);
|
||||
|
||||
if remaining.is_empty() {
|
||||
tasks.move_to(pos.cloned());
|
||||
tasks.move_to(pos);
|
||||
if dots > 1 {
|
||||
info!("Moving up {} tasks", dots - 1)
|
||||
} else {
|
||||
|
@ -716,13 +661,13 @@ async fn main() -> Result<()> {
|
|||
} else {
|
||||
match remaining.parse::<usize>() {
|
||||
Ok(depth) if depth < 10 => {
|
||||
if pos != tasks.get_position_ref() {
|
||||
tasks.move_to(pos.cloned());
|
||||
if pos != tasks.get_position() {
|
||||
tasks.move_to(pos);
|
||||
}
|
||||
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);
|
||||
|
||||
if remaining.is_empty() {
|
||||
tasks.move_to(pos.cloned());
|
||||
tasks.move_to(pos);
|
||||
if dots > 1 {
|
||||
info!("Moving up {} tasks", dots - 1)
|
||||
}
|
||||
} else if let Ok(depth) = remaining.parse::<usize>() {
|
||||
if pos != tasks.get_position_ref() {
|
||||
tasks.move_to(pos.cloned());
|
||||
if pos != tasks.get_position() {
|
||||
tasks.move_to(pos);
|
||||
}
|
||||
tasks.set_search_depth(depth);
|
||||
} else {
|
||||
|
@ -755,13 +700,13 @@ async fn main() -> Result<()> {
|
|||
let filtered =
|
||||
tasks.get_filtered(pos, |t| {
|
||||
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)))
|
||||
});
|
||||
if filtered.len() == 1 {
|
||||
tasks.move_to(filtered.into_iter().next());
|
||||
} else {
|
||||
tasks.move_to(pos.cloned());
|
||||
tasks.move_to(pos);
|
||||
if !tasks.set_view(filtered) {
|
||||
continue 'repl;
|
||||
}
|
||||
|
@ -794,7 +739,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
tasks.filter_or_create(tasks.get_position().as_ref(), &command);
|
||||
tasks.filter_or_create(tasks.get_position(), &command);
|
||||
}
|
||||
}
|
||||
tasks.custom_time = None;
|
||||
|
|
79
src/task.rs
79
src/task.rs
|
@ -10,10 +10,11 @@ use colored::{ColoredString, Colorize};
|
|||
use itertools::Either::{Left, Right};
|
||||
use itertools::Itertools;
|
||||
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::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_DEPENDS: &str = "depends";
|
||||
|
@ -23,8 +24,8 @@ pub static MARKER_PROPERTY: &str = "property";
|
|||
pub(crate) struct Task {
|
||||
/// Event that defines this task
|
||||
pub(crate) event: Event,
|
||||
/// Cached sorted tags of the event with references remove - do not modify!
|
||||
pub(crate) tags: Option<BTreeSet<Tag>>,
|
||||
/// Cached sorted tags of the event with references removed
|
||||
tags: Option<BTreeSet<Tag>>,
|
||||
/// Task references derived from the event tags
|
||||
refs: Vec<(String, EventId)>,
|
||||
/// Events belonging to this task, such as state updates and notes
|
||||
|
@ -51,10 +52,10 @@ impl Hash for Task {
|
|||
|
||||
impl Task {
|
||||
pub(crate) fn new(event: Event) -> Task {
|
||||
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
|
||||
Some(TagStandard::Event { event_id, marker, .. }) =>
|
||||
Left((marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), *event_id)),
|
||||
_ => Right(tag.clone()),
|
||||
let (refs, tags) = event.tags.iter().partition_map(|tag| if let Some(et) = match_event_tag(tag) {
|
||||
Left((et.marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), et.id))
|
||||
} else {
|
||||
Right(tag.clone())
|
||||
});
|
||||
// Separate refs for dependencies
|
||||
Task {
|
||||
|
@ -114,7 +115,7 @@ impl Task {
|
|||
}
|
||||
|
||||
pub(crate) fn priority_raw(&self) -> Option<&str> {
|
||||
self.props.iter().rev()
|
||||
self.props.iter()
|
||||
.chain(once(&self.event))
|
||||
.find_map(|p| {
|
||||
p.tags.iter().find_map(|t|
|
||||
|
@ -137,9 +138,9 @@ impl Task {
|
|||
}
|
||||
|
||||
pub(crate) fn state(&self) -> Option<TaskState> {
|
||||
let now = Timestamp::now();
|
||||
let now = now();
|
||||
// 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| {
|
||||
if ts.time <= now {
|
||||
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
|
||||
P: FnMut(&&Tag) -> bool,
|
||||
{
|
||||
self.tags.as_ref().map(|tags| {
|
||||
tags.iter()
|
||||
self.tags()
|
||||
.filter(predicate)
|
||||
.map(|t| t.content().unwrap().to_string())
|
||||
.sorted_unstable()
|
||||
.dedup()
|
||||
.join(" ")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, property: &str) -> Option<String> {
|
||||
|
@ -190,7 +202,7 @@ impl Task {
|
|||
"id" => Some(self.event.id.to_string()),
|
||||
"parentid" => self.parent_id().map(|i| i.to_string()),
|
||||
"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)),
|
||||
"kind" => Some(self.event.kind.to_string()),
|
||||
// Dynamic
|
||||
|
@ -198,8 +210,8 @@ impl Task {
|
|||
"status" => self.state_label().map(|c| c.to_string()),
|
||||
"desc" => self.descriptions().last().cloned(),
|
||||
"description" => Some(self.descriptions().join(" ")),
|
||||
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
|
||||
"tags" => self.filter_tags(|_| true),
|
||||
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
|
||||
"tags" => Some(self.join_tags(|_| true)), // TODO test these!
|
||||
"alltags" => Some(format!("{:?}", self.tags)),
|
||||
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
|
||||
"props" => Some(format!(
|
||||
|
@ -331,3 +343,34 @@ impl Display for State {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
716
src/tasks.rs
716
src/tasks.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue