feat: make hashtags case-insensitive

This commit is contained in:
xeruf 2024-11-21 09:17:56 +01:00
parent 20fc8f9a3a
commit 0a7685d907
5 changed files with 128 additions and 42 deletions

89
src/hashtag.rs Normal file
View file

@ -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<Self> 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<Self, Self::Error> {
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<Ordering> {
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());
}

View file

@ -92,7 +92,7 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
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<Tag>) {
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<Tag>) {
(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![]));
}

View file

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

View file

@ -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<Item=&Tag> {
self.tags().filter(|t| is_hashtag(t))
pub(crate) fn list_hashtags(&self) -> impl Iterator<Item=Hashtag> + use<'_> {
self.tags().filter_map(|t| Hashtag::try_from(t).ok())
}
fn tags(&self) -> impl Iterator<Item=&Tag> {
@ -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))

View file

@ -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<Tag>,
tags: BTreeSet<Hashtag>,
/// Tags filtered out from view
tags_excluded: BTreeSet<Tag>,
tags_excluded: BTreeSet<Hashtag>,
/// 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<Item=&str> {
pub(crate) fn all_hashtags(&self) -> impl Iterator<Item=String> {
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<Item=Tag>) -> bool {
pub(crate) fn update_tags(&mut self, tags: impl IntoIterator<Item=Hashtag>) -> 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<Item=Tag>) {
fn set_tags(&mut self, tags: impl IntoIterator<Item=Hashtag>) {
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<Item=Tag> + 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);