Compare commits

...

7 Commits

Author SHA1 Message Date
xeruf f240413e2a style: various small cleanups 2024-08-21 12:31:29 +03:00
xeruf 999068bdd9 fix(main): pass own username to Tasks object 2024-08-21 12:30:13 +03:00
xeruf f7f4bdc4f3 refactor(main): merge local_tasks into relays map 2024-08-21 12:22:47 +03:00
xeruf ed1f482707 feat: revamp timestamp formatting helpers 2024-08-21 11:57:28 +03:00
xeruf 3dca6a4b23 fix(main): safer key persistence 2024-08-21 11:52:07 +03:00
xeruf 17b3334aea fix: prompt via readline 2024-08-21 11:05:43 +03:00
xeruf 77ba311bab feat(main): neatly interpret plain hour in date filter 2024-08-21 10:14:01 +03:00
5 changed files with 162 additions and 112 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
/target
/examples
/.idea
relays
keys
*.html

View File

@ -1,7 +1,6 @@
use std::fmt::Display;
use std::io::{stdin, stdout, Write};
use std::ops::Sub;
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc};
use chrono::{DateTime, Local, TimeDelta, TimeZone, Utc};
use chrono::LocalResult::Single;
use log::{debug, error, info, trace, warn};
use nostr_sdk::Timestamp;
@ -10,13 +9,20 @@ pub fn some_non_empty(str: &str) -> Option<String> {
if str.is_empty() { None } else { Some(str.to_string()) }
}
pub fn prompt(prompt: &str) -> Option<String> {
print!("{} ", prompt);
stdout().flush().unwrap();
match stdin().lines().next() {
Some(Ok(line)) => Some(line),
_ => None,
}
/// Parses the hour from a plain number in the String,
/// with max of max_future hours into the future.
pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
str.parse::<u32>().ok().and_then(|hour| {
let now = Local::now();
#[allow(deprecated)]
now.date().and_hms_opt(hour, 0, 0).map(|time| {
if time - now > TimeDelta::hours(max_future) {
time.sub(TimeDelta::days(1))
} else {
time
}
})
})
}
pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
@ -50,41 +56,60 @@ pub fn parse_tracking_stamp(str: &str) -> Option<Timestamp> {
})
}
// For use in format strings but not possible, so need global find-replace
pub const MAX_TIMESTAMP_WIDTH: u8 = 15;
/// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today
pub fn relative_datetimestamp(stamp: &Timestamp) -> String {
/// Format DateTime easily comprehensible for human but unambiguous.
/// Length may vary.
pub fn format_datetime_relative(time: DateTime<Local>) -> String {
let date = time.date_naive();
let prefix =
match Local::now()
.date_naive()
.signed_duration_since(date)
.num_days() {
-1 => "tomorrow ".into(),
0 => "".into(),
1 => "yesterday ".into(),
-3..=3 => date.format("%a ").to_string(),
//-10..=10 => date.format("%d. %a ").to_string(),
-100..=100 => date.format("%b %d ").to_string(),
_ => date.format("%y-%m-%d ").to_string(),
};
format!("{}{}", prefix, time.format("%H:%M"))
}
/// Format a nostr timestamp with the given formatting function.
pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where
F: Fn(DateTime<Local>) -> String,
{
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
Single(time) => {
let date = time.date_naive();
let prefix = match Local::now()
.date_naive()
.signed_duration_since(date)
.num_days()
{
-1 => "tomorrow ".into(),
0 => "".into(),
1 => "yesterday ".into(),
2..=6 => date.format("last %a ").to_string(),
_ => date.format("%y-%m-%d ").to_string(),
};
format!("{}{}", prefix, time.format("%H:%M"))
}
Single(time) => formatter(time),
_ => stamp.to_human_datetime(),
}
}
/// Format a nostr timestamp in a sensible comprehensive format
pub fn local_datetimestamp(stamp: &Timestamp) -> String {
format_stamp(stamp, "%y-%m-%d %a %H:%M")
/// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today.
pub fn format_timestamp_relative(stamp: &Timestamp) -> String {
format_as_datetime(stamp, format_datetime_relative)
}
/// Format a nostr timestamp with the given format
pub fn format_stamp(stamp: &Timestamp, format: &str) -> String {
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
Single(time) => time.format(format).to_string(),
_ => stamp.to_human_datetime(),
/// Format nostr timestamp with the given format.
pub fn format_timestamp(stamp: &Timestamp, format: &str) -> String {
format_as_datetime(stamp, |time| time.format(format).to_string())
}
/// Format nostr timestamp in a sensible comprehensive format with consistent length and consistent sorting.
///
/// Currently: 18 characters
pub fn format_timestamp_local(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M")
}
pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
// Rough difference in days
match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 {
0 => format_timestamp(stamp, "%H:%M"),
-3..=3 => format_timestamp(stamp, "%a %H:%M"),
_ => format_timestamp_local(stamp),
}
}
}

View File

@ -3,13 +3,14 @@ use std::collections::{HashMap, VecDeque};
use std::env::{args, var};
use std::fs;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::io::{BufRead, BufReader, Write};
use std::iter::once;
use std::ops::Sub;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::Duration;
use chrono::Local;
use colored::Colorize;
use env_logger::{Builder, Target, WriteStyle};
use itertools::Itertools;
@ -27,7 +28,7 @@ use xdg::BaseDirectories;
use crate::helpers::*;
use crate::kinds::{KINDS, PROP_KINDS, PROPERTY_COLUMNS, TRACKING_KIND};
use crate::task::{MARKER_DEPENDS, MARKER_PARENT, State};
use crate::task::{MARKER_DEPENDS, State};
use crate::tasks::{PropertyCollection, StateFilter, Tasks};
mod helpers;
@ -37,6 +38,7 @@ mod kinds;
const UNDO_DELAY: u64 = 60;
const INACTVITY_DELAY: u64 = 200;
const LOCAL_RELAY_NAME: &str = "TEMP";
/// Turn a Result into an Option, showing a warning on error with optional prefix
macro_rules! or_warn {
@ -139,7 +141,8 @@ pub(crate) enum MostrMessage {
}
#[tokio::main]
async fn main() {
async fn main() -> Result<()> {
// TODO preserve prompt lines
let mut rl = Editor::new();
let mut args = args().skip(1).peekable();
@ -153,7 +156,7 @@ async fn main() {
} else {
let mut builder = colog::default_builder();
builder.filter(Some("nostr-relay-pool"), LevelFilter::Error);
//.filter(Some("nostr-relay-pool::relay::internal"), LevelFilter::Off)
//.filter(Some("nostr-relay-pool::relay::internal"), LevelFilter::Off)
builder
}.write_style(WriteStyle::Always).target(Target::Pipe(Box::new(rl.get_printer()))).init();
@ -163,19 +166,31 @@ async fn main() {
let keysfile = config_dir.join("key");
let relayfile = config_dir.join("relays");
let keys = match fs::read_to_string(&keysfile).map(|s| Keys::from_str(&s)) {
Ok(Ok(key)) => key,
_ => {
warn!("Could not read keys from {}", keysfile.to_string_lossy());
let keys = prompt("Secret key?")
.and_then(|s| or_warn!(Keys::from_str(&s)))
.unwrap_or_else(|| {
info!("Generating and persisting new key");
Keys::generate()
});
or_warn!(fs::write(&keysfile, keys.secret_key().unwrap().to_string()));
keys
}
let keys = if let Ok(Ok(key)) = fs::read_to_string(&keysfile).map(|s| Keys::from_str(&s)) {
key
} else {
warn!("Could not read keys from {}", keysfile.to_string_lossy());
let line = rl.readline("Secret key? (leave blank to generate and save a new keypair) ")?;
let keys = if line.is_empty() {
info!("Generating and persisting new key");
Keys::generate()
} else {
Keys::from_str(&line).inspect_err(|_| eprintln!())?
};
let mut file = match File::create_new(&keysfile) {
Ok(file) => file,
Err(e) => {
let line = rl.readline(&format!("Overwrite {}? (enter anything to abort) ", keysfile.to_string_lossy()))?;
if line.is_empty() {
File::create(&keysfile)?
} else {
eprintln!();
Err(e)?
}
}
};
file.write_all(keys.secret_key().unwrap().to_string().as_bytes())?;
keys
};
let client = Client::new(&keys);
@ -194,7 +209,7 @@ async fn main() {
}
Err(e) => {
warn!("Could not read relays file: {}", e);
if let Some(line) = prompt("Relay?") {
if let Ok(line) = rl.readline("Relay? ") {
let url = if line.contains("://") {
line
} else {
@ -223,18 +238,20 @@ async fn main() {
], None).await;
info!("Subscribed to updates with {:?}", sub2);
let metadata = var("USER").ok().map(
|user| Metadata::new().name(user));
let myMeta = metadata.clone();
let (tx, mut rx) = mpsc::channel::<MostrMessage>(64);
let tasks_for_url = |url: Option<Url>| Tasks::from(url, &tx, &keys);
let mut relays: HashMap<Url, Tasks> =
client.relays().await.into_keys().map(|url| (url.clone(), tasks_for_url(Some(url)))).collect();
let tasks_for_url = |url: Option<Url>| Tasks::from(url, &tx, &keys, metadata.clone());
let mut relays: HashMap<Option<Url>, Tasks> =
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 Ok(user) = var("USER") {
let metadata = Metadata::new()
.name(user);
or_warn!(client.set_metadata(&metadata).await);
if let Some(meta) = myMeta.as_ref() {
or_warn!(client.set_metadata(meta).await, "Unable to set metadata");
}
loop {
@ -286,11 +303,15 @@ async fn main() {
info!("Shutting down nostr communication thread");
});
let mut local_tasks = Tasks::from(None, &tx, &keys);
let mut selected_relay: Option<Url> = relays.keys().nth(0).cloned();
if relays.is_empty() {
relays.insert(None, tasks_for_url(None));
}
let mut selected_relay: Option<Url> = relays.keys()
.find_or_first(|url| url.as_ref().is_some_and(|u| u.scheme() == "wss"))
.unwrap().clone();
{
let tasks = selected_relay.as_ref().and_then(|url| relays.get_mut(&url)).unwrap_or_else(|| &mut local_tasks);
let tasks = relays.get_mut(&selected_relay).unwrap();
for argument in args {
tasks.make_task(&argument);
}
@ -298,12 +319,14 @@ async fn main() {
loop {
trace!("All Root Tasks:\n{}", relays.iter().map(|(url, tasks)|
format!("{}: [{}]", url, tasks.children_of(None).map(|id| tasks.get_task_title(id)).join("; "))).join("\n"));
format!("{}: [{}]",
url.as_ref().map(ToString::to_string).unwrap_or(LOCAL_RELAY_NAME.to_string()),
tasks.children_of(None).map(|id| tasks.get_task_title(id)).join("; "))).join("\n"));
println!();
let tasks = selected_relay.as_ref().and_then(|url| relays.get(url)).unwrap_or(&local_tasks);
let tasks = relays.get(&selected_relay).unwrap();
let prompt = format!(
"{} {}{}) ",
selected_relay.as_ref().map_or("TEMP".to_string(), |url| url.to_string()).bright_black(),
selected_relay.as_ref().map_or(LOCAL_RELAY_NAME.to_string(), |url| url.to_string()).bright_black(),
tasks.get_task_path(tasks.get_position()).bold(),
tasks.get_prompt_suffix().italic(),
);
@ -321,7 +344,7 @@ async fn main() {
"At {} found {} kind {} content \"{}\" tags {:?}",
event.created_at, event.id, event.kind, event.content, event.tags.iter().map(|tag| tag.as_vec()).collect_vec()
);
match relays.get_mut(&relay_url) {
match relays.get_mut(&Some(relay_url.clone())) {
Some(tasks) => tasks.add(*event),
None => warn!("Event received from unknown relay {relay_url}: {:?}", *event)
}
@ -340,7 +363,7 @@ async fn main() {
None
};
let arg_default = arg.unwrap_or("");
let tasks = selected_relay.as_ref().and_then(|url| relays.get_mut(&url)).unwrap_or_else(|| &mut local_tasks);
let tasks = relays.get_mut(&selected_relay).unwrap();
match op {
None => {
debug!("Flushing Tasks because of empty command");
@ -378,7 +401,7 @@ async fn main() {
None => {
tasks.get_current_task().map_or_else(
|| info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes"),
|task| println!("{}", task.description_events().map(|e| format!("{} {}", local_datetimestamp(&e.created_at), e.content)).join("\n")),
|task| println!("{}", task.description_events().map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content)).join("\n")),
);
continue;
}
@ -403,7 +426,7 @@ async fn main() {
match arg {
None => {
let today = Timestamp::from(Timestamp::now() - 80_000);
info!("Filtering for tasks from the last 22 hours");
info!("Filtering for tasks created in the last 22 hours");
tasks.set_filter(
tasks.filtered_tasks(tasks.get_position_ref())
.filter(|t| t.event.created_at > today)
@ -431,15 +454,18 @@ async fn main() {
.collect()
)
} else {
parse_date(arg).map(|time| {
info!("Filtering for tasks from {}", time); // TODO localize
tasks.set_filter(
tasks.filtered_tasks(tasks.get_position_ref())
.filter(|t| t.event.created_at.as_u64() as i64 > time.timestamp())
.map(|t| t.event.id)
.collect()
);
});
parse_hour(arg, 1)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
.map(|time| {
info!("Filtering for tasks created after {}", format_datetime_relative(time));
let threshold = time.to_utc().timestamp();
tasks.set_filter(
tasks.filtered_tasks(tasks.get_position_ref())
.filter(|t| t.event.created_at.as_u64() as i64 > threshold)
.map(|t| t.event.id)
.collect()
);
});
}
}
}
@ -520,6 +546,7 @@ async fn main() {
let (label, times) = tasks.times_tracked();
println!("{}\n{}", label.italic(), times.rev().take(15).join("\n"));
}
// TODO show history from author / pubkey
} else {
let (label, mut times) = tasks.times_tracked();
println!("{}\n{}", label.italic(), times.join("\n"));
@ -533,7 +560,7 @@ async fn main() {
Some(arg) => {
if parse_tracking_stamp(arg).map(|stamp| tasks.track_at(stamp, None)).is_none() {
// So the error message is not covered up
continue
continue;
}
}
}
@ -591,9 +618,9 @@ async fn main() {
let filtered = tasks.filtered_tasks(pos)
.filter(|t| {
transform(&t.event.content).contains(slice) || t.tags.iter().flatten().any(|tag|
tag.content().is_some_and(|s| transform(s).contains(slice))
)
transform(&t.event.content).contains(slice) ||
t.tags.iter().flatten().any(
|tag| tag.content().is_some_and(|s| transform(s).contains(slice)))
})
.map(|t| t.event.id)
.collect_vec();
@ -609,8 +636,8 @@ async fn main() {
_ =>
if Regex::new("^wss?://").unwrap().is_match(&input.trim()) {
tasks.move_to(None);
if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_str().starts_with(&input)) {
selected_relay = Some(url.clone());
if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_ref().is_some_and(|url| url.as_str().starts_with(&input))) {
selected_relay = url.clone();
or_warn!(tasks.print_tasks());
continue;
}
@ -620,7 +647,7 @@ async fn main() {
Ok(_) => {
info!("Connecting to {url}");
selected_relay = Some(url.clone());
relays.insert(url.clone(), tasks_for_url(Some(url)));
relays.insert(selected_relay.clone(), tasks_for_url(selected_relay.clone()));
}
}
});
@ -639,9 +666,10 @@ async fn main() {
println!();
drop(tx);
drop(local_tasks);
drop(relays);
info!("Submitting pending updates...");
or_warn!(sender.await);
Ok(())
}

View File

@ -10,7 +10,7 @@ use itertools::Itertools;
use log::{debug, error, info, trace, warn};
use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
use crate::helpers::{local_datetimestamp, some_non_empty};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{is_hashtag, TASK_KIND};
pub static MARKER_PARENT: &str = "parent";
@ -156,7 +156,7 @@ impl Task {
"parentid" => self.parent_id().map(|i| i.to_string()),
"name" => Some(self.event.content.clone()),
"pubkey" => Some(self.event.pubkey.to_string()),
"created" => Some(local_datetimestamp(&self.event.created_at)),
"created" => Some(format_timestamp_local(&self.event.created_at)),
"kind" => Some(self.event.kind.to_string()),
// Dynamic
"status" => self.state_label().map(|c| c.to_string()),

View File

@ -6,7 +6,6 @@ use std::ops::{Div, Rem};
use std::str::FromStr;
use std::time::Duration;
use chrono::Local;
use colored::Colorize;
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
@ -15,7 +14,7 @@ use nostr_sdk::prelude::Marker;
use TagStandard::Hashtag;
use crate::{EventSender, MostrMessage};
use crate::helpers::{format_stamp, local_datetimestamp, parse_tracking_stamp, relative_datetimestamp, some_non_empty};
use crate::helpers::{format_timestamp_local, format_timestamp_relative, format_timestamp_relative_to, parse_tracking_stamp, some_non_empty};
use crate::kinds::*;
use crate::task::{MARKER_DEPENDS, MARKER_PARENT, State, Task, TaskState};
@ -104,13 +103,15 @@ impl Display for StateFilter {
}
impl Tasks {
pub(crate) fn from(url: Option<Url>, tx: &tokio::sync::mpsc::Sender<MostrMessage>, keys: &Keys) -> Self {
Self::with_sender(EventSender {
pub(crate) fn from(url: Option<Url>, tx: &tokio::sync::mpsc::Sender<MostrMessage>, keys: &Keys, metadata: Option<Metadata>) -> Self {
let mut new = Self::with_sender(EventSender {
url,
tx: tx.clone(),
keys: keys.clone(),
queue: Default::default(),
})
});
metadata.map(|m| new.users.insert(keys.public_key(), m));
new
}
pub(crate) fn with_sender(sender: EventSender) -> Self {
@ -189,7 +190,7 @@ impl Tasks {
.join(" "));
if new != last {
// TODO alternate color with grey between days
full.push(format!("{:>15} {}", relative_datetimestamp(&event.created_at), new.as_ref().unwrap_or(&"---".to_string())));
full.push(format!("{} {}", format_timestamp_local(&event.created_at), new.as_ref().unwrap_or(&"---".to_string())));
last = new;
}
}
@ -206,18 +207,13 @@ impl Tasks {
let mut iter = timestamps(set.iter(), &ids).tuples();
while let Some(((start, _), (end, _))) = iter.next() {
vec.push(format!("{} - {} by {}",
local_datetimestamp(start),
// Only use full stamp when ambiguous (>1day)
if end.as_u64() - start.as_u64() > 80_000 {
local_datetimestamp(end)
} else {
format_stamp(end, "%H:%M")
},
format_timestamp_local(start),
format_timestamp_relative_to(end, start),
self.get_author(key)))
}
iter.into_buffer()
.for_each(|(stamp, _)|
vec.push(format!("{} started by {}", local_datetimestamp(stamp), self.get_author(key))));
vec.push(format!("{} started by {}", format_timestamp_local(stamp), self.get_author(key))));
vec
}).sorted_unstable(); // TODO sorting depends on timestamp format - needed to interleave different people
(format!("Times Tracked on {:?}", self.get_task_title(&id)), Box::from(history))
@ -400,7 +396,7 @@ impl Tasks {
let mut tracking_stamp: Option<Timestamp> = None;
for elem in
timestamps(self.history.get(&self.sender.pubkey()).into_iter().flatten(), &vec![t.get_id()])
.map(|(e, o)| e) {
.map(|(e, _)| e) {
if tracking_stamp.is_some() && elem > now {
break;
}
@ -409,10 +405,10 @@ impl Tasks {
writeln!(
lock,
"Tracking since {} (total tracked time {}m) - {} since {}",
tracking_stamp.map_or("?".to_string(), |t| relative_datetimestamp(&t)),
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
self.time_tracked(*t.get_id()) / 60,
state.get_label(),
relative_datetimestamp(&state.time)
format_timestamp_relative(&state.time)
)?;
writeln!(lock, "{}", t.descriptions().join("\n"))?;
}
@ -736,7 +732,7 @@ impl Tasks {
}
pub(crate) fn track_at(&mut self, time: Timestamp, task: Option<EventId>) -> EventId {
info!("{} from {}", task.map_or(String::from("Stopping time-tracking"), |id| format!("Tracking \"{}\"", self.get_task_title(&id))), relative_datetimestamp(&time));
info!("{} from {}", task.map_or(String::from("Stopping time-tracking"), |id| format!("Tracking \"{}\"", self.get_task_title(&id))), format_timestamp_relative(&time));
self.submit(
build_tracking(task)
.custom_created_at(time)