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

View file

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

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

View file

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

View file

@ -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)?
}
}
};
file.write_all(keys.secret_key().unwrap().to_string().as_bytes())?;
keys
};
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)?;
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,17 +385,27 @@ 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"));
continue 'repl;
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;
} 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 {
@ -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;

View file

@ -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()
.filter(predicate)
.map(|t| t.content().unwrap().to_string())
.join(" ")
})
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);
}
}

File diff suppressed because it is too large Load diff