Compare commits

...

7 commits

5 changed files with 224 additions and 123 deletions

View file

@ -1,7 +1,8 @@
use crate::task::MARKER_PARENT;
use crate::tasks::nostr_users::NostrUsers;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools;
use nostr_sdk::{EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard};
use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue;
@ -99,22 +100,27 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
/// as well as various embedded tags.
///
/// Expects sanitized input.
pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
pub(crate) fn extract_tags(input: &str, users: &NostrUsers) -> (String, Vec<Tag>) {
let words = input.split_ascii_whitespace();
let mut prio = None;
let mut tags = Vec::with_capacity(4);
let result = words.filter(|s| {
if s.starts_with('*') {
if s.len() == 1 {
prio = Some(HIGH_PRIO);
if s.starts_with('@') {
if let Ok(key) = PublicKey::parse(&s[1..]) {
tags.push(Tag::public_key(key));
return false;
} else if let Some((key, _)) = users.find_user(&s[1..]) {
tags.push(Tag::public_key(*key));
return false;
}
return match s[1..].parse::<Prio>() {
Ok(num) => {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
false
}
_ => true,
};
} else if s.starts_with('*') {
if s.len() == 1 {
tags.push(to_prio_tag(HIGH_PRIO));
return false;
}
if let Ok(num) = s[1..].parse::<Prio>() {
tags.push(to_prio_tag(num * (if s.len() > 2 { 1 } else { 10 })));
return false
}
}
true
}).collect_vec();
@ -122,7 +128,7 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(prio.map(|p| to_prio_tag(p)))
.chain(tags)
.collect_vec();
tags.sort();
tags.dedup();
@ -161,10 +167,11 @@ pub 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"),
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it", &Default::default()),
("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_tag)).collect()));
assert_eq!(extract_tags("So tagless #"),
("So tagless".to_string(), vec![]));
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag))
.collect()));
assert_eq!(extract_tags("So tagless @hewo #", &Default::default()),
("So tagless @hewo".to_string(), vec![]));
}

View file

@ -12,7 +12,7 @@ use crate::event_sender::MostrMessage;
use crate::helpers::*;
use crate::kinds::{format_tag_basic, 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 crate::tasks::{referenced_event, PropertyCollection, StateFilter, TasksRelay};
use chrono::Local;
use colored::Colorize;
use directories::ProjectDirs;
@ -438,14 +438,36 @@ async fn main() -> Result<()> {
Some('&') => {
match arg {
None => tasks.undo(),
Some(text) => match text.parse::<u8>() {
Ok(int) => {
tasks.move_back_by(int as usize);
Some(text) => {
if text == "&" {
println!(
"My History:\n{}",
tasks.history_before_now()
.take(9)
.enumerate()
.dropping(1)
.map(|(c, e)| {
format!("({}) {}",
c,
match referenced_event(e) {
Some(target) => tasks.get_task_path(Some(target)),
None => "---".to_string(),
},
)
})
.join("\n")
);
continue 'repl;
}
_ => {
if !tasks.move_back_to(text) {
warn!("Did not find a match in history for \"{text}\"");
continue 'repl;
match text.parse::<u8>() {
Ok(int) => {
tasks.move_back_by(int as usize);
}
_ => {
if !tasks.move_back_to(text) {
warn!("Did not find a match in history for \"{text}\"");
continue 'repl;
}
}
}
}
@ -464,7 +486,7 @@ async fn main() -> Result<()> {
Some(arg) => {
if arg == "@" {
tasks.reset_key_filter()
} else if let Some((key, name)) = tasks.find_user_with_displayname(arg) {
} else if let Some((key, name)) = tasks.find_user(arg) {
info!("Showing {}'s tasks", name);
tasks.set_key_filter(key)
} else {

View file

@ -4,13 +4,14 @@ use std::collections::BTreeSet;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::iter::once;
use std::str::FromStr;
use std::string::ToString;
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::{Alphabet, Event, EventId, Kind, PublicKey, SingleLetterTag, Tag, TagKind, Timestamp};
use crate::hashtag::{is_hashtag, Hashtag};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
@ -70,6 +71,15 @@ impl Task {
&self.event.id
}
pub(crate) fn get_owner(&self) -> PublicKey {
self.tags()
.find(|t| t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)))
.and_then(|t| t.content()
.and_then(|c| PublicKey::from_str(c).inspect_err(|e| warn!("Unparseable pubkey in {:?}", t)).ok()))
.unwrap_or_else(|| self.event.pubkey)
}
pub(crate) fn find_refs<'a>(&'a self, marker: &'a str) -> impl Iterator<Item=&'a EventId> {
self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker))
}
@ -184,11 +194,12 @@ impl Task {
self.tags().filter_map(|t| Hashtag::try_from(t).ok())
}
/// Tags of this task that are not event references, newest to oldest
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)))
)
self.props.iter()
.flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
.chain(self.tags.iter().flatten())
}
fn join_tags<P>(&self, predicate: P) -> String

View file

@ -1,3 +1,5 @@
pub(crate) mod nostr_users;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fmt::{Display, Formatter};
use std::iter::{empty, once, FusedIterator};
@ -5,11 +7,12 @@ use std::ops::{Div, Rem};
use std::str::FromStr;
use std::time::Duration;
use crate::hashtag::Hashtag;
use crate::event_sender::{EventSender, MostrMessage};
use crate::hashtag::Hashtag;
use crate::helpers::{format_timestamp_local, format_timestamp_relative, format_timestamp_relative_to, parse_tracking_stamp, some_non_empty, to_string_or_default, CHARACTER_THRESHOLD};
use crate::kinds::*;
use crate::task::{State, Task, TaskState, MARKER_DEPENDS, MARKER_PARENT, MARKER_PROPERTY};
use crate::tasks::nostr_users::NostrUsers;
use colored::Colorize;
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
@ -54,7 +57,7 @@ pub(crate) struct TasksRelay {
/// History of active tasks by PubKey
history: HashMap<PublicKey, BTreeMap<Timestamp, Event>>,
/// Index of known users with metadata
users: HashMap<PublicKey, Metadata>,
users: NostrUsers,
/// Own pinned tasks
bookmarks: Vec<EventId>,
@ -156,7 +159,7 @@ impl TasksRelay {
bookmarks: Default::default(),
properties: [
"author",
"owner",
"prio",
"state",
"rtime",
@ -167,7 +170,7 @@ impl TasksRelay {
sorting: [
"priority",
"status",
"author",
"owner",
"hashtags",
"rtime",
"name",
@ -253,51 +256,60 @@ impl TasksRelay {
}
/// Dynamic time tracking overview for current task or current user.
pub(crate) fn times_tracked(&self) -> (String, Box<dyn DoubleEndedIterator<Item=String>>) {
pub(crate) fn times_tracked(&self) -> (String, Box<dyn DoubleEndedIterator<Item=String> + '_>) {
self.times_tracked_for(&self.sender.pubkey())
}
pub(crate) fn history_for(
&self,
key: &PublicKey,
) -> Option<impl DoubleEndedIterator<Item=String> + '_> {
self.history.get(key).map(|hist| {
let mut last = None;
// TODO limit history to active tags
hist.values().filter_map(move |event| {
let new = some_non_empty(&event.tags.iter()
.filter_map(|t| t.content())
.map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_path(Some(id))))
.join(" "));
if new != last {
// TODO omit intervals <2min - but I think I need threeway variable tracking for that
// TODO alternate color with grey between days
last = new;
return Some(format!(
"{} {}",
format_timestamp_local(&event.created_at),
last.as_ref().unwrap_or(&"---".to_string())
));
}
None
})
})
}
pub(crate) fn times_tracked_for(
&self,
key: &PublicKey,
) -> (String, Box<dyn DoubleEndedIterator<Item=String>>) {
) -> (String, Box<dyn DoubleEndedIterator<Item=String> + '_>) {
match self.get_position() {
None => {
if let Some(hist) = self.history.get(key) {
let mut last = None;
let mut full = Vec::with_capacity(hist.len());
for event in hist.values() {
let new = some_non_empty(&event.tags.iter()
.filter_map(|t| t.content())
.map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_path(Some(id))))
.join(" "));
if new != last {
// TODO omit intervals <2min - but I think I need threeway for that
// TODO alternate color with grey between days
full.push(format!(
"{} {}",
format_timestamp_local(&event.created_at),
new.as_ref().unwrap_or(&"---".to_string())
));
last = new;
}
}
// TODO show history for active tags
(
format!("Time-Tracking History for {}:", self.get_displayname(&key)),
Box::from(full.into_iter()),
)
} else {
(
"Nothing time-tracked yet".to_string(),
Box::from(empty()),
)
match self.history_for(key) {
Some(hist) =>
(
format!("Time-Tracking History for {}:", self.users.get_displayname(&key)),
Box::from(hist),
),
None =>
(
"Nothing time-tracked yet".to_string(),
Box::from(empty()),
)
}
}
Some(id) => {
// TODO show current recursive with pubkey
let ids = [id];
let history =
let mut history =
self.history.iter().flat_map(|(key, set)| {
let mut vec = Vec::with_capacity(set.len() / 2);
let mut iter = timestamps(set.values(), &ids).tuples();
@ -308,7 +320,7 @@ impl TasksRelay {
"{} - {} by {}",
format_timestamp_local(start),
format_timestamp_relative_to(end, start),
self.get_displayname(key)
self.users.get_displayname(key)
))
}
}
@ -316,14 +328,17 @@ impl TasksRelay {
vec.push(format!(
"{} started by {}",
format_timestamp_local(stamp),
self.get_displayname(key)
self.users.get_displayname(key)
))
});
vec
}).sorted_unstable(); // TODO sorting depends on timestamp format - needed to interleave different people
})
.collect_vec();
// TODO sorting depends on timestamp format - needed to interleave different people
history.sort_unstable();
(
format!("Times Tracked on {:?}", self.get_task_title(&id)),
Box::from(history),
Box::from(history.into_iter()),
)
}
}
@ -393,7 +408,7 @@ impl TasksRelay {
Some(key) =>
if key != self.sender.pubkey() {
prompt.push_str(" @");
prompt.push_str(&self.get_username(&key))
prompt.push_str(&self.users.get_username(&key))
},
}
for tag in self.tags.iter() {
@ -494,7 +509,7 @@ impl TasksRelay {
fn filter(&self, task: &Task) -> bool {
self.state.matches(task) &&
(!!task.is_task() || self.pubkey.is_none_or(|p| p == task.event.pubkey)) &&
(!task.is_task() || self.pubkey.is_none_or(|p| p == task.event.pubkey)) &&
self.priority.is_none_or(|prio| {
task.priority().unwrap_or(DEFAULT_PRIO) >= prio
}) &&
@ -646,7 +661,8 @@ impl TasksRelay {
}
"progress" => prog_string.clone(),
"author" | "creator" => format!("{:.6}", self.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment
"owner" => format!("{:.6}", self.users.get_username(&task.get_owner())),
"author" | "creator" => format!("{:.6}", self.users.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment
"prio" => self
.traverse_up_from(Some(task.event.id))
.find_map(Task::priority_raw)
@ -667,35 +683,8 @@ impl TasksRelay {
}
}
pub(crate) fn find_user_with_displayname(&self, term: &str) -> Option<(PublicKey, String)> {
match PublicKey::from_str(term) {
Ok(key) => Some((key, self.get_displayname(&key))),
Err(_) => self.find_user(term).map(|(k, _)| (*k, self.get_displayname(k))),
}
}
// Find username or key starting with the given term.
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
if let Ok(key) = PublicKey::from_str(term) {
return self.users.get_key_value(&key);
}
self.users.iter().find(|(k, v)|
// TODO regex word boundary
v.name.as_ref().is_some_and(|n| n.starts_with(term)) ||
v.display_name.as_ref().is_some_and(|n| n.starts_with(term)) ||
(term.len() > 4 && k.to_string().starts_with(term)))
}
pub(crate) fn get_displayname(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.display_name.clone().or(m.name.clone()))
.unwrap_or_else(|| pubkey.to_string())
}
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.name.clone())
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
pub(super) fn find_user(&self, name: &str) -> Option<(PublicKey, String)> {
self.users.find_user_with_displayname(name)
}
// Movement and Selection
@ -870,8 +859,8 @@ impl TasksRelay {
return vec![id];
}
let lowercase_arg = arg.to_ascii_lowercase();
// TODO apply regex to all matching
let regex = Regex::new(&format!(r"\b{}", lowercase_arg)).unwrap();
// TODO apply regex to all matching, parse as plain match
let regex = Regex::new(&format!(r"\b{}", lowercase_arg));
let mut filtered: Vec<EventId> = Vec::with_capacity(32);
let mut filtered_fuzzy: Vec<EventId> = Vec::with_capacity(32);
@ -882,7 +871,9 @@ impl TasksRelay {
return vec![task.event.id];
} else if content.starts_with(arg) {
filtered.push(task.event.id)
} else if regex.is_match(lowercase.as_bytes()) {
} else if regex.as_ref()
.map(|r| r.is_match(lowercase.as_bytes()))
.unwrap_or_else(|_| lowercase.starts_with(&lowercase_arg)) {
filtered_fuzzy.push(task.event.id)
}
}
@ -951,6 +942,11 @@ impl TasksRelay {
}
pub(crate) fn move_to(&mut self, target: Option<EventId>) {
if let Some(time) = self.custom_time {
self.track_at(time, target);
return;
}
self.view.clear();
let pos = self.get_position();
if target == pos {
@ -1065,7 +1061,7 @@ impl TasksRelay {
///
/// Sanitizes input
pub(crate) fn make_task_with(&mut self, input: &str, tags: impl IntoIterator<Item=Tag>, set_state: bool) -> EventId {
let (input, input_tags) = extract_tags(input.trim());
let (input, input_tags) = extract_tags(input.trim(), &self.users);
let prio =
if input_tags.iter().any(|t| t.kind().to_string() == PRIO) { None } else { self.priority.map(|p| to_prio_tag(p)) };
info!("Created task \"{input}\" with tags [{}]", join_tags(&input_tags));
@ -1156,9 +1152,7 @@ impl TasksRelay {
pub(crate) fn add(&mut self, event: Event) {
let author = event.pubkey;
if !self.users.contains_key(&author) {
self.users.insert(author, Metadata::new());
}
self.users.create(author);
match event.kind {
Kind::GitIssue => self.add_task(event),
Kind::Metadata => match Metadata::from_json(event.content.as_str()) {
@ -1221,25 +1215,29 @@ impl TasksRelay {
}
fn get_own_events_history(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
self.history.get(&self.sender.pubkey())
self.get_own_history()
.into_iter()
.flat_map(|t| t.values())
}
fn history_before_now(&self) -> impl Iterator<Item=&Event> {
pub(super) fn history_before_now(&self) -> impl Iterator<Item=&Event> {
self.get_own_history().into_iter().flat_map(|hist| {
let now = now();
hist.values().rev().skip_while(move |e| e.created_at > now)
hist.values().rev()
.skip_while(move |e| e.created_at > now)
.dedup_by(|e1, e2| e1.id == e2.id)
})
}
pub(crate) fn move_back_to(&mut self, str: &str) -> bool {
let lower = str.to_ascii_lowercase();
let found = self.history_before_now().find(|e| {
referenced_event(e)
.and_then(|id| self.get_by_id(&id))
.is_some_and(|t| t.event.content.to_ascii_lowercase().contains(&lower))
});
let found =
self.history_before_now()
.find(|e| {
referenced_event(e)
.and_then(|id| self.get_by_id(&id))
.is_some_and(|t| t.event.content.to_ascii_lowercase().contains(&lower))
});
if let Some(event) = found {
self.move_to(referenced_event(event));
return true;
@ -1287,7 +1285,7 @@ impl TasksRelay {
} else {
vec![id]
};
let (desc, tags) = extract_tags(comment);
let (desc, tags) = extract_tags(comment, &self.users);
let prop =
EventBuilder::new(state.into(), desc)
.tags(ids.into_iter()
@ -1315,7 +1313,7 @@ impl TasksRelay {
/// Creates a note or activity, depending on whether the parent is a task.
/// Sanitizes Input.
pub(crate) fn make_note(&mut self, note: &str) -> EventId {
let (name, tags) = extract_tags(note.trim());
let (name, tags) = extract_tags(note.trim(), &self.users);
let format = format!("\"{name}\" with tags [{}]", join_tags(&tags));
let mut prop =
EventBuilder::new(Kind::TextNote, name)
@ -1527,7 +1525,7 @@ fn referenced_events(event: &Event) -> impl Iterator<Item=EventId> + '_ {
event.tags.iter().filter_map(|tag| match_event_tag(tag).map(|t| t.id))
}
fn referenced_event(event: &Event) -> Option<EventId> {
pub fn referenced_event(event: &Event) -> Option<EventId> {
referenced_events(event).next()
}
@ -1828,7 +1826,7 @@ mod tasks_test {
tasks.custom_time = Some(Timestamp::now());
tasks.update_state("Finished #YeaH # oi", State::Done);
assert_eq!(tasks.get_by_id(&parent).unwrap().list_hashtags().collect_vec(), ["tag1", "YeaH", "oi", "tag3", "yeah"].map(Hashtag::from));
assert_eq!(tasks.get_by_id(&parent).unwrap().list_hashtags().collect_vec(), ["YeaH", "oi", "tag3", "yeah", "tag1"].map(Hashtag::from));
assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(now());

63
src/tasks/nostr_users.rs Normal file
View file

@ -0,0 +1,63 @@
use std::collections::HashMap;
use std::str::FromStr;
use nostr_sdk::{Keys, Metadata, PublicKey, Tag};
#[derive(Default, Debug, Clone, PartialEq, Eq)]
pub struct NostrUsers {
users: HashMap<PublicKey, Metadata>
}
impl NostrUsers {
pub(crate) fn find_user_with_displayname(&self, term: &str) -> Option<(PublicKey, String)> {
self.find_user(term)
.map(|(k, _)| (*k, self.get_displayname(k)))
}
// Find username or key starting with the given term.
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
let lowered = term.trim().to_ascii_lowercase();
let term = lowered.as_str();
if term.is_empty() {
return None
}
if let Ok(key) = PublicKey::from_str(term) {
return self.users.get_key_value(&key);
}
self.users.iter().find(|(k, v)|
// TODO regex word boundary
v.name.as_ref().is_some_and(|n| n.to_ascii_lowercase().starts_with(term)) ||
v.display_name.as_ref().is_some_and(|n| n.to_ascii_lowercase().starts_with(term)) ||
(term.len() > 4 && k.to_string().starts_with(term)))
}
pub(crate) fn get_displayname(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.display_name.clone().or(m.name.clone()))
.unwrap_or_else(|| pubkey.to_string())
}
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.name.clone())
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
}
pub(super) fn insert(&mut self, pubkey: PublicKey, metadata: Metadata) {
self.users.insert(pubkey, metadata);
}
pub(super) fn create(&mut self, pubkey: PublicKey) {
if !self.users.contains_key(&pubkey) {
self.users.insert(pubkey, Default::default());
}
}
}
#[test]
fn test_user_extract() {
let keys = Keys::generate();
let mut users = NostrUsers::default();
users.insert(keys.public_key, Metadata::new().display_name("Tester Jo"));
assert_eq!(crate::kinds::extract_tags("Hello @test", &users),
("Hello".to_string(), vec![Tag::public_key(keys.public_key)]));
}