fix: make hashtag behaviour more consistent

This commit is contained in:
xeruf 2024-11-21 09:47:14 +01:00
parent 0a7685d907
commit 9c92a19cde
3 changed files with 59 additions and 47 deletions

View File

@ -1,41 +1,43 @@
use nostr_sdk::{Alphabet, Tag};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use std::ops::Deref; use std::hash::{Hash, Hasher};
use itertools::Itertools;
use nostr_sdk::{Alphabet, Tag};
pub fn is_hashtag(tag: &Tag) -> bool { pub fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag() tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T) .is_some_and(|letter| letter.character == Alphabet::T)
} }
/// This exists so that Hashtags can easily be matched without caring about case
/// but displayed in their original case
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Hashtag(pub String); pub struct Hashtag {
value: String,
impl Display for Hashtag { lowercased: String,
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
} }
impl Hashtag { impl Hashtag {
pub fn content(&self) -> &str { &self.0 }
pub fn matches(&self, token: &str) -> bool { pub fn matches(&self, token: &str) -> bool {
self.0.contains(&token.to_ascii_lowercase()) self.lowercased.contains(&token.to_ascii_lowercase())
}
}
impl Display for Hashtag {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value)
}
}
impl Hash for Hashtag {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write(self.lowercased.as_bytes());
} }
} }
impl Eq for Hashtag {} impl Eq for Hashtag {}
impl PartialEq<Self> for Hashtag { impl PartialEq<Self> for Hashtag {
fn eq(&self, other: &Self) -> bool { fn eq(&self, other: &Self) -> bool {
self.0.to_ascii_lowercase() == other.0.to_ascii_lowercase() self.lowercased == other.lowercased
}
}
impl Deref for Hashtag {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
} }
} }
impl TryFrom<&Tag> for Hashtag { impl TryFrom<&Tag> for Hashtag {
@ -43,24 +45,28 @@ impl TryFrom<&Tag> for Hashtag {
fn try_from(value: &Tag) -> Result<Self, Self::Error> { fn try_from(value: &Tag) -> Result<Self, Self::Error> {
value.content().take_if(|_| is_hashtag(value)) value.content().take_if(|_| is_hashtag(value))
.map(|s| Hashtag(s.trim().to_string())) .map(|s| Hashtag::from(s))
.ok_or_else(|| "Tag is not a Hashtag".to_string()) .ok_or_else(|| "Tag is not a Hashtag".to_string())
} }
} }
impl From<&str> for Hashtag { impl From<&str> for Hashtag {
fn from(value: &str) -> Self { fn from(value: &str) -> Self {
Hashtag(value.trim().to_string()) let val = value.trim().to_string();
Hashtag {
lowercased: val.to_ascii_lowercase(),
value: val,
}
} }
} }
impl From<&Hashtag> for Tag { impl From<&Hashtag> for Tag {
fn from(value: &Hashtag) -> Self { fn from(value: &Hashtag) -> Self {
Tag::hashtag(&value.0) Tag::hashtag(&value.lowercased)
} }
} }
impl Ord for Hashtag { impl Ord for Hashtag {
fn cmp(&self, other: &Self) -> Ordering { fn cmp(&self, other: &Self) -> Ordering {
self.0.to_ascii_lowercase().cmp(&other.0.to_ascii_lowercase()) self.lowercased.cmp(&other.lowercased)
// Wanted to do this so lowercase tags are preferred, // Wanted to do this so lowercase tags are preferred,
// but is technically undefined behaviour // but is technically undefined behaviour
// because it deviates from Eq implementation // because it deviates from Eq implementation
@ -72,7 +78,7 @@ impl Ord for Hashtag {
} }
impl PartialOrd for Hashtag { impl PartialOrd for Hashtag {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.0.to_ascii_lowercase().cmp(&other.0.to_ascii_lowercase())) Some(self.lowercased.cmp(&other.lowercased))
} }
} }
@ -81,9 +87,10 @@ fn test_hashtag() {
assert_eq!("yeah", "YeaH".to_ascii_lowercase()); assert_eq!("yeah", "YeaH".to_ascii_lowercase());
assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal); assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal);
use itertools::Itertools;
let strings = vec!["yeah", "YeaH"]; let strings = vec!["yeah", "YeaH"];
let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec(); let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec();
assert_eq!(strings, tags.iter().map(Hashtag::deref).collect_vec()); assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
tags.sort_unstable(); tags.sort_unstable();
assert_eq!(strings, tags.iter().map(Hashtag::deref).collect_vec()); assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
} }

View File

@ -584,7 +584,7 @@ async fn main() -> Result<()> {
Some('+') => Some('+') =>
match arg { match arg {
Some(arg) => tasks.add_tag(arg.to_string()), Some(arg) => tasks.add_tag(arg),
None => { None => {
tasks.print_hashtags(); tasks.print_hashtags();
if tasks.has_tag_filter() { if tasks.has_tag_filter() {
@ -710,7 +710,7 @@ async fn main() -> Result<()> {
tasks.get_filtered(pos, |t| { tasks.get_filtered(pos, |t| {
transform(&t.event.content).contains(&remaining) || transform(&t.event.content).contains(&remaining) ||
t.list_hashtags().any( t.list_hashtags().any(
|tag| tag.contains(&remaining)) |tag| tag.matches(&remaining))
}); });
if filtered.len() == 1 { if filtered.len() == 1 {
tasks.move_to(filtered.into_iter().next()); tasks.move_to(filtered.into_iter().next());

View File

@ -243,12 +243,8 @@ impl TasksRelay {
.filter(|t| t.pure_state() != State::Closed) .filter(|t| t.pure_state() != State::Closed)
} }
pub(crate) fn all_hashtags(&self) -> impl Iterator<Item=String> { pub(crate) fn all_hashtags(&self) -> BTreeSet<Hashtag> {
self.nonclosed_tasks() self.nonclosed_tasks().flat_map(|t| t.list_hashtags()).collect()
.flat_map(|t| t.list_hashtags())
.sorted_unstable()
.dedup()
.map(|h| h.0)
} }
/// Dynamic time tracking overview for current task or current user. /// Dynamic time tracking overview for current task or current user.
@ -727,7 +723,7 @@ impl TasksRelay {
pub(crate) fn print_hashtags(&self) { pub(crate) fn print_hashtags(&self) {
println!( println!(
"Hashtags of all known tasks:\n{}", "Hashtags of all known tasks:\n{}",
self.all_hashtags().join(" ").italic() self.all_hashtags().into_iter().join(" ").italic()
); );
} }
@ -751,10 +747,10 @@ impl TasksRelay {
self.tags.extend(tags); self.tags.extend(tags);
} }
pub(crate) fn add_tag(&mut self, tag: String) { pub(crate) fn add_tag(&mut self, tag: &str) {
self.view.clear(); self.view.clear();
info!("Added tag filter for #{tag}"); info!("Added tag filter for #{tag}");
let tag = Hashtag(tag); let tag = Hashtag::from(tag);
self.tags_excluded.remove(&tag); self.tags_excluded.remove(&tag);
self.tags.insert(tag); self.tags.insert(tag);
} }
@ -762,11 +758,11 @@ impl TasksRelay {
pub(crate) fn remove_tag(&mut self, tag: &str) { pub(crate) fn remove_tag(&mut self, tag: &str) {
self.view.clear(); self.view.clear();
let len = self.tags.len(); let len = self.tags.len();
self.tags.retain(|t| !t.starts_with(tag)); self.tags.retain(|t| !t.matches(tag));
if self.tags.len() < len { if self.tags.len() < len {
info!("Removed tag filters starting with {tag}"); info!("Removed tag filters containing {tag}");
} else { } else {
self.tags_excluded.insert(Hashtag(tag.to_string()).into()); self.tags_excluded.insert(Hashtag::from(tag).into());
info!("Excluding #{tag} from view"); info!("Excluding #{tag} from view");
} }
} }
@ -1784,21 +1780,30 @@ mod tasks_test {
let parent = tasks.make_task("parent #tag1"); let parent = tasks.make_task("parent #tag1");
tasks.move_to(Some(parent)); tasks.move_to(Some(parent));
let sub = tasks.make_task("sub #oi # tag2"); let sub = tasks.make_task("sub #oi # tag2");
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2"]); assert_eq!(tasks.all_hashtags(), ["oi", "tag1", "tag2"].into_iter().map(Hashtag::from).collect());
tasks.make_note("note with #tag3 # yeah"); tasks.make_note("note with #tag3 # yeah");
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "yeah"]); let all_tags = ["oi", "tag1", "tag2", "tag3", "yeah"].into_iter().map(Hashtag::from).collect();
assert_eq!(tasks.all_hashtags(), all_tags);
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(), ["tag1", "YeaH", "oi", "tag3", "yeah"].map(Hashtag::from));
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "YeaH"]); assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(now()); tasks.custom_time = Some(now());
tasks.update_state("Closing Down", State::Closed); tasks.update_state("Closing Down", State::Closed);
assert_eq!(tasks.get_by_id(&sub).unwrap().pure_state(), State::Closed); assert_eq!(tasks.get_by_id(&sub).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.get_by_id(&parent).unwrap().pure_state(), State::Closed); assert_eq!(tasks.get_by_id(&parent).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.nonclosed_tasks().next(), None); assert_eq!(tasks.nonclosed_tasks().next(), None);
assert_eq!(tasks.all_hashtags().next(), None); assert_eq!(tasks.all_hashtags(), Default::default());
}
#[test]
fn test_tags() {
let mut tasks = stub_tasks();
tasks.update_tags(["dp", "yeah"].into_iter().map(Hashtag::from));
tasks.remove_tag("Y");
assert_eq!(tasks.tags, ["dp"].into_iter().map(Hashtag::from).collect());
} }
#[test] #[test]
@ -1854,7 +1859,7 @@ mod tasks_test {
assert_tasks!(tasks, [pin, test, parent]); assert_tasks!(tasks, [pin, test, parent]);
tasks.set_view_depth(1); tasks.set_view_depth(1);
assert_tasks!(tasks, [pin, test]); assert_tasks!(tasks, [pin, test]);
tasks.add_tag("tag".to_string()); tasks.add_tag("tag");
assert_tasks!(tasks, [test]); assert_tasks!(tasks, [test]);
assert_eq!( assert_eq!(
tasks.filtered_tasks(None, true), tasks.filtered_tasks(None, true),
@ -2042,7 +2047,7 @@ mod tasks_test {
tasks.view_depth = 9; tasks.view_depth = 9;
assert_tasks!(tasks, [t111, t12]); assert_tasks!(tasks, [t111, t12]);
tasks.add_tag("tag".to_string()); tasks.add_tag("tag");
tasks.view_depth = 0; tasks.view_depth = 0;
assert_tasks!(tasks, [t11]); assert_tasks!(tasks, [t11]);
tasks.search_depth = 0; tasks.search_depth = 0;