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 /target
/examples /examples
/.idea
relays relays
keys keys
*.html *.html

View File

@ -1,7 +1,6 @@
use std::fmt::Display; use std::ops::Sub;
use std::io::{stdin, stdout, Write};
use chrono::{DateTime, Local, NaiveDateTime, TimeZone, Utc}; use chrono::{DateTime, Local, TimeDelta, TimeZone, Utc};
use chrono::LocalResult::Single; use chrono::LocalResult::Single;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::Timestamp; 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()) } if str.is_empty() { None } else { Some(str.to_string()) }
} }
pub fn prompt(prompt: &str) -> Option<String> { /// Parses the hour from a plain number in the String,
print!("{} ", prompt); /// with max of max_future hours into the future.
stdout().flush().unwrap(); pub fn parse_hour(str: &str, max_future: i64) -> Option<DateTime<Local>> {
match stdin().lines().next() { str.parse::<u32>().ok().and_then(|hour| {
Some(Ok(line)) => Some(line), let now = Local::now();
_ => None, #[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>> { 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 /// Format DateTime easily comprehensible for human but unambiguous.
pub const MAX_TIMESTAMP_WIDTH: u8 = 15; /// 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) => formatter(time),
_ => stamp.to_human_datetime(),
}
}
/// Format nostr Timestamp relative to local time /// Format nostr Timestamp relative to local time
/// with optional day specifier or full date depending on distance to today /// with optional day specifier or full date depending on distance to today.
pub fn relative_datetimestamp(stamp: &Timestamp) -> String { pub fn format_timestamp_relative(stamp: &Timestamp) -> String {
match Local.timestamp_opt(stamp.as_u64() as i64, 0) { format_as_datetime(stamp, format_datetime_relative)
Single(time) => { }
let date = time.date_naive();
let prefix = match Local::now() /// Format nostr timestamp with the given format.
.date_naive() pub fn format_timestamp(stamp: &Timestamp, format: &str) -> String {
.signed_duration_since(date) format_as_datetime(stamp, |time| time.format(format).to_string())
.num_days() }
{
-1 => "tomorrow ".into(), /// Format nostr timestamp in a sensible comprehensive format with consistent length and consistent sorting.
0 => "".into(), ///
1 => "yesterday ".into(), /// Currently: 18 characters
2..=6 => date.format("last %a ").to_string(), pub fn format_timestamp_local(stamp: &Timestamp) -> String {
_ => date.format("%y-%m-%d ").to_string(), format_timestamp(stamp, "%y-%m-%d %a %H:%M")
}; }
format!("{}{}", prefix, time.format("%H:%M"))
} pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
_ => stamp.to_human_datetime(), // 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),
} }
} }
/// 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 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(),
}
}

View File

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

View File

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