Compare commits

..

No commits in common. "00bd7a997a6afd5c2bf9267fa8de802d9d3471a6" and "ed72bcebcfe7c46f9517d674d9e99613d98b4ed2" have entirely different histories.

10 changed files with 915 additions and 1542 deletions

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

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](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

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

View file

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

View file

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

View file

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

View file

@ -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(),
}
let line = readline.readline("Secret key? (leave blank to generate and save a new keypair) ")?;
let keys = if line.is_empty() {
info!("Generating and persisting new key");
Keys::generate()
} else {
Keys::from_str(&line)
.inspect_err(|e| eprintln!("Invalid key provided: {e}"))?
};
or_warn!(keys_entry.set_secret(keys.secret_key().as_secret_bytes()),
"Could not persist keys");
Ok(keys)
}
// TODO this direly needs testing
fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
let min = Timestamp::now().sub(UNDO_DELAY);
{
// Always flush if oldest event older than a minute or newer than now
let borrow = self.queue.borrow();
if borrow.iter().any(|e| e.created_at < min || e.created_at > Timestamp::now()) {
drop(borrow);
debug!("Flushing event queue because it is older than a minute");
self.force_flush();
}
}
let mut queue = self.queue.borrow_mut();
Ok(event_builder.to_event(&self.keys).inspect(|event| {
if event.kind == TRACKING_KIND && event.created_at > min && event.created_at < tasks::now() {
// Do not send redundant movements
queue.retain(|e| e.kind != TRACKING_KIND);
}
queue.push(event.clone());
})?)
}
/// Sends all pending events
fn force_flush(&self) {
debug!("Flushing {} events from queue", self.queue.borrow().len());
let values = self.clear();
self.url.as_ref().map(|url| {
self.tx.try_send(MostrMessage::AddTasks(url.clone(), values)).err().map(|e| {
error!("Nostr communication thread failure, changes will not be persisted: {}", e)
})
});
}
/// Sends all pending events if there is a non-tracking event
fn flush(&self) {
if self.queue.borrow().iter().any(|event| event.kind != TRACKING_KIND) {
self.force_flush()
}
}
fn clear(&self) -> Events {
trace!("Cleared queue: {:?}", self.queue.borrow());
self.queue.replace(Vec::with_capacity(3))
}
pub(crate) fn pubkey(&self) -> PublicKey {
self.keys.public_key()
}
}
impl Drop for EventSender {
fn drop(&mut self) {
self.force_flush();
debug!("Dropped {:?}", self);
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum MostrMessage {
Flush,
NewRelay(Url),
AddTasks(Url, Vec<Event>),
}
#[tokio::main]
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,28 +450,18 @@ 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;
} else {
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its updates");
}
}
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes");
tasks.recurse_activities = !tasks.recurse_activities;
info!("Toggled activities recursion to {}", tasks.recurse_activities);
}
}
Some(arg) => {
if arg.len() < CHARACTER_THRESHOLD {
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
@ -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;

View file

@ -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()
self.tags.as_ref().map(|tags| {
tags.iter()
.filter(predicate)
.map(|t| t.content().unwrap().to_string())
.sorted_unstable()
.dedup()
.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);
}
}

File diff suppressed because it is too large Load diff