Compare commits

...

30 commits

Author SHA1 Message Date
xeruf
e95a14ae89 release: 0.7.0 - task reassignment, user interaction and more helpful feedback 2024-11-25 02:33:32 +01:00
xeruf
a3eeb38e5f fix(tasks): fallback upon invalid regex 2024-11-25 02:30:56 +01:00
xeruf
3a4588b45d feat: display task owner 2024-11-25 02:29:23 +01:00
xeruf
6ef5c47e98 feat: assign users to task 2024-11-25 02:15:18 +01:00
xeruf
87392fccb6 feat: guidance for moving backward 2024-11-25 01:45:18 +01:00
xeruf
78438696ac refactor: create own struct for nostr relay users 2024-11-24 23:42:47 +01:00
xeruf
654f273ad9 fix(tasks): fix pubkey filtering to show all activities instead of all tasks 2024-11-24 23:14:35 +01:00
xeruf
cb15fbaac5 enhance(tasks): feedback about ask movement with custom time 2024-11-24 23:11:19 +01:00
xeruf
a8fb3f919d enhance(tasks): improve state labels 2024-11-24 08:51:54 +01:00
xeruf
044c853993 enhance(tasks): show displayname rather than username where appropriate 2024-11-24 08:47:57 +01:00
xeruf
b26d64646c fix(main): do not create empty notes 2024-11-23 12:11:31 +01:00
xeruf
7ecfa6e810 feat(task): get state at specific time 2024-11-23 08:47:54 +01:00
xeruf
fe0b59ef65 feat(main): use whoami crate to get full name, also on windows 2024-11-22 13:37:19 +01:00
xeruf
031d9a3b69 release: 0.6.3 - enhance quick access 2024-11-22 11:24:40 +01:00
xeruf
58117b901a style: clean up code formatting and add notes 2024-11-22 11:22:28 +01:00
xeruf
29476e60ad fix: flush more liberally 2024-11-22 11:21:04 +01:00
xeruf
1a7b65fe1c fix(tasks): priority filtering for quick access with exhaustive tests 2024-11-22 11:20:13 +01:00
xeruf
94976905d3 fix(tasks): properly test quick access bookmarks and view 2024-11-22 10:06:50 +01:00
xeruf
0cf354942e feat(tasks): show activities to everyone 2024-11-22 09:29:50 +01:00
xeruf
a6b611312b fix(tasks): correct task hints when showing sole details 2024-11-22 09:28:46 +01:00
xeruf
bd32e61212 refactor: revamp visible task algorithm 2024-11-22 00:04:19 +01:00
xeruf
5cd82e8581 enhance: display more accurate time tracking prefixes 2024-11-21 23:59:30 +01:00
xeruf
eea8511a6e feat: enable finding user by partial key and name 2024-11-21 21:18:26 +01:00
xeruf
5032b4db93 refactor(tasks): omit empty descriptions
preparation for state update notes
2024-11-21 10:56:52 +01:00
xeruf
fc97b513c4 release: 0.6.2 - case-insensitive hashtags and pubkey filtering 2024-11-21 09:48:04 +01:00
xeruf
9c92a19cde fix: make hashtag behaviour more consistent 2024-11-21 09:47:14 +01:00
xeruf
0a7685d907 feat: make hashtags case-insensitive 2024-11-21 09:17:56 +01:00
xeruf
20fc8f9a3a refactor: rename set_filter_from to since 2024-11-20 23:28:06 +01:00
xeruf
1f13c45831 feat: easy reset to own pubkey filter 2024-11-20 23:28:06 +01:00
xeruf
e320523fc0 feat: enable setting pubkey as context and auto-filter for own 2024-11-20 23:28:06 +01:00
10 changed files with 713 additions and 362 deletions

65
Cargo.lock generated
View file

@ -340,7 +340,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f"
dependencies = [
"bitcoin-internals 0.3.0",
"bitcoin-internals",
"bitcoin_hashes 0.14.0",
]
@ -393,22 +393,16 @@ checksum = "788902099d47c8682efe6a7afb01c8d58b9794ba66c06affd81c3d6b560743eb"
dependencies = [
"base58ck",
"bech32",
"bitcoin-internals 0.3.0",
"bitcoin-internals",
"bitcoin-io",
"bitcoin-units",
"bitcoin_hashes 0.14.0",
"hex-conservative 0.2.1",
"hex-conservative",
"hex_lit",
"secp256k1",
"serde",
]
[[package]]
name = "bitcoin-internals"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
[[package]]
name = "bitcoin-internals"
version = "0.3.0"
@ -430,7 +424,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2"
dependencies = [
"bitcoin-internals 0.3.0",
"bitcoin-internals",
"serde",
]
@ -440,16 +434,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90064b8dee6815a6470d60bad07bbbaee885c0e12d04177138fa3291a01b7bc4"
[[package]]
name = "bitcoin_hashes"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
dependencies = [
"bitcoin-internals 0.2.0",
"hex-conservative 0.1.2",
]
[[package]]
name = "bitcoin_hashes"
version = "0.14.0"
@ -457,7 +441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16"
dependencies = [
"bitcoin-io",
"hex-conservative 0.2.1",
"hex-conservative",
"serde",
]
@ -1130,12 +1114,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hex-conservative"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20"
[[package]]
name = "hex-conservative"
version = "0.2.1"
@ -1488,7 +1466,7 @@ dependencies = [
[[package]]
name = "mostr"
version = "0.6.1"
version = "0.7.0"
dependencies = [
"chrono",
"chrono-english",
@ -1506,6 +1484,7 @@ dependencies = [
"regex",
"rustyline",
"tokio",
"whoami",
]
[[package]]
@ -1962,6 +1941,15 @@ version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
[[package]]
name = "redox_syscall"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f"
dependencies = [
"bitflags 2.6.0",
]
[[package]]
name = "redox_users"
version = "0.3.5"
@ -1969,7 +1957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
dependencies = [
"getrandom 0.1.16",
"redox_syscall",
"redox_syscall 0.1.57",
"rust-argon2",
]
@ -2150,7 +2138,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
"bitcoin_hashes 0.13.0",
"bitcoin_hashes 0.14.0",
"rand",
"secp256k1-sys",
"serde",
@ -2684,6 +2672,12 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.93"
@ -2770,6 +2764,17 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d"
dependencies = [
"redox_syscall 0.5.7",
"wasite",
"web-sys",
]
[[package]]
name = "winapi"
version = "0.3.9"

View file

@ -5,7 +5,7 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
readme = "README.md"
license = "GPL 3.0"
authors = ["melonion"]
version = "0.6.1"
version = "0.7.0"
rust-version = "1.82"
edition = "2021"
default-run = "mostr"
@ -25,6 +25,7 @@ rustyline = { git = "https://github.com/xeruf/rustyline", rev = "465b14d" }
# OS-Specific Abstractions
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] }
directories = "5.0"
whoami = "1.5"
# Application Utils
itertools = "0.12"
chrono = "0.4"

View file

@ -65,7 +65,7 @@ impl EventSender {
})?)
}
/// Sends all pending events
fn force_flush(&self) {
pub(crate) fn force_flush(&self) {
debug!("Flushing {} events from queue", self.queue.borrow().len());
let values = self.clear();
self.url.as_ref().map(|url| {

96
src/hashtag.rs Normal file
View file

@ -0,0 +1,96 @@
use nostr_sdk::{Alphabet, Tag};
use std::cmp::Ordering;
use std::fmt::{Display, Formatter};
use std::hash::{Hash, Hasher};
pub fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.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)]
pub struct Hashtag {
value: String,
lowercased: String,
}
impl Hashtag {
pub fn matches(&self, token: &str) -> bool {
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 PartialEq<Self> for Hashtag {
fn eq(&self, other: &Self) -> bool {
self.lowercased == other.lowercased
}
}
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::from(s))
.ok_or_else(|| "Tag is not a Hashtag".to_string())
}
}
impl From<&str> for Hashtag {
fn from(value: &str) -> Self {
let val = value.trim().to_string();
Hashtag {
lowercased: val.to_ascii_lowercase(),
value: val,
}
}
}
impl From<&Hashtag> for Tag {
fn from(value: &Hashtag) -> Self {
Tag::hashtag(&value.lowercased)
}
}
impl Ord for Hashtag {
fn cmp(&self, other: &Self) -> Ordering {
self.lowercased.cmp(&other.lowercased)
// 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.lowercased.cmp(&other.lowercased))
}
}
#[test]
fn test_hashtag() {
assert_eq!("yeah", "YeaH".to_ascii_lowercase());
assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal);
use itertools::Itertools;
let strings = vec!["yeah", "YeaH"];
let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec();
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
tags.sort_unstable();
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
}

View file

@ -36,6 +36,7 @@ impl<T: TimeZone> ToTimestamp for DateTime<T> {
/// Parses the hour from a plain number in the String,
/// with max of max_future hours into the future.
/// TODO parse HHMM as well
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
str.parse::<u32>().ok().and_then(|hour| {
let now = Local::now();

View file

@ -1,7 +1,8 @@
use crate::task::MARKER_PARENT;
use crate::tasks::nostr_users::NostrUsers;
use crate::tasks::HIGH_PRIO;
use itertools::Itertools;
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
use nostr_sdk::{EventBuilder, EventId, Kind, PublicKey, Tag, TagKind, TagStandard};
use std::borrow::Cow;
pub const TASK_KIND: Kind = Kind::GitIssue;
@ -45,6 +46,7 @@ Task:
- `time` - time tracked on this task by you
Utilities:
- `state` - indicator of current progress
- `owner` - author or task assignee
- `rtime` - time tracked on this tasks and its subtree by everyone
- `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete
@ -91,44 +93,49 @@ 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
/// as well as various embedded tags.
///
/// 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 mut prio = None;
let mut tags = Vec::with_capacity(4);
let result = words.filter(|s| {
if s.starts_with('*') {
if s.len() == 1 {
prio = Some(HIGH_PRIO);
if s.starts_with('@') {
if let Ok(key) = PublicKey::parse(&s[1..]) {
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 match s[1..].parse::<Prio>() {
Ok(num) => {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
false
}
_ => true,
};
} else if s.starts_with('*') {
if s.len() == 1 {
tags.push(to_prio_tag(HIGH_PRIO));
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
}).collect_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(prio.map(|p| to_prio_tag(p)))
.chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(tags)
.collect_vec();
tags.sort();
tags.dedup();
(main, tags)
}
pub fn to_hashtag(tag: &str) -> Tag {
pub fn to_hashtag_tag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into()
}
@ -154,21 +161,17 @@ 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()])
}
#[test]
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(),
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()));
assert_eq!(extract_tags("So tagless #"),
("So tagless".to_string(), vec![]));
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag))
.collect()));
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::kinds::{format_tag_basic, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS};
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 colored::Colorize;
use directories::ProjectDirs;
@ -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";
@ -199,21 +200,20 @@ async fn main() -> Result<()> {
}
}
let metadata = var("USER").ok().map(
|user| Metadata::new().name(user));
let moved_metadata = metadata.clone();
let metadata = Metadata::new()
.name(whoami::username())
.display_name(whoami::realname());
let metadata_clone = metadata.clone();
let (tx, mut rx) = mpsc::channel::<MostrMessage>(64);
let tasks_for_url = |url: Option<Url>| TasksRelay::from(url, &tx, &keys, metadata.clone());
let tasks_for_url = |url: Option<Url>| TasksRelay::from(url, &tx, &keys, Some(metadata.clone()));
let mut relays: HashMap<Option<Url>, TasksRelay> =
client.relays().await.into_keys().map(|url| (Some(url.clone()), tasks_for_url(Some(url)))).collect();
let sender = tokio::spawn(async move {
let mut queue: Option<(Url, Vec<Event>)> = None;
if let Some(meta) = moved_metadata.as_ref() {
or_warn!(client.set_metadata(meta).await, "Unable to set metadata");
}
or_warn!(client.set_metadata(&metadata_clone).await, "Unable to set metadata");
'repl: loop {
let result_received = timeout(Duration::from_secs(INACTVITY_DELAY), rx.recv()).await;
@ -385,7 +385,7 @@ async fn main() -> Result<()> {
match arg {
None => {
if let Some(task) = tasks.get_current_task() {
println!("Change History:");
println!("Change History for {}:", task.get_id());
for e in once(&task.event).chain(task.props.iter().rev()) {
println!("{} {} [{}]",
format_timestamp_full(&e.created_at),
@ -417,8 +417,8 @@ async fn main() -> Result<()> {
}
}
Some(arg) => {
if arg.len() < CHARACTER_THRESHOLD {
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
if arg.trim().len() < 2 {
warn!("Needs at least 2 characters!");
continue 'repl;
}
tasks.make_note(arg);
@ -438,14 +438,36 @@ async fn main() -> Result<()> {
Some('&') => {
match arg {
None => tasks.undo(),
Some(text) => match text.parse::<u8>() {
Ok(int) => {
tasks.move_back_by(int as usize);
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;
}
_ => {
if !tasks.move_back_to(text) {
warn!("Did not find a match in history for \"{text}\"");
continue 'repl;
match text.parse::<u8>() {
Ok(int) => {
tasks.move_back_by(int as usize);
}
_ => {
if !tasks.move_back_to(text) {
warn!("Did not find a match in history for \"{text}\"");
continue 'repl;
}
}
}
}
@ -453,37 +475,33 @@ async fn main() -> Result<()> {
}
Some('@') => {
let success = match arg {
match arg {
None => {
let today = Timestamp::now() - 80_000;
info!("Filtering for tasks from the last 22 hours");
tasks.set_filter_from(today)
if !tasks.set_filter_since(today) {
continue 'repl;
}
}
Some(arg) => {
if arg == "@" {
info!("Filtering for own tasks");
tasks.set_filter_author(keys.public_key())
} else if let Ok(key) = PublicKey::from_str(arg) {
let author = tasks.get_username(&key);
info!("Filtering for tasks by {author}");
tasks.set_filter_author(key)
} else if let Some((key, meta)) = tasks.find_user(arg) {
info!("Filtering for tasks by {}", meta.display_name.as_ref().unwrap_or(meta.name.as_ref().unwrap_or(&key.to_string())));
tasks.set_filter_author(key.clone())
tasks.reset_key_filter()
} else if let Some((key, name)) = tasks.find_user(arg) {
info!("Showing {}'s tasks", name);
tasks.set_key_filter(key)
} else {
parse_hour(arg, 1)
if parse_hour(arg, 1)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
.map(|time| {
info!("Filtering for tasks from {}", format_datetime_relative(time));
tasks.set_filter_from(time.to_timestamp())
tasks.set_filter_since(time.to_timestamp())
})
.unwrap_or(false)
.is_none_or(|b| !b) {
continue 'repl;
}
}
}
};
if !success {
continue 'repl;
}
}
Some('*') => {
@ -577,14 +595,14 @@ 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;
}
}
Some('+') =>
match arg {
Some(arg) => tasks.add_tag(arg.to_string()),
Some(arg) => tasks.add_tag(arg),
None => {
tasks.print_hashtags();
if tasks.has_tag_filter() {
@ -622,7 +640,7 @@ async fn main() -> Result<()> {
label)
},
vec.iter().rev().join("\n"));
} else if let Ok(key) = PublicKey::parse(arg) { // TODO also match name
} else if let Some((key, _)) = tasks.find_user(arg) {
let (label, mut times) = tasks.times_tracked_for(&key);
println!("{}\n{}", label.italic(),
times.join("\n"));
@ -709,8 +727,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.matches(&remaining))
});
if filtered.len() == 1 {
tasks.move_to(filtered.into_iter().next());

View file

@ -4,16 +4,17 @@ use std::collections::BTreeSet;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::iter::once;
use std::str::FromStr;
use std::string::ToString;
use colored::{ColoredString, Colorize};
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 nostr_sdk::{Alphabet, Event, EventId, Kind, PublicKey, SingleLetterTag, Tag, TagKind, 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";
@ -70,6 +71,15 @@ impl Task {
&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> {
self.refs.iter().filter_map(move |(str, id)| Some(id).filter(|_| str == marker))
}
@ -98,7 +108,8 @@ impl Task {
/// Description items, ordered newest to oldest
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content)
self.description_events()
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty()))
}
pub(crate) fn is_task_kind(&self) -> bool {
@ -134,16 +145,15 @@ impl Task {
})
}
pub(crate) fn last_state_update(&self) -> Timestamp {
pub fn last_state_update(&self) -> Timestamp {
self.state().map(|s| s.time).unwrap_or(self.event.created_at)
}
pub(crate) fn state(&self) -> Option<TaskState> {
let now = now();
pub fn state_at(&self, time: Timestamp) -> Option<TaskState> {
// TODO do not iterate constructed state objects
let state = self.states().take_while_inclusive(|ts| ts.time > now);
let state = self.states().take_while_inclusive(|ts| ts.time > time);
state.last().map(|ts| {
if ts.time <= now {
if ts.time <= time {
ts
} else {
self.default_state()
@ -151,6 +161,12 @@ impl Task {
})
}
/// Returns the current state if this is a task rather than an activity
pub fn state(&self) -> Option<TaskState> {
let now = now();
self.state_at(now)
}
pub(crate) fn pure_state(&self) -> State {
self.state().map_or(State::Open, |s| s.state)
}
@ -174,15 +190,16 @@ 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> + '_ {
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> {
self.tags.iter().flatten().chain(
self.props.iter().flat_map(|e| e.tags.iter()
.filter(|t| t.single_letter_tag().is_none_or(|s| s.character != Alphabet::E)))
)
self.props.iter()
.flat_map(|e| e.tags.iter()
.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
@ -222,10 +239,7 @@ impl Task {
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content))
.collect_vec()
)),
"descriptions" => Some(format!(
"{:?}",
self.descriptions().collect_vec()
)),
"descriptions" => Some(format!("{:?}", self.descriptions().collect_vec())),
_ => {
warn!("Unknown task property {}", property);
None
@ -234,6 +248,7 @@ impl Task {
}
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub(crate) struct TaskState {
pub(crate) state: State,
name: Option<String>,
@ -269,7 +284,7 @@ impl Display for TaskState {
}
}
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
pub(crate) enum State {
/// Actionable
Open = 1630,
@ -357,21 +372,34 @@ 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);
let now = Timestamp::now();
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.custom_created_at(now)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
task.props.insert(
EventBuilder::new(State::Open.into(), "").tags([Tag::hashtag("tag2")])
.custom_created_at(Timestamp::from(Timestamp::now() - 2))
EventBuilder::new(State::Open.into(), "Ready").tags([Tag::hashtag("tag2")])
.custom_created_at(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))
.custom_created_at(now + 9)
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Closed);
assert_eq!(task.state_at(now), Some(TaskState {
state: State::Done,
name: None,
time: now,
}));
assert_eq!(task.state_at(now - 1), Some(TaskState {
state: State::Open,
name: Some("Ready".to_string()),
time: now - 2,
}));
}
}

File diff suppressed because it is too large Load diff

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)]));
}