diff --git a/src/hashtag.rs b/src/hashtag.rs new file mode 100644 index 0000000..9c0dbfd --- /dev/null +++ b/src/hashtag.rs @@ -0,0 +1,89 @@ +use std::cmp::Ordering; +use std::fmt::{Display, Formatter}; +use std::ops::Deref; +use itertools::Itertools; +use nostr_sdk::{Alphabet, Tag}; + +pub fn is_hashtag(tag: &Tag) -> bool { + tag.single_letter_tag() + .is_some_and(|letter| letter.character == Alphabet::T) +} + +#[derive(Clone, Debug)] +pub struct Hashtag(pub String); + +impl Display for Hashtag { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Hashtag { + pub fn content(&self) -> &str { &self.0 } + pub fn matches(&self, token: &str) -> bool { + self.0.contains(&token.to_ascii_lowercase()) + } +} + +impl Eq for Hashtag {} +impl PartialEq for Hashtag { + fn eq(&self, other: &Self) -> bool { + self.0.to_ascii_lowercase() == other.0.to_ascii_lowercase() + } +} +impl Deref for Hashtag { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl TryFrom<&Tag> for Hashtag { + type Error = String; + + fn try_from(value: &Tag) -> Result { + value.content().take_if(|_| is_hashtag(value)) + .map(|s| Hashtag(s.trim().to_string())) + .ok_or_else(|| "Tag is not a Hashtag".to_string()) + } +} +impl From<&str> for Hashtag { + fn from(value: &str) -> Self { + Hashtag(value.trim().to_string()) + } +} +impl From<&Hashtag> for Tag { + fn from(value: &Hashtag) -> Self { + Tag::hashtag(&value.0) + } +} + +impl Ord for Hashtag { + fn cmp(&self, other: &Self) -> Ordering { + self.0.to_ascii_lowercase().cmp(&other.0.to_ascii_lowercase()) + // Wanted to do this so lowercase tags are preferred, + // but is technically undefined behaviour + // because it deviates from Eq implementation + //match { + // Ordering::Equal => self.0.cmp(&other.0), + // other => other, + //} + } +} +impl PartialOrd for Hashtag { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.0.to_ascii_lowercase().cmp(&other.0.to_ascii_lowercase())) + } +} + +#[test] +fn test_hashtag() { + assert_eq!("yeah", "YeaH".to_ascii_lowercase()); + assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal); + + let strings = vec!["yeah", "YeaH"]; + let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec(); + assert_eq!(strings, tags.iter().map(Hashtag::deref).collect_vec()); + tags.sort_unstable(); + assert_eq!(strings, tags.iter().map(Hashtag::deref).collect_vec()); +} \ No newline at end of file diff --git a/src/kinds.rs b/src/kinds.rs index 350b6cc..4a2ad74 100644 --- a/src/kinds.rs +++ b/src/kinds.rs @@ -92,7 +92,7 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator + '_ { input.split_ascii_whitespace() .filter(|s| s.starts_with('#')) .map(|s| s.trim_start_matches('#')) - .map(to_hashtag) + .map(to_hashtag_tag) } /// Extracts everything after a " # " as a list of tags @@ -121,7 +121,7 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec) { let mut split = result.split(|e| { e == &"#" }); let main = split.next().unwrap().join(" "); let mut tags = extract_hashtags(&main) - .chain(split.flatten().map(|s| to_hashtag(&s))) + .chain(split.flatten().map(|s| to_hashtag_tag(&s))) .chain(prio.map(|p| to_prio_tag(p))) .collect_vec(); tags.sort(); @@ -129,7 +129,7 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec) { (main, tags) } -pub fn to_hashtag(tag: &str) -> Tag { +pub fn to_hashtag_tag(tag: &str) -> Tag { TagStandard::Hashtag(tag.to_string()).into() } @@ -155,11 +155,6 @@ pub fn format_tag_basic(tag: &Tag) -> String { } } -pub fn is_hashtag(tag: &Tag) -> bool { - tag.single_letter_tag() - .is_some_and(|letter| letter.character == Alphabet::T) -} - pub fn to_prio_tag(value: Prio) -> Tag { Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()]) } @@ -169,7 +164,7 @@ 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())); + .chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag)).collect())); assert_eq!(extract_tags("So tagless #"), ("So tagless".to_string(), vec![])); } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index f388aaa..e37f60e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,7 +21,6 @@ use itertools::Itertools; use keyring::Entry; use log::{debug, error, info, trace, warn, LevelFilter}; use nostr_sdk::prelude::*; -use nostr_sdk::TagStandard::Hashtag; use regex::Regex; use rustyline::config::Configurer; use rustyline::error::ReadlineError; @@ -29,12 +28,14 @@ use rustyline::DefaultEditor; use tokio::sync::mpsc; use tokio::time::error::Elapsed; use tokio::time::timeout; +use crate::hashtag::Hashtag; mod helpers; mod task; mod tasks; mod kinds; mod event_sender; +mod hashtag; const INACTVITY_DELAY: u64 = 200; const LOCAL_RELAY_NAME: &str = "TEMP"; @@ -576,7 +577,7 @@ async fn main() -> Result<()> { } Some('#') => { - if !tasks.update_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())) { + if !tasks.update_tags(arg_default.split_whitespace().map(Hashtag::from)) { continue; } } @@ -708,8 +709,8 @@ async fn main() -> Result<()> { let filtered = tasks.get_filtered(pos, |t| { transform(&t.event.content).contains(&remaining) || - t.get_hashtags().any( - |tag| tag.content().is_some_and(|s| transform(s).contains(&remaining))) + t.list_hashtags().any( + |tag| tag.contains(&remaining)) }); if filtered.len() == 1 { tasks.move_to(filtered.into_iter().next()); diff --git a/src/task.rs b/src/task.rs index 8857d33..5ea1407 100644 --- a/src/task.rs +++ b/src/task.rs @@ -11,9 +11,9 @@ 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 crate::hashtag::{is_hashtag, Hashtag}; 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::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND}; use crate::tasks::now; pub static MARKER_PARENT: &str = "parent"; @@ -174,8 +174,8 @@ impl Task { } } - pub(crate) fn get_hashtags(&self) -> impl Iterator { - self.tags().filter(|t| is_hashtag(t)) + pub(crate) fn list_hashtags(&self) -> impl Iterator + use<'_> { + self.tags().filter_map(|t| Hashtag::try_from(t).ok()) } fn tags(&self) -> impl Iterator { @@ -357,7 +357,7 @@ mod tasks_test { 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); + assert_eq!(task.list_hashtags().count(), 1); task.props.insert( EventBuilder::new(State::Done.into(), "") .sign_with_keys(&keys).unwrap()); @@ -367,7 +367,7 @@ mod tasks_test { .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); + assert_eq!(task.list_hashtags().count(), 2); task.props.insert( EventBuilder::new(State::Closed.into(), "") .custom_created_at(Timestamp::from(Timestamp::now() + 1)) diff --git a/src/tasks.rs b/src/tasks.rs index 4433a45..58e4346 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -1,11 +1,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::fmt::{Display, Formatter}; -use std::io::Write; use std::iter::{empty, once, FusedIterator}; 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::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::*; @@ -16,7 +16,6 @@ use log::{debug, error, info, trace, warn}; use nostr_sdk::{Alphabet, Event, EventBuilder, EventId, JsonUtil, Keys, Kind, Metadata, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp, Url}; use regex::bytes::Regex; use tokio::sync::mpsc::Sender; -use TagStandard::Hashtag; const DEFAULT_PRIO: Prio = 25; pub const HIGH_PRIO: Prio = 85; @@ -72,9 +71,9 @@ pub(crate) struct TasksRelay { pub(crate) recurse_activities: bool, /// Currently active tags - tags: BTreeSet, + tags: BTreeSet, /// Tags filtered out from view - tags_excluded: BTreeSet, + tags_excluded: BTreeSet, /// Current active state state: StateFilter, /// Current priority for filtering and new tasks @@ -244,12 +243,12 @@ impl TasksRelay { .filter(|t| t.pure_state() != State::Closed) } - pub(crate) fn all_hashtags(&self) -> impl Iterator { + pub(crate) fn all_hashtags(&self) -> impl Iterator { self.nonclosed_tasks() - .flat_map(|t| t.get_hashtags()) - .filter_map(|tag| tag.content().map(|s| s.trim())) + .flat_map(|t| t.list_hashtags()) .sorted_unstable() .dedup() + .map(|h| h.0) } /// Dynamic time tracking overview for current task or current user. @@ -397,10 +396,10 @@ impl TasksRelay { }, } for tag in self.tags.iter() { - prompt.push_str(&format!(" #{}", tag.content().unwrap())); + prompt.push_str(&format!(" #{}", tag)); } for tag in self.tags_excluded.iter() { - prompt.push_str(&format!(" -#{}", tag.content().unwrap())); + prompt.push_str(&format!(" -#{}", tag)); } prompt.push_str(&self.state.indicator()); self.priority.map(|p| @@ -498,10 +497,10 @@ impl TasksRelay { self.priority.is_none_or(|prio| { task.priority().unwrap_or(DEFAULT_PRIO) >= prio }) && - !task.get_hashtags().any(|tag| self.tags_excluded.contains(tag)) && + !task.list_hashtags().any(|tag| self.tags_excluded.contains(&tag)) && (self.tags.is_empty() || { - let mut iter = task.get_hashtags().sorted_unstable(); - self.tags.iter().all(|tag| iter.any(|t| t == tag)) + let mut iter = task.list_hashtags().sorted_unstable(); + self.tags.iter().all(|tag| iter.any(|t| &t == tag)) }) } @@ -733,7 +732,7 @@ impl TasksRelay { } /// Returns true if tags have been updated, false if it printed something - pub(crate) fn update_tags(&mut self, tags: impl IntoIterator) -> bool { + pub(crate) fn update_tags(&mut self, tags: impl IntoIterator) -> bool { let mut peekable = tags.into_iter().peekable(); if self.tags.is_empty() && peekable.peek().is_none() { if !self.tags_excluded.is_empty() { @@ -747,7 +746,7 @@ impl TasksRelay { } } - fn set_tags(&mut self, tags: impl IntoIterator) { + fn set_tags(&mut self, tags: impl IntoIterator) { self.tags.clear(); self.tags.extend(tags); } @@ -755,7 +754,7 @@ impl TasksRelay { pub(crate) fn add_tag(&mut self, tag: String) { self.view.clear(); info!("Added tag filter for #{tag}"); - let tag: Tag = Hashtag(tag).into(); + let tag = Hashtag(tag); self.tags_excluded.remove(&tag); self.tags.insert(tag); } @@ -763,9 +762,7 @@ impl TasksRelay { pub(crate) fn remove_tag(&mut self, tag: &str) { self.view.clear(); let len = self.tags.len(); - self.tags.retain(|t| { - !t.content().is_some_and(|value| value.to_string().starts_with(tag)) - }); + self.tags.retain(|t| !t.starts_with(tag)); if self.tags.len() < len { info!("Removed tag filters starting with {tag}"); } else { @@ -964,6 +961,10 @@ impl TasksRelay { }) } + fn context_hashtags(&self) -> impl Iterator + use<'_> { + self.tags.iter().map(Tag::from) + } + /// Creates a task following the current state /// /// Sanitizes input @@ -1007,7 +1008,7 @@ impl TasksRelay { let id = self.submit( EventBuilder::new(TASK_KIND, &input) .tags(input_tags) - .tags(self.tags.iter().cloned()) + .tags(self.context_hashtags()) .tags(tags) .tags(prio) ); @@ -1255,7 +1256,7 @@ impl TasksRelay { MARKER_PROPERTY } else { // Activity if parent is not a task - prop = prop.add_tags(self.tags.iter().cloned()); + prop = prop.add_tags(self.context_hashtags()); MARKER_PARENT }; info!("Created {} {format}", if marker == MARKER_PROPERTY { "note" } else { "activity" } ); @@ -1788,9 +1789,9 @@ mod tasks_test { assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "yeah"]); tasks.custom_time = Some(Timestamp::now()); - tasks.update_state("Finished #yeah # oi", State::Done); - assert_eq!(tasks.get_by_id(&parent).unwrap().get_hashtags().cloned().collect_vec(), ["tag1", "oi", "yeah", "tag3", "yeah"].map(to_hashtag)); - assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "yeah"]); + 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.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "YeaH"]); tasks.custom_time = Some(now()); tasks.update_state("Closing Down", State::Closed);