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::task::MARKER_PARENT;
use crate::tasks::nostr_users::NostrUsers;
use crate::tasks::HIGH_PRIO; use crate::tasks::HIGH_PRIO;
use itertools::Itertools; 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; use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue; 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. /// as well as various embedded tags.
/// ///
/// Expects sanitized input. /// 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 words = input.split_ascii_whitespace();
let mut prio = None; let mut tags = Vec::with_capacity(4);
let result = words.filter(|s| { let result = words.filter(|s| {
if s.starts_with('*') { if s.starts_with('@') {
if s.len() == 1 { if let Ok(key) = PublicKey::parse(&s[1..]) {
prio = Some(HIGH_PRIO); 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 false;
} }
return match s[1..].parse::<Prio>() { } else if s.starts_with('*') {
Ok(num) => { if s.len() == 1 {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 })); tags.push(to_prio_tag(HIGH_PRIO));
false 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,
};
} }
true true
}).collect_vec(); }).collect_vec();
@ -122,7 +128,7 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
let main = split.next().unwrap().join(" "); let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main) let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag_tag(&s))) .chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(prio.map(|p| to_prio_tag(p))) .chain(tags)
.collect_vec(); .collect_vec();
tags.sort(); tags.sort();
tags.dedup(); tags.dedup();
@ -161,10 +167,11 @@ pub fn to_prio_tag(value: Prio) -> Tag {
#[test] #[test]
fn test_extract_tags() { 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(), ("Hello from #mars with #greetings #yeah".to_string(),
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.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())); .chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag))
assert_eq!(extract_tags("So tagless #"), .collect()));
("So tagless".to_string(), vec![])); 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::helpers::*;
use crate::kinds::{format_tag_basic, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS}; 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::task::{State, Task, TaskState, MARKER_PROPERTY};
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay}; use crate::tasks::{referenced_event, PropertyCollection, StateFilter, TasksRelay};
use chrono::Local; use chrono::Local;
use colored::Colorize; use colored::Colorize;
use directories::ProjectDirs; use directories::ProjectDirs;
@ -438,7 +438,28 @@ async fn main() -> Result<()> {
Some('&') => { Some('&') => {
match arg { match arg {
None => tasks.undo(), None => tasks.undo(),
Some(text) => match text.parse::<u8>() { 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;
}
match text.parse::<u8>() {
Ok(int) => { Ok(int) => {
tasks.move_back_by(int as usize); tasks.move_back_by(int as usize);
} }
@ -451,6 +472,7 @@ async fn main() -> Result<()> {
} }
} }
} }
}
Some('@') => { Some('@') => {
match arg { match arg {
@ -464,7 +486,7 @@ async fn main() -> Result<()> {
Some(arg) => { Some(arg) => {
if arg == "@" { if arg == "@" {
tasks.reset_key_filter() 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); info!("Showing {}'s tasks", name);
tasks.set_key_filter(key) tasks.set_key_filter(key)
} else { } else {

View file

@ -4,13 +4,14 @@ use std::collections::BTreeSet;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::iter::once; use std::iter::once;
use std::str::FromStr;
use std::string::ToString; use std::string::ToString;
use colored::{ColoredString, Colorize}; use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right}; use itertools::Either::{Left, Right};
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; 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::hashtag::{is_hashtag, Hashtag};
use crate::helpers::{format_timestamp_local, some_non_empty}; use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND}; use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
@ -70,6 +71,15 @@ impl Task {
&self.event.id &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> { 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)) 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()) 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> { fn tags(&self) -> impl Iterator<Item=&Tag> {
self.tags.iter().flatten().chain( self.props.iter()
self.props.iter().flat_map(|e| e.tags.iter() .flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E))) .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 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::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::iter::{empty, once, FusedIterator}; use std::iter::{empty, once, FusedIterator};
@ -5,11 +7,12 @@ use std::ops::{Div, Rem};
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use crate::hashtag::Hashtag;
use crate::event_sender::{EventSender, MostrMessage}; 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::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::kinds::*;
use crate::task::{State, Task, TaskState, MARKER_DEPENDS, MARKER_PARENT, MARKER_PROPERTY}; use crate::task::{State, Task, TaskState, MARKER_DEPENDS, MARKER_PARENT, MARKER_PROPERTY};
use crate::tasks::nostr_users::NostrUsers;
use colored::Colorize; use colored::Colorize;
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
@ -54,7 +57,7 @@ pub(crate) struct TasksRelay {
/// History of active tasks by PubKey /// History of active tasks by PubKey
history: HashMap<PublicKey, BTreeMap<Timestamp, Event>>, history: HashMap<PublicKey, BTreeMap<Timestamp, Event>>,
/// Index of known users with metadata /// Index of known users with metadata
users: HashMap<PublicKey, Metadata>, users: NostrUsers,
/// Own pinned tasks /// Own pinned tasks
bookmarks: Vec<EventId>, bookmarks: Vec<EventId>,
@ -156,7 +159,7 @@ impl TasksRelay {
bookmarks: Default::default(), bookmarks: Default::default(),
properties: [ properties: [
"author", "owner",
"prio", "prio",
"state", "state",
"rtime", "rtime",
@ -167,7 +170,7 @@ impl TasksRelay {
sorting: [ sorting: [
"priority", "priority",
"status", "status",
"author", "owner",
"hashtags", "hashtags",
"rtime", "rtime",
"name", "name",
@ -253,41 +256,50 @@ impl TasksRelay {
} }
/// Dynamic time tracking overview for current task or current user. /// 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()) self.times_tracked_for(&self.sender.pubkey())
} }
pub(crate) fn times_tracked_for( pub(crate) fn history_for(
&self, &self,
key: &PublicKey, key: &PublicKey,
) -> (String, Box<dyn DoubleEndedIterator<Item=String>>) { ) -> Option<impl DoubleEndedIterator<Item=String> + '_> {
match self.get_position() { self.history.get(key).map(|hist| {
None => {
if let Some(hist) = self.history.get(key) {
let mut last = None; let mut last = None;
let mut full = Vec::with_capacity(hist.len()); // TODO limit history to active tags
for event in hist.values() { hist.values().filter_map(move |event| {
let new = some_non_empty(&event.tags.iter() let new = some_non_empty(&event.tags.iter()
.filter_map(|t| t.content()) .filter_map(|t| t.content())
.map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_path(Some(id)))) .map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_path(Some(id))))
.join(" ")); .join(" "));
if new != last { if new != last {
// TODO omit intervals <2min - but I think I need threeway for that // TODO omit intervals <2min - but I think I need threeway variable tracking for that
// TODO alternate color with grey between days // TODO alternate color with grey between days
full.push(format!( last = new;
return Some(format!(
"{} {}", "{} {}",
format_timestamp_local(&event.created_at), format_timestamp_local(&event.created_at),
new.as_ref().unwrap_or(&"---".to_string()) last.as_ref().unwrap_or(&"---".to_string())
)); ));
last = new;
} }
None
})
})
} }
// TODO show history for active tags
pub(crate) fn times_tracked_for(
&self,
key: &PublicKey,
) -> (String, Box<dyn DoubleEndedIterator<Item=String> + '_>) {
match self.get_position() {
None => {
match self.history_for(key) {
Some(hist) =>
( (
format!("Time-Tracking History for {}:", self.get_displayname(&key)), format!("Time-Tracking History for {}:", self.users.get_displayname(&key)),
Box::from(full.into_iter()), Box::from(hist),
) ),
} else { None =>
( (
"Nothing time-tracked yet".to_string(), "Nothing time-tracked yet".to_string(),
Box::from(empty()), Box::from(empty()),
@ -297,7 +309,7 @@ impl TasksRelay {
Some(id) => { Some(id) => {
// TODO show current recursive with pubkey // TODO show current recursive with pubkey
let ids = [id]; let ids = [id];
let history = let mut history =
self.history.iter().flat_map(|(key, set)| { self.history.iter().flat_map(|(key, set)| {
let mut vec = Vec::with_capacity(set.len() / 2); let mut vec = Vec::with_capacity(set.len() / 2);
let mut iter = timestamps(set.values(), &ids).tuples(); let mut iter = timestamps(set.values(), &ids).tuples();
@ -308,7 +320,7 @@ impl TasksRelay {
"{} - {} by {}", "{} - {} by {}",
format_timestamp_local(start), format_timestamp_local(start),
format_timestamp_relative_to(end, 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!( vec.push(format!(
"{} started by {}", "{} started by {}",
format_timestamp_local(stamp), format_timestamp_local(stamp),
self.get_displayname(key) self.users.get_displayname(key)
)) ))
}); });
vec 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)), 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) => Some(key) =>
if key != self.sender.pubkey() { if key != self.sender.pubkey() {
prompt.push_str(" @"); prompt.push_str(" @");
prompt.push_str(&self.get_username(&key)) prompt.push_str(&self.users.get_username(&key))
}, },
} }
for tag in self.tags.iter() { for tag in self.tags.iter() {
@ -494,7 +509,7 @@ impl TasksRelay {
fn filter(&self, task: &Task) -> bool { fn filter(&self, task: &Task) -> bool {
self.state.matches(task) && 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| { self.priority.is_none_or(|prio| {
task.priority().unwrap_or(DEFAULT_PRIO) >= prio task.priority().unwrap_or(DEFAULT_PRIO) >= prio
}) && }) &&
@ -646,7 +661,8 @@ impl TasksRelay {
} }
"progress" => prog_string.clone(), "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 "prio" => self
.traverse_up_from(Some(task.event.id)) .traverse_up_from(Some(task.event.id))
.find_map(Task::priority_raw) .find_map(Task::priority_raw)
@ -667,35 +683,8 @@ impl TasksRelay {
} }
} }
pub(crate) fn find_user_with_displayname(&self, term: &str) -> Option<(PublicKey, String)> { pub(super) fn find_user(&self, name: &str) -> Option<(PublicKey, String)> {
match PublicKey::from_str(term) { self.users.find_user_with_displayname(name)
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()))
} }
// Movement and Selection // Movement and Selection
@ -870,8 +859,8 @@ impl TasksRelay {
return vec![id]; return vec![id];
} }
let lowercase_arg = arg.to_ascii_lowercase(); let lowercase_arg = arg.to_ascii_lowercase();
// TODO apply regex to all matching // TODO apply regex to all matching, parse as plain match
let regex = Regex::new(&format!(r"\b{}", lowercase_arg)).unwrap(); let regex = Regex::new(&format!(r"\b{}", lowercase_arg));
let mut filtered: Vec<EventId> = Vec::with_capacity(32); let mut filtered: Vec<EventId> = Vec::with_capacity(32);
let mut filtered_fuzzy: 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]; return vec![task.event.id];
} else if content.starts_with(arg) { } else if content.starts_with(arg) {
filtered.push(task.event.id) 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) filtered_fuzzy.push(task.event.id)
} }
} }
@ -951,6 +942,11 @@ impl TasksRelay {
} }
pub(crate) fn move_to(&mut self, target: Option<EventId>) { 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(); self.view.clear();
let pos = self.get_position(); let pos = self.get_position();
if target == pos { if target == pos {
@ -1065,7 +1061,7 @@ impl TasksRelay {
/// ///
/// Sanitizes input /// Sanitizes input
pub(crate) fn make_task_with(&mut self, input: &str, tags: impl IntoIterator<Item=Tag>, set_state: bool) -> EventId { 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 = let prio =
if input_tags.iter().any(|t| t.kind().to_string() == PRIO) { None } else { self.priority.map(|p| to_prio_tag(p)) }; 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)); info!("Created task \"{input}\" with tags [{}]", join_tags(&input_tags));
@ -1156,9 +1152,7 @@ impl TasksRelay {
pub(crate) fn add(&mut self, event: Event) { pub(crate) fn add(&mut self, event: Event) {
let author = event.pubkey; let author = event.pubkey;
if !self.users.contains_key(&author) { self.users.create(author);
self.users.insert(author, Metadata::new());
}
match event.kind { match event.kind {
Kind::GitIssue => self.add_task(event), Kind::GitIssue => self.add_task(event),
Kind::Metadata => match Metadata::from_json(event.content.as_str()) { Kind::Metadata => match Metadata::from_json(event.content.as_str()) {
@ -1221,21 +1215,25 @@ impl TasksRelay {
} }
fn get_own_events_history(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ { fn get_own_events_history(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
self.history.get(&self.sender.pubkey()) self.get_own_history()
.into_iter() .into_iter()
.flat_map(|t| t.values()) .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| { self.get_own_history().into_iter().flat_map(|hist| {
let now = now(); 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 { pub(crate) fn move_back_to(&mut self, str: &str) -> bool {
let lower = str.to_ascii_lowercase(); let lower = str.to_ascii_lowercase();
let found = self.history_before_now().find(|e| { let found =
self.history_before_now()
.find(|e| {
referenced_event(e) referenced_event(e)
.and_then(|id| self.get_by_id(&id)) .and_then(|id| self.get_by_id(&id))
.is_some_and(|t| t.event.content.to_ascii_lowercase().contains(&lower)) .is_some_and(|t| t.event.content.to_ascii_lowercase().contains(&lower))
@ -1287,7 +1285,7 @@ impl TasksRelay {
} else { } else {
vec![id] vec![id]
}; };
let (desc, tags) = extract_tags(comment); let (desc, tags) = extract_tags(comment, &self.users);
let prop = let prop =
EventBuilder::new(state.into(), desc) EventBuilder::new(state.into(), desc)
.tags(ids.into_iter() .tags(ids.into_iter()
@ -1315,7 +1313,7 @@ impl TasksRelay {
/// Creates a note or activity, depending on whether the parent is a task. /// Creates a note or activity, depending on whether the parent is a task.
/// Sanitizes Input. /// Sanitizes Input.
pub(crate) fn make_note(&mut self, note: &str) -> EventId { 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 format = format!("\"{name}\" with tags [{}]", join_tags(&tags));
let mut prop = let mut prop =
EventBuilder::new(Kind::TextNote, name) 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)) 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() referenced_events(event).next()
} }
@ -1828,7 +1826,7 @@ mod tasks_test {
tasks.custom_time = Some(Timestamp::now()); tasks.custom_time = Some(Timestamp::now());
tasks.update_state("Finished #YeaH # oi", State::Done); 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); assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(now()); 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)]));
}