Compare commits
30 commits
b87970d4e2
...
e95a14ae89
Author | SHA1 | Date | |
---|---|---|---|
|
e95a14ae89 | ||
|
a3eeb38e5f | ||
|
3a4588b45d | ||
|
6ef5c47e98 | ||
|
87392fccb6 | ||
|
78438696ac | ||
|
654f273ad9 | ||
|
cb15fbaac5 | ||
|
a8fb3f919d | ||
|
044c853993 | ||
|
b26d64646c | ||
|
7ecfa6e810 | ||
|
fe0b59ef65 | ||
|
031d9a3b69 | ||
|
58117b901a | ||
|
29476e60ad | ||
|
1a7b65fe1c | ||
|
94976905d3 | ||
|
0cf354942e | ||
|
a6b611312b | ||
|
bd32e61212 | ||
|
5cd82e8581 | ||
|
eea8511a6e | ||
|
5032b4db93 | ||
|
fc97b513c4 | ||
|
9c92a19cde | ||
|
0a7685d907 | ||
|
20fc8f9a3a | ||
|
1f13c45831 | ||
|
e320523fc0 |
10 changed files with 713 additions and 362 deletions
65
Cargo.lock
generated
65
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
96
src/hashtag.rs
Normal 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());
|
||||
}
|
|
@ -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();
|
||||
|
|
55
src/kinds.rs
55
src/kinds.rs
|
@ -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![]));
|
||||
}
|
100
src/main.rs
100
src/main.rs
|
@ -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());
|
||||
|
|
80
src/task.rs
80
src/task.rs
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
610
src/tasks.rs
610
src/tasks.rs
File diff suppressed because it is too large
Load diff
63
src/tasks/nostr_users.rs
Normal file
63
src/tasks/nostr_users.rs
Normal 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)]));
|
||||
}
|
Loading…
Add table
Reference in a new issue