forked from janek/mostr
Compare commits
No commits in common. "00bd7a997a6afd5c2bf9267fa8de802d9d3471a6" and "ed72bcebcfe7c46f9517d674d9e99613d98b4ed2" have entirely different histories.
00bd7a997a
...
ed72bcebcf
10 changed files with 915 additions and 1542 deletions
1167
Cargo.lock
generated
1167
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"
|
||||
license = "GPL 3.0"
|
||||
authors = ["melonion"]
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
rust-version = "1.82"
|
||||
edition = "2021"
|
||||
default-run = "mostr"
|
||||
|
@ -13,26 +13,20 @@ default-run = "mostr"
|
|||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
# Basics
|
||||
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
regex = "1.10.6"
|
||||
# System
|
||||
directories = "5.0"
|
||||
itertools = "0.12"
|
||||
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 = { git = "https://github.com/rust-nostr/nostr", rev = "e82bc787bdd8490ceadb034fe4483e4df1e91b2a" }
|
||||
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" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.41", features = ["rt", "rt-multi-thread", "macros", "io-std"] }
|
||||
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)
|
||||
|
||||
Mostr mainly uses the following [NIPs](https://github.com/nostr-protocol/nips):
|
||||
Mostr mainly uses the following 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 rust(up) and run a development build with:
|
||||
Install rustup and run a development build with:
|
||||
|
||||
cargo run
|
||||
|
||||
A `relay` list can be placed in a config file
|
||||
A `relay` list and private `key` can be placed in config files
|
||||
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.
|
||||
The key is saved in the system keychain.
|
||||
Both are currently saved in plain text to the above files.
|
||||
|
||||
Install latest build:
|
||||
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
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,10 +7,6 @@ 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()) }
|
||||
}
|
||||
|
@ -124,7 +120,7 @@ where
|
|||
{
|
||||
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
|
||||
Single(time) => formatter(time),
|
||||
_ => stamp.to_human_datetime().to_string(),
|
||||
_ => stamp.to_human_datetime(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
79
src/kinds.rs
79
src/kinds.rs
|
@ -1,6 +1,8 @@
|
|||
use crate::task::MARKER_PARENT;
|
||||
use crate::task::{State, 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;
|
||||
|
||||
|
@ -53,36 +55,23 @@ 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), "")
|
||||
.tags(id.into_iter().map(Tag::event))
|
||||
EventBuilder::new(
|
||||
Kind::from(TRACKING_KIND),
|
||||
"",
|
||||
id.into_iter().map(Tag::event),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn join<'a, T>(tags: T) -> String
|
||||
where
|
||||
T: IntoIterator<Item=&'a Tag>,
|
||||
{
|
||||
tags.into_iter().map(format_tag).join(", ")
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Return Hashtags embedded in the string.
|
||||
|
@ -104,40 +93,37 @@ 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
|
||||
}).collect_vec();
|
||||
let mut split = result.split(|e| { e == &"#" });
|
||||
let main = split.next().unwrap().join(" ");
|
||||
let mut tags = extract_hashtags(&main)
|
||||
let tags = extract_hashtags(&main)
|
||||
.chain(split.flatten().map(|s| to_hashtag(&s)))
|
||||
.chain(prio.map(|p| to_prio_tag(p)))
|
||||
.collect_vec();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
.chain(prio.map(|p| to_prio_tag(p))).collect();
|
||||
(main, tags)
|
||||
}
|
||||
|
||||
pub fn to_hashtag(tag: &str) -> Tag {
|
||||
TagStandard::Hashtag(tag.to_string()).into()
|
||||
fn to_hashtag(tag: &str) -> Tag {
|
||||
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,
|
||||
|
@ -145,7 +131,10 @@ 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.as_slice().join(" ")
|
||||
_ => tag.content().map_or_else(
|
||||
|| format!("Kind {}", tag.kind()),
|
||||
|content| content.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -160,10 +149,10 @@ pub(crate) fn to_prio_tag(value: Prio) -> Tag {
|
|||
|
||||
#[test]
|
||||
fn test_extract_tags() {
|
||||
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("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("So tagless #"),
|
||||
("So tagless".to_string(), vec![]));
|
||||
}
|
229
src/main.rs
229
src/main.rs
|
@ -1,24 +1,19 @@
|
|||
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::iter::once;
|
||||
use std::ops::Sub;
|
||||
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;
|
||||
|
@ -27,15 +22,21 @@ 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";
|
||||
|
||||
|
@ -61,36 +62,95 @@ macro_rules! or_warn {
|
|||
}
|
||||
}
|
||||
|
||||
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}"))?);
|
||||
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);
|
||||
}
|
||||
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<()> {
|
||||
println!("Running Mostr Version {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let mut rl = DefaultEditor::new()?;
|
||||
rl.set_auto_add_history(true);
|
||||
|
||||
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 {
|
||||
|
@ -99,13 +159,9 @@ 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)))
|
||||
|
@ -114,34 +170,43 @@ async fn main() -> Result<()> {
|
|||
|
||||
let config_dir =
|
||||
ProjectDirs::from("", "", "mostr")
|
||||
.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 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)); }));
|
||||
}
|
||||
|
||||
let keys = read_keys(&mut rl)?;
|
||||
.map(|p| p.config_dir().to_path_buf())
|
||||
.unwrap_or(PathBuf::new());
|
||||
let keysfile = config_dir.join("key");
|
||||
let relayfile = config_dir.join("relays");
|
||||
|
||||
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)?
|
||||
}
|
||||
}
|
||||
};
|
||||
file.write_all(keys.secret_key().unwrap().to_string().as_bytes())?;
|
||||
keys
|
||||
};
|
||||
|
||||
let client = ClientBuilder::new()
|
||||
.opts(Options::new()
|
||||
.automatic_authentication(true)
|
||||
.notification_channel_size(8192)
|
||||
)
|
||||
.signer(keys.clone())
|
||||
.pool(RelayPoolOptions::new().notification_channel_size(8192)))
|
||||
.signer(&keys)
|
||||
.build();
|
||||
info!("My public key: {}", keys.public_key());
|
||||
|
||||
|
@ -236,7 +301,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).await;
|
||||
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
|
||||
queue = None;
|
||||
}
|
||||
}
|
||||
|
@ -248,7 +313,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).await;
|
||||
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
|
||||
queue = None;
|
||||
}
|
||||
Ok(None) => {
|
||||
|
@ -259,7 +324,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).await;
|
||||
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
|
||||
}
|
||||
info!("Shutting down nostr communication thread");
|
||||
});
|
||||
|
@ -300,7 +365,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_slice()).collect_vec()
|
||||
event.created_at, event.id, event.kind, event.content, event.tags.iter().map(|tag| tag.as_vec()).collect_vec()
|
||||
);
|
||||
match relays.get_mut(&Some(relay_url.clone())) {
|
||||
Some(tasks) => tasks.add(*event),
|
||||
|
@ -385,27 +450,17 @@ async fn main() -> Result<()> {
|
|||
match arg {
|
||||
None => {
|
||||
if let Some(task) = tasks.get_current_task() {
|
||||
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))));
|
||||
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"));
|
||||
continue 'repl;
|
||||
}
|
||||
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) => {
|
||||
if arg.len() < CHARACTER_THRESHOLD {
|
||||
|
@ -652,7 +707,7 @@ async fn main() -> Result<()> {
|
|||
let pos = tasks.up_by(dots - 1);
|
||||
|
||||
if remaining.is_empty() {
|
||||
tasks.move_to(pos);
|
||||
tasks.move_to(pos.cloned());
|
||||
if dots > 1 {
|
||||
info!("Moving up {} tasks", dots - 1)
|
||||
} else {
|
||||
|
@ -661,13 +716,13 @@ async fn main() -> Result<()> {
|
|||
} else {
|
||||
match remaining.parse::<usize>() {
|
||||
Ok(depth) if depth < 10 => {
|
||||
if pos != tasks.get_position() {
|
||||
tasks.move_to(pos);
|
||||
if pos != tasks.get_position_ref() {
|
||||
tasks.move_to(pos.cloned());
|
||||
}
|
||||
tasks.set_view_depth(depth);
|
||||
}
|
||||
_ => {
|
||||
tasks.filter_or_create(pos, &remaining).map(|id| tasks.move_to(Some(id)));
|
||||
tasks.filter_or_create(pos.cloned().as_ref(), &remaining).map(|id| tasks.move_to(Some(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -680,13 +735,13 @@ async fn main() -> Result<()> {
|
|||
let pos = tasks.up_by(dots - 1);
|
||||
|
||||
if remaining.is_empty() {
|
||||
tasks.move_to(pos);
|
||||
tasks.move_to(pos.cloned());
|
||||
if dots > 1 {
|
||||
info!("Moving up {} tasks", dots - 1)
|
||||
}
|
||||
} else if let Ok(depth) = remaining.parse::<usize>() {
|
||||
if pos != tasks.get_position() {
|
||||
tasks.move_to(pos);
|
||||
if pos != tasks.get_position_ref() {
|
||||
tasks.move_to(pos.cloned());
|
||||
}
|
||||
tasks.set_search_depth(depth);
|
||||
} else {
|
||||
|
@ -700,13 +755,13 @@ async fn main() -> Result<()> {
|
|||
let filtered =
|
||||
tasks.get_filtered(pos, |t| {
|
||||
transform(&t.event.content).contains(&remaining) ||
|
||||
t.get_hashtags().any(
|
||||
t.tags.iter().flatten().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);
|
||||
tasks.move_to(pos.cloned());
|
||||
if !tasks.set_view(filtered) {
|
||||
continue 'repl;
|
||||
}
|
||||
|
@ -739,7 +794,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
});
|
||||
} else {
|
||||
tasks.filter_or_create(tasks.get_position(), &command);
|
||||
tasks.filter_or_create(tasks.get_position().as_ref(), &command);
|
||||
}
|
||||
}
|
||||
tasks.custom_time = None;
|
||||
|
|
85
src/task.rs
85
src/task.rs
|
@ -10,11 +10,10 @@ use colored::{ColoredString, Colorize};
|
|||
use itertools::Either::{Left, Right};
|
||||
use itertools::Itertools;
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use nostr_sdk::{Alphabet, Event, EventId, Kind, Tag, Timestamp};
|
||||
use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
|
||||
|
||||
use crate::helpers::{format_timestamp_local, some_non_empty};
|
||||
use crate::kinds::{is_hashtag, match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
|
||||
use crate::tasks::now;
|
||||
use crate::kinds::{is_hashtag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
|
||||
|
||||
pub static MARKER_PARENT: &str = "parent";
|
||||
pub static MARKER_DEPENDS: &str = "depends";
|
||||
|
@ -24,8 +23,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 removed
|
||||
tags: Option<BTreeSet<Tag>>,
|
||||
/// Cached sorted tags of the event with references remove - do not modify!
|
||||
pub(crate) 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
|
||||
|
@ -52,10 +51,10 @@ impl Hash for Task {
|
|||
|
||||
impl Task {
|
||||
pub(crate) fn new(event: Event) -> Task {
|
||||
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())
|
||||
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()),
|
||||
});
|
||||
// Separate refs for dependencies
|
||||
Task {
|
||||
|
@ -115,7 +114,7 @@ impl Task {
|
|||
}
|
||||
|
||||
pub(crate) fn priority_raw(&self) -> Option<&str> {
|
||||
self.props.iter()
|
||||
self.props.iter().rev()
|
||||
.chain(once(&self.event))
|
||||
.find_map(|p| {
|
||||
p.tags.iter().find_map(|t|
|
||||
|
@ -138,9 +137,9 @@ impl Task {
|
|||
}
|
||||
|
||||
pub(crate) fn state(&self) -> Option<TaskState> {
|
||||
let now = now();
|
||||
let now = Timestamp::now();
|
||||
// TODO do not iterate constructed state objects
|
||||
let state = self.states().take_while_inclusive(|ts| ts.time > now);
|
||||
let state = self.states().rev().take_while_inclusive(|ts| ts.time > now);
|
||||
state.last().map(|ts| {
|
||||
if ts.time <= now {
|
||||
ts
|
||||
|
@ -173,27 +172,16 @@ impl Task {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
fn filter_tags<P>(&self, predicate: P) -> Option<String>
|
||||
where
|
||||
P: FnMut(&&Tag) -> bool,
|
||||
{
|
||||
self.tags()
|
||||
.filter(predicate)
|
||||
.map(|t| t.content().unwrap().to_string())
|
||||
.sorted_unstable()
|
||||
.dedup()
|
||||
.join(" ")
|
||||
self.tags.as_ref().map(|tags| {
|
||||
tags.iter()
|
||||
.filter(predicate)
|
||||
.map(|t| t.content().unwrap().to_string())
|
||||
.join(" ")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get(&self, property: &str) -> Option<String> {
|
||||
|
@ -202,7 +190,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()),
|
||||
"key" | "pubkey" => Some(self.event.pubkey.to_string()),
|
||||
"pubkey" => Some(self.event.pubkey.to_string()),
|
||||
"created" => Some(format_timestamp_local(&self.event.created_at)),
|
||||
"kind" => Some(self.event.kind.to_string()),
|
||||
// Dynamic
|
||||
|
@ -210,8 +198,8 @@ impl Task {
|
|||
"status" => self.state_label().map(|c| c.to_string()),
|
||||
"desc" => self.descriptions().last().cloned(),
|
||||
"description" => Some(self.descriptions().join(" ")),
|
||||
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
|
||||
"tags" => Some(self.join_tags(|_| true)), // TODO test these!
|
||||
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
|
||||
"tags" => self.filter_tags(|_| true),
|
||||
"alltags" => Some(format!("{:?}", self.tags)),
|
||||
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
|
||||
"props" => Some(format!(
|
||||
|
@ -343,34 +331,3 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
752
src/tasks.rs
752
src/tasks.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue