forked from janek/mostr
feat: make hashtags case-insensitive
This commit is contained in:
parent
20fc8f9a3a
commit
0a7685d907
5 changed files with 128 additions and 42 deletions
89
src/hashtag.rs
Normal file
89
src/hashtag.rs
Normal 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());
|
||||
}
|
13
src/kinds.rs
13
src/kinds.rs
|
@ -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![]));
|
||||
}
|
|
@ -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());
|
||||
|
|
12
src/task.rs
12
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<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))
|
||||
|
|
47
src/tasks.rs
47
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<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);
|
||||
|
|
Loading…
Add table
Reference in a new issue