Compare commits
10 Commits
01ece3b2af
...
9eaf10006b
Author | SHA1 | Date |
---|---|---|
xeruf | 9eaf10006b | |
xeruf | 6492a22cc9 | |
xeruf | 13dac88ded | |
xeruf | 1263e39fb3 | |
xeruf | 714d4a4d5b | |
xeruf | 1d7d3eea74 | |
xeruf | 3cab294122 | |
xeruf | 01305c5a78 | |
xeruf | 14a1cbe09c | |
xeruf | 533378b24d |
|
@ -95,7 +95,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
|
||||||
- `TASK` - create task
|
- `TASK` - create task
|
||||||
+ prefix with space if you want a task to start with a command character
|
+ prefix with space if you want a task to start with a command character
|
||||||
+ copy in text with newlines to create one task per line
|
+ copy in text with newlines to create one task per line
|
||||||
- `.` - clear filters
|
- `.` - clear all filters
|
||||||
- `.TASK`
|
- `.TASK`
|
||||||
+ activate task by id
|
+ activate task by id
|
||||||
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
|
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
|
||||||
|
@ -125,9 +125,9 @@ Dot or slash can be repeated to move to parent tasks before acting.
|
||||||
|
|
||||||
Property Filters:
|
Property Filters:
|
||||||
|
|
||||||
- `#TAG1 TAG2` - set tag filter (empty: list all used tags)
|
- `#TAG1 TAG2` - set tag filter
|
||||||
- `+TAG` - add tag filter
|
- `+TAG` - add tag filter (empty: list all used tags)
|
||||||
- `-TAG` - remove tag filters by prefix
|
- `-TAG` - remove tag filters (by prefix)
|
||||||
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
|
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
|
||||||
- `@AUTHOR` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
|
- `@AUTHOR` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
|
||||||
- TBI: `**INT` - filter by priority
|
- TBI: `**INT` - filter by priority
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use std::ops::Sub;
|
use std::ops::Sub;
|
||||||
|
|
||||||
use chrono::{DateTime, Local, TimeDelta, TimeZone, Utc};
|
use chrono::{DateTime, Local, NaiveTime, 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;
|
||||||
|
@ -40,7 +40,15 @@ pub fn parse_date(str: &str) -> Option<DateTime<Utc>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}.map(|time| {
|
||||||
|
// TODO properly map date without time to day start, also support intervals
|
||||||
|
if str.chars().any(|c| c.is_numeric()) {
|
||||||
|
time
|
||||||
|
} else {
|
||||||
|
#[allow(deprecated)]
|
||||||
|
time.date().and_time(NaiveTime::default()).unwrap()
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turn a human-readable relative timestamp into a nostr Timestamp.
|
/// Turn a human-readable relative timestamp into a nostr Timestamp.
|
||||||
|
|
|
@ -57,7 +57,7 @@ where
|
||||||
EventBuilder::new(
|
EventBuilder::new(
|
||||||
Kind::from(TRACKING_KIND),
|
Kind::from(TRACKING_KIND),
|
||||||
"",
|
"",
|
||||||
id.into_iter().map(|id| Tag::event(id)),
|
id.into_iter().map(Tag::event),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ where
|
||||||
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
||||||
info!("Created {}task \"{name}\" with tags [{}]",
|
info!("Created {}task \"{name}\" with tags [{}]",
|
||||||
kind.map(|k| k.0).unwrap_or_default(),
|
kind.map(|k| k.0).unwrap_or_default(),
|
||||||
tags.iter().map(|tag| format_tag(tag)).join(", "));
|
tags.iter().map(format_tag).join(", "));
|
||||||
EventBuilder::new(kind.map(|k| k.1).unwrap_or(Kind::from(TASK_KIND)), name, tags)
|
EventBuilder::new(kind.map(|k| k.1).unwrap_or(Kind::from(TASK_KIND)), name, tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,7 +107,7 @@ fn format_tag(tag: &Tag) -> String {
|
||||||
public_key,
|
public_key,
|
||||||
alias,
|
alias,
|
||||||
..
|
..
|
||||||
}) => format!("Key{}: {:.8}", public_key.to_string(), alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default()),
|
}) => format!("Key{}: {:.8}", public_key, alias.as_ref().map(|s| format!(" {s}")).unwrap_or_default()),
|
||||||
Some(TagStandard::Hashtag(content)) =>
|
Some(TagStandard::Hashtag(content)) =>
|
||||||
format!("#{content}"),
|
format!("#{content}"),
|
||||||
_ => tag.content().map_or_else(
|
_ => tag.content().map_or_else(
|
||||||
|
|
60
src/main.rs
60
src/main.rs
|
@ -18,7 +18,8 @@ use log::{debug, error, info, LevelFilter, trace, warn};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use nostr_sdk::TagStandard::Hashtag;
|
use nostr_sdk::TagStandard::Hashtag;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use rustyline::{DefaultEditor, Editor};
|
use rustyline::config::Configurer;
|
||||||
|
use rustyline::DefaultEditor;
|
||||||
use rustyline::error::ReadlineError;
|
use rustyline::error::ReadlineError;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
|
@ -143,6 +144,7 @@ pub(crate) enum MostrMessage {
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let mut rl = DefaultEditor::new()?;
|
let mut rl = DefaultEditor::new()?;
|
||||||
|
rl.set_auto_add_history(true);
|
||||||
|
|
||||||
let mut args = args().skip(1).peekable();
|
let mut args = args().skip(1).peekable();
|
||||||
let mut builder = if args.peek().is_some_and(|arg| arg == "--debug") {
|
let mut builder = if args.peek().is_some_and(|arg| arg == "--debug") {
|
||||||
|
@ -239,12 +241,12 @@ async fn main() -> Result<()> {
|
||||||
client.connect().await;
|
client.connect().await;
|
||||||
|
|
||||||
let sub1 = client.subscribe(vec![
|
let sub1 = client.subscribe(vec![
|
||||||
Filter::new().kinds(KINDS.into_iter().map(|k| Kind::from(k)))
|
Filter::new().kinds(KINDS.into_iter().map(Kind::from))
|
||||||
], None).await;
|
], None).await;
|
||||||
info!("Subscribed to tasks with {:?}", sub1);
|
info!("Subscribed to tasks with {:?}", sub1);
|
||||||
|
|
||||||
let sub2 = client.subscribe(vec![
|
let sub2 = client.subscribe(vec![
|
||||||
Filter::new().kinds(PROP_KINDS.into_iter().map(|k| Kind::from(k)))
|
Filter::new().kinds(PROP_KINDS.into_iter().map(Kind::from))
|
||||||
], None).await;
|
], None).await;
|
||||||
info!("Subscribed to updates with {:?}", sub2);
|
info!("Subscribed to updates with {:?}", sub2);
|
||||||
|
|
||||||
|
@ -336,7 +338,7 @@ async fn main() -> Result<()> {
|
||||||
let tasks = relays.get(&selected_relay).unwrap();
|
let tasks = relays.get(&selected_relay).unwrap();
|
||||||
let prompt = format!(
|
let prompt = format!(
|
||||||
"{} {}{}) ",
|
"{} {}{}) ",
|
||||||
selected_relay.as_ref().map_or(LOCAL_RELAY_NAME.to_string(), |url| url.to_string()).bright_black(),
|
selected_relay.as_ref().map_or(LOCAL_RELAY_NAME.to_string(), |url| url.to_string()).dimmed(),
|
||||||
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(),
|
||||||
);
|
);
|
||||||
|
@ -418,19 +420,19 @@ async fn main() -> Result<()> {
|
||||||
Some(arg) => {
|
Some(arg) => {
|
||||||
if arg.len() < CHARACTER_THRESHOLD {
|
if arg.len() < CHARACTER_THRESHOLD {
|
||||||
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
|
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
tasks.make_note(arg)
|
tasks.make_note(arg)
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some('>') => {
|
Some('>') => {
|
||||||
tasks.update_state(&arg_default, State::Done);
|
tasks.update_state(arg_default, State::Done);
|
||||||
tasks.move_up();
|
tasks.move_up();
|
||||||
}
|
}
|
||||||
|
|
||||||
Some('<') => {
|
Some('<') => {
|
||||||
tasks.update_state(&arg_default, State::Closed);
|
tasks.update_state(arg_default, State::Closed);
|
||||||
tasks.move_up();
|
tasks.move_up();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -441,7 +443,7 @@ async fn main() -> Result<()> {
|
||||||
Some('@') => {
|
Some('@') => {
|
||||||
match arg {
|
match arg {
|
||||||
None => {
|
None => {
|
||||||
let today = Timestamp::from(Timestamp::now() - 80_000);
|
let today = Timestamp::now() - 80_000;
|
||||||
info!("Filtering for tasks created in 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())
|
||||||
|
@ -503,7 +505,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(arg) => 'arm: {
|
Some(arg) => 'arm: {
|
||||||
if arg.chars().next() != Some('|') {
|
if !arg.starts_with('|') {
|
||||||
if let Some(pos) = tasks.get_position() {
|
if let Some(pos) = tasks.get_position() {
|
||||||
tasks.move_up();
|
tasks.move_up();
|
||||||
tasks.make_task_with(
|
tasks.make_task_with(
|
||||||
|
@ -536,33 +538,34 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Some('#') =>
|
Some('#') =>
|
||||||
match arg {
|
tasks.set_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())),
|
||||||
Some(arg) => tasks.set_tags(arg.split_whitespace().map(|s| Hashtag(s.to_string()).into())),
|
|
||||||
None => {
|
|
||||||
println!("Hashtags of all known tasks:\n{}", tasks.all_hashtags().join(" "));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some('+') =>
|
Some('+') =>
|
||||||
match arg {
|
match arg {
|
||||||
Some(arg) => tasks.add_tag(arg.to_string()),
|
Some(arg) => tasks.add_tag(arg.to_string()),
|
||||||
None => tasks.clear_filter()
|
None => {
|
||||||
|
println!("Hashtags of all known tasks:\n{}", tasks.all_hashtags().join(" ").italic());
|
||||||
|
if tasks.has_tag_filter() {
|
||||||
|
println!("Use # to remove tag filters and . to remove all filters.")
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some('-') =>
|
Some('-') =>
|
||||||
match arg {
|
match arg {
|
||||||
Some(arg) => tasks.remove_tag(arg),
|
Some(arg) => tasks.remove_tag(arg),
|
||||||
None => tasks.clear_filter()
|
None => tasks.clear_filters()
|
||||||
}
|
}
|
||||||
|
|
||||||
Some('(') => {
|
Some('(') => {
|
||||||
if let Some(arg) = arg {
|
if let Some(arg) = arg {
|
||||||
if tasks.track_from(arg) {
|
if tasks.track_from(arg) {
|
||||||
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).collect_vec().iter().rev().join("\n"));
|
||||||
}
|
}
|
||||||
// TODO show history from author / pubkey
|
// TODO show history of 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"));
|
||||||
|
@ -574,9 +577,10 @@ async fn main() -> Result<()> {
|
||||||
match arg {
|
match arg {
|
||||||
None => tasks.move_to(None),
|
None => tasks.move_to(None),
|
||||||
Some(arg) => {
|
Some(arg) => {
|
||||||
if parse_tracking_stamp(arg).map(|stamp| tasks.track_at(stamp, None)).is_some() {
|
if parse_tracking_stamp(arg).and_then(|stamp| tasks.track_at(stamp, None)).is_some() {
|
||||||
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).collect_vec().iter().rev().join("\n"));
|
||||||
}
|
}
|
||||||
// So the error message is not covered up
|
// So the error message is not covered up
|
||||||
continue;
|
continue;
|
||||||
|
@ -597,6 +601,8 @@ async fn main() -> Result<()> {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos.cloned());
|
||||||
if dots > 1 {
|
if dots > 1 {
|
||||||
info!("Moving up {} tasks", dots - 1)
|
info!("Moving up {} tasks", dots - 1)
|
||||||
|
} else {
|
||||||
|
tasks.clear_filters();
|
||||||
}
|
}
|
||||||
} else if let Ok(depth) = slice.parse::<i8>() {
|
} else if let Ok(depth) = slice.parse::<i8>() {
|
||||||
if pos != tasks.get_position_ref() {
|
if pos != tasks.get_position_ref() {
|
||||||
|
@ -629,7 +635,7 @@ async fn main() -> Result<()> {
|
||||||
tasks.set_depth(depth);
|
tasks.set_depth(depth);
|
||||||
} else {
|
} else {
|
||||||
let mut transform: Box<dyn Fn(&str) -> String> = Box::new(|s: &str| s.to_string());
|
let mut transform: Box<dyn Fn(&str) -> String> = Box::new(|s: &str| s.to_string());
|
||||||
if slice.chars().find(|c| c.is_ascii_uppercase()).is_none() {
|
if !slice.chars().any(|c| c.is_ascii_uppercase()) {
|
||||||
// Smart-case - case-sensitive if any uppercase char is entered
|
// Smart-case - case-sensitive if any uppercase char is entered
|
||||||
transform = Box::new(|s| s.to_ascii_lowercase());
|
transform = Box::new(|s| s.to_ascii_lowercase());
|
||||||
}
|
}
|
||||||
|
@ -643,7 +649,7 @@ async fn main() -> Result<()> {
|
||||||
.map(|t| t.event.id)
|
.map(|t| t.event.id)
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
if filtered.len() == 1 {
|
if filtered.len() == 1 {
|
||||||
tasks.move_to(filtered.into_iter().nth(0));
|
tasks.move_to(filtered.into_iter().next());
|
||||||
} else {
|
} else {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos.cloned());
|
||||||
tasks.set_filter(filtered);
|
tasks.set_filter(filtered);
|
||||||
|
@ -652,10 +658,10 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
_ =>
|
_ =>
|
||||||
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_ref().is_some_and(|url| url.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 = url.clone();
|
selected_relay.clone_from(url);
|
||||||
or_warn!(tasks.print_tasks());
|
or_warn!(tasks.print_tasks());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
22
src/task.rs
22
src/task.rs
|
@ -46,7 +46,8 @@ impl Ord for Task {
|
||||||
impl Task {
|
impl Task {
|
||||||
pub(crate) fn new(event: Event) -> Task {
|
pub(crate) fn new(event: Event) -> Task {
|
||||||
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
|
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
|
||||||
Some(TagStandard::Event { event_id, marker, .. }) => Left((marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), event_id.clone())),
|
Some(TagStandard::Event { event_id, marker, .. }) =>
|
||||||
|
Left((marker.as_ref().map_or(MARKER_PARENT.to_string(), |m| m.to_string()), *event_id)),
|
||||||
_ => Right(tag.clone()),
|
_ => Right(tag.clone()),
|
||||||
});
|
});
|
||||||
// Separate refs for dependencies
|
// Separate refs for dependencies
|
||||||
|
@ -82,13 +83,7 @@ impl Task {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
|
pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
|
||||||
self.props.iter().filter_map(|event| {
|
self.props.iter().filter(|event| event.kind == Kind::TextNote)
|
||||||
if event.kind == Kind::TextNote {
|
|
||||||
Some(event)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
|
pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
|
||||||
|
@ -105,7 +100,7 @@ impl Task {
|
||||||
event.kind.try_into().ok().map(|s| TaskState {
|
event.kind.try_into().ok().map(|s| TaskState {
|
||||||
name: some_non_empty(&event.content),
|
name: some_non_empty(&event.content),
|
||||||
state: s,
|
state: s,
|
||||||
time: event.created_at.clone(),
|
time: event.created_at,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -142,9 +137,9 @@ impl Task {
|
||||||
P: FnMut(&&Tag) -> bool,
|
P: FnMut(&&Tag) -> bool,
|
||||||
{
|
{
|
||||||
self.tags.as_ref().map(|tags| {
|
self.tags.as_ref().map(|tags| {
|
||||||
tags.into_iter()
|
tags.iter()
|
||||||
.filter(predicate)
|
.filter(predicate)
|
||||||
.map(|t| format!("{}", t.content().unwrap()))
|
.map(|t| t.content().unwrap().to_string())
|
||||||
.join(" ")
|
.join(" ")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -261,10 +256,7 @@ impl TryFrom<Kind> for State {
|
||||||
}
|
}
|
||||||
impl State {
|
impl State {
|
||||||
pub(crate) fn is_open(&self) -> bool {
|
pub(crate) fn is_open(&self) -> bool {
|
||||||
match self {
|
matches!(self, State::Open | State::Pending | State::Procedure)
|
||||||
State::Open | State::Pending | State::Procedure => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn kind(self) -> u16 {
|
pub(crate) fn kind(self) -> u16 {
|
||||||
|
|
222
src/tasks.rs
222
src/tasks.rs
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::io::{Error, stdout, Write};
|
use std::io::{Error, stdout, Write};
|
||||||
use std::iter::{empty, once};
|
use std::iter::{empty, once};
|
||||||
|
@ -18,13 +18,18 @@ use crate::helpers::{CHARACTER_THRESHOLD, format_timestamp_local, format_timesta
|
||||||
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};
|
||||||
|
|
||||||
|
const MAX_OFFSET: u64 = 9;
|
||||||
|
fn now() -> Timestamp {
|
||||||
|
Timestamp::now() + MAX_OFFSET
|
||||||
|
}
|
||||||
|
|
||||||
type TaskMap = HashMap<EventId, Task>;
|
type TaskMap = HashMap<EventId, Task>;
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Tasks {
|
pub(crate) struct Tasks {
|
||||||
/// The Tasks
|
/// The Tasks
|
||||||
tasks: TaskMap,
|
tasks: TaskMap,
|
||||||
/// History of active tasks by PubKey
|
/// History of active tasks by PubKey
|
||||||
history: HashMap<PublicKey, BTreeSet<Event>>,
|
history: HashMap<PublicKey, BTreeMap<Timestamp, Event>>,
|
||||||
/// Index of found users with metadata
|
/// Index of found users with metadata
|
||||||
users: HashMap<PublicKey, Metadata>,
|
users: HashMap<PublicKey, Metadata>,
|
||||||
|
|
||||||
|
@ -49,8 +54,9 @@ pub(crate) struct Tasks {
|
||||||
sender: EventSender,
|
sender: EventSender,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub(crate) enum StateFilter {
|
pub(crate) enum StateFilter {
|
||||||
|
#[default]
|
||||||
Default,
|
Default,
|
||||||
All,
|
All,
|
||||||
State(String),
|
State(String),
|
||||||
|
@ -68,7 +74,7 @@ impl StateFilter {
|
||||||
match self {
|
match self {
|
||||||
StateFilter::Default => {
|
StateFilter::Default => {
|
||||||
let state = task.pure_state();
|
let state = task.pure_state();
|
||||||
state.is_open() || (state == State::Done && task.parent_id() != None)
|
state.is_open() || (state == State::Done && task.parent_id().is_some())
|
||||||
}
|
}
|
||||||
StateFilter::All => true,
|
StateFilter::All => true,
|
||||||
StateFilter::State(filter) => task.state().is_some_and(|t| t.matches_label(filter)),
|
StateFilter::State(filter) => task.state().is_some_and(|t| t.matches_label(filter)),
|
||||||
|
@ -83,11 +89,6 @@ impl StateFilter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl Default for StateFilter {
|
|
||||||
fn default() -> Self {
|
|
||||||
StateFilter::Default
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Display for StateFilter {
|
impl Display for StateFilter {
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(
|
write!(
|
||||||
|
@ -104,12 +105,7 @@ impl Display for StateFilter {
|
||||||
|
|
||||||
impl Tasks {
|
impl Tasks {
|
||||||
pub(crate) fn from(url: Option<Url>, tx: &tokio::sync::mpsc::Sender<MostrMessage>, keys: &Keys, metadata: Option<Metadata>) -> Self {
|
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 {
|
let mut new = Self::with_sender(EventSender::from(url, tx, keys));
|
||||||
url,
|
|
||||||
tx: tx.clone(),
|
|
||||||
keys: keys.clone(),
|
|
||||||
queue: Default::default(),
|
|
||||||
});
|
|
||||||
metadata.map(|m| new.users.insert(keys.public_key(), m));
|
metadata.map(|m| new.users.insert(keys.public_key(), m));
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
|
@ -155,18 +151,21 @@ impl Tasks {
|
||||||
self.get_position_ref().cloned()
|
self.get_position_ref().cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> Timestamp {
|
pub(crate) fn get_position_ref(&self) -> Option<&EventId> {
|
||||||
Timestamp::from(Timestamp::now() + Self::MAX_OFFSET)
|
self.get_position_at(now()).1
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_position_ref(&self) -> Option<&EventId> {
|
fn get_position_at(&self, timestamp: Timestamp) -> (Timestamp, Option<&EventId>) {
|
||||||
self.history_from(Self::now())
|
self.history_from(timestamp)
|
||||||
.last()
|
.last()
|
||||||
.and_then(|e| referenced_events(e))
|
.filter(|e| e.created_at <= timestamp)
|
||||||
|
.map_or_else(
|
||||||
|
|| (Timestamp::now(), None),
|
||||||
|
|e| (e.created_at, referenced_events(e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ids of all subtasks recursively found for id, including itself
|
/// Ids of all subtasks recursively found for id, including itself
|
||||||
pub(crate) fn get_task_tree<'a>(&'a self, id: &'a EventId) -> ChildIterator {
|
fn get_task_tree<'a>(&'a self, id: &'a EventId) -> ChildIterator {
|
||||||
ChildIterator::from(self, id)
|
ChildIterator::from(self, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,13 +183,13 @@ impl Tasks {
|
||||||
pub(crate) fn times_tracked(&self) -> (String, Box<dyn DoubleEndedIterator<Item=String>>) {
|
pub(crate) fn times_tracked(&self) -> (String, Box<dyn DoubleEndedIterator<Item=String>>) {
|
||||||
match self.get_position_ref() {
|
match self.get_position_ref() {
|
||||||
None => {
|
None => {
|
||||||
if let Some(set) = self.history.get(&self.sender.pubkey()) {
|
if let Some(hist) = self.history.get(&self.sender.pubkey()) {
|
||||||
let mut last = None;
|
let mut last = None;
|
||||||
let mut full = Vec::with_capacity(set.len());
|
let mut full = Vec::with_capacity(hist.len());
|
||||||
for event in set {
|
for event in hist.values() {
|
||||||
let new = some_non_empty(&event.tags.iter()
|
let new = some_non_empty(&event.tags.iter()
|
||||||
.filter_map(|t| t.content())
|
.filter_map(|t| t.content())
|
||||||
.map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_title(&id)))
|
.map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_path(Some(id))))
|
||||||
.join(" "));
|
.join(" "));
|
||||||
if new != last {
|
if new != last {
|
||||||
// TODO alternate color with grey between days
|
// TODO alternate color with grey between days
|
||||||
|
@ -208,7 +207,7 @@ impl Tasks {
|
||||||
let history =
|
let history =
|
||||||
self.history.iter().flat_map(|(key, set)| {
|
self.history.iter().flat_map(|(key, set)| {
|
||||||
let mut vec = Vec::with_capacity(set.len() / 2);
|
let mut vec = Vec::with_capacity(set.len() / 2);
|
||||||
let mut iter = timestamps(set.iter(), &ids).tuples();
|
let mut iter = timestamps(set.values(), &ids).tuples();
|
||||||
while let Some(((start, _), (end, _))) = iter.next() {
|
while let Some(((start, _), (end, _))) = iter.next() {
|
||||||
vec.push(format!("{} - {} by {}",
|
vec.push(format!("{} - {} by {}",
|
||||||
format_timestamp_local(start),
|
format_timestamp_local(start),
|
||||||
|
@ -220,14 +219,14 @@ impl Tasks {
|
||||||
vec.push(format!("{} started by {}", format_timestamp_local(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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total time in seconds tracked on this task by the current user.
|
/// Total time in seconds tracked on this task by the current user.
|
||||||
pub(crate) fn time_tracked(&self, id: EventId) -> u64 {
|
pub(crate) fn time_tracked(&self, id: EventId) -> u64 {
|
||||||
Durations::from(self.history.get(&self.sender.pubkey()).into_iter().flatten(), &vec![&id]).sum::<Duration>().as_secs()
|
Durations::from(self.get_own_history(), &vec![&id]).sum::<Duration>().as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -237,7 +236,7 @@ impl Tasks {
|
||||||
|
|
||||||
let children = self.get_task_tree(&id).get_all();
|
let children = self.get_task_tree(&id).get_all();
|
||||||
for user in self.history.values() {
|
for user in self.history.values() {
|
||||||
total += Durations::from(user, &children).sum::<Duration>().as_secs();
|
total += Durations::from(user.values(), &children).sum::<Duration>().as_secs();
|
||||||
}
|
}
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
|
@ -284,9 +283,10 @@ impl Tasks {
|
||||||
join_tasks(self.traverse_up_from(id), true)
|
join_tasks(self.traverse_up_from(id), true)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.or_else(|| id.map(|id| id.to_string()))
|
.or_else(|| id.map(|id| id.to_string()))
|
||||||
.unwrap_or(String::new())
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Iterate over the task referenced by the given id and all its available parents.
|
||||||
fn traverse_up_from(&self, id: Option<EventId>) -> ParentIterator {
|
fn traverse_up_from(&self, id: Option<EventId>) -> ParentIterator {
|
||||||
ParentIterator {
|
ParentIterator {
|
||||||
tasks: &self.tasks,
|
tasks: &self.tasks,
|
||||||
|
@ -314,7 +314,7 @@ impl Tasks {
|
||||||
iter: impl Iterator<Item=&'a EventId>,
|
iter: impl Iterator<Item=&'a EventId>,
|
||||||
depth: i8,
|
depth: i8,
|
||||||
) -> Box<impl Iterator<Item=&'a Task>> {
|
) -> Box<impl Iterator<Item=&'a Task>> {
|
||||||
iter.filter_map(|id| self.get_by_id(&id))
|
iter.filter_map(|id| self.get_by_id(id))
|
||||||
.flat_map(move |task| {
|
.flat_map(move |task| {
|
||||||
let new_depth = depth - 1;
|
let new_depth = depth - 1;
|
||||||
if new_depth == 0 {
|
if new_depth == 0 {
|
||||||
|
@ -372,7 +372,7 @@ impl Tasks {
|
||||||
// TODO apply filters in transit
|
// TODO apply filters in transit
|
||||||
self.state.matches(t) &&
|
self.state.matches(t) &&
|
||||||
t.tags.as_ref().map_or(true, |tags| {
|
t.tags.as_ref().map_or(true, |tags| {
|
||||||
tags.iter().find(|tag| self.tags_excluded.contains(tag)).is_none()
|
!tags.iter().any(|tag| self.tags_excluded.contains(tag))
|
||||||
}) &&
|
}) &&
|
||||||
(self.tags.is_empty() ||
|
(self.tags.is_empty() ||
|
||||||
t.tags.as_ref().map_or(false, |tags| {
|
t.tags.as_ref().map_or(false, |tags| {
|
||||||
|
@ -386,7 +386,7 @@ impl Tasks {
|
||||||
if self.depth == 0 {
|
if self.depth == 0 {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
if self.view.len() > 0 {
|
if !self.view.is_empty() {
|
||||||
return self.resolve_tasks(self.view.iter()).collect();
|
return self.resolve_tasks(self.view.iter()).collect();
|
||||||
}
|
}
|
||||||
self.filtered_tasks(self.get_position_ref()).collect()
|
self.filtered_tasks(self.get_position_ref()).collect()
|
||||||
|
@ -396,15 +396,15 @@ impl Tasks {
|
||||||
let mut lock = stdout().lock();
|
let mut lock = stdout().lock();
|
||||||
if let Some(t) = self.get_current_task() {
|
if let Some(t) = self.get_current_task() {
|
||||||
let state = t.state_or_default();
|
let state = t.state_or_default();
|
||||||
let now = &Self::now();
|
let now = &now();
|
||||||
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.get_own_history(), &[t.get_id()])
|
||||||
.map(|(e, _)| e) {
|
.map(|(e, _)| e) {
|
||||||
if tracking_stamp.is_some() && elem > now {
|
if tracking_stamp.is_some() && elem > now {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tracking_stamp = Some(elem.clone())
|
tracking_stamp = Some(*elem)
|
||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
lock,
|
lock,
|
||||||
|
@ -458,9 +458,8 @@ impl Tasks {
|
||||||
|
|
||||||
fn get_property(&self, task: &Task, str: &str) -> String {
|
fn get_property(&self, task: &Task, str: &str) -> String {
|
||||||
let progress =
|
let progress =
|
||||||
self
|
self.total_progress(task.get_id())
|
||||||
.total_progress(task.get_id())
|
.filter(|_| !task.children.is_empty());
|
||||||
.filter(|_| task.children.len() > 0);
|
|
||||||
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
|
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
|
||||||
match str {
|
match str {
|
||||||
"subtasks" => {
|
"subtasks" => {
|
||||||
|
@ -496,7 +495,7 @@ impl Tasks {
|
||||||
// TODO format strings configurable
|
// TODO format strings configurable
|
||||||
"time" => display_time("MMMm", self.time_tracked(*task.get_id())),
|
"time" => display_time("MMMm", self.time_tracked(*task.get_id())),
|
||||||
"rtime" => display_time("HH:MM", self.total_time_tracked(*task.get_id())),
|
"rtime" => display_time("HH:MM", self.total_time_tracked(*task.get_id())),
|
||||||
prop => task.get(prop).unwrap_or(String::new()),
|
prop => task.get(prop).unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -515,15 +514,18 @@ impl Tasks {
|
||||||
self.view = view;
|
self.view = view;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn clear_filter(&mut self) {
|
pub(crate) fn clear_filters(&mut self) {
|
||||||
self.view.clear();
|
self.view.clear();
|
||||||
self.tags.clear();
|
self.tags.clear();
|
||||||
self.tags_excluded.clear();
|
self.tags_excluded.clear();
|
||||||
info!("Removed all filters");
|
info!("Removed all filters");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_tag_filter(&self) -> bool {
|
||||||
|
!self.tags.is_empty() || !self.tags_excluded.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn set_tags(&mut self, tags: impl IntoIterator<Item=Tag>) {
|
pub(crate) fn set_tags(&mut self, tags: impl IntoIterator<Item=Tag>) {
|
||||||
self.tags_excluded.clear();
|
|
||||||
self.tags.clear();
|
self.tags.clear();
|
||||||
self.tags.extend(tags);
|
self.tags.extend(tags);
|
||||||
}
|
}
|
||||||
|
@ -563,12 +565,20 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns ids of tasks starting with the given string.
|
/// Returns ids of tasks starting with the given string.
|
||||||
|
///
|
||||||
|
/// Tries, in order:
|
||||||
|
/// - single case-insensitive exact name match in visible tasks
|
||||||
|
/// - single case-insensitive exact name match in all tasks
|
||||||
|
/// - visible tasks starting with given arg case-sensitive
|
||||||
|
/// - visible tasks where any word starts with given arg case-insensitive
|
||||||
pub(crate) fn get_filtered(&self, position: Option<&EventId>, arg: &str) -> Vec<EventId> {
|
pub(crate) fn get_filtered(&self, position: Option<&EventId>, arg: &str) -> Vec<EventId> {
|
||||||
if let Ok(id) = EventId::parse(arg) {
|
if let Ok(id) = EventId::parse(arg) {
|
||||||
return vec![id];
|
return vec![id];
|
||||||
}
|
}
|
||||||
let mut filtered: Vec<EventId> = Vec::with_capacity(32);
|
|
||||||
let lowercase_arg = arg.to_ascii_lowercase();
|
let lowercase_arg = arg.to_ascii_lowercase();
|
||||||
|
let has_space = lowercase_arg.split_ascii_whitespace().count() > 1;
|
||||||
|
|
||||||
|
let mut filtered: Vec<EventId> = Vec::with_capacity(32);
|
||||||
let mut filtered_more: Vec<EventId> = Vec::with_capacity(32);
|
let mut filtered_more: Vec<EventId> = Vec::with_capacity(32);
|
||||||
for task in self.filtered_tasks(position) {
|
for task in self.filtered_tasks(position) {
|
||||||
let lowercase = task.event.content.to_ascii_lowercase();
|
let lowercase = task.event.content.to_ascii_lowercase();
|
||||||
|
@ -576,14 +586,21 @@ impl Tasks {
|
||||||
return vec![task.event.id];
|
return vec![task.event.id];
|
||||||
} else if task.event.content.starts_with(arg) {
|
} else if task.event.content.starts_with(arg) {
|
||||||
filtered.push(task.event.id)
|
filtered.push(task.event.id)
|
||||||
} else if lowercase.starts_with(&lowercase_arg) {
|
} else if if has_space { lowercase.starts_with(&lowercase_arg) } else { lowercase.split_ascii_whitespace().any(|word| word.starts_with(&lowercase_arg)) } {
|
||||||
filtered_more.push(task.event.id)
|
filtered_more.push(task.event.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if filtered.len() == 0 {
|
for task in self.tasks.values() {
|
||||||
|
if task.event.content.to_ascii_lowercase() == lowercase_arg &&
|
||||||
|
!self.traverse_up_from(Some(*task.get_id())).any(|t| t.pure_state() == State::Closed) {
|
||||||
|
// exclude closed tasks and their subtasks
|
||||||
|
return vec![task.event.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filtered.is_empty() {
|
||||||
return filtered_more;
|
return filtered_more;
|
||||||
}
|
}
|
||||||
return filtered;
|
filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Finds out what to do with the given string.
|
/// Finds out what to do with the given string.
|
||||||
|
@ -596,7 +613,7 @@ impl Tasks {
|
||||||
self.view.clear();
|
self.view.clear();
|
||||||
if arg.len() < CHARACTER_THRESHOLD {
|
if arg.len() < CHARACTER_THRESHOLD {
|
||||||
warn!("New task name needs at least {CHARACTER_THRESHOLD} characters");
|
warn!("New task name needs at least {CHARACTER_THRESHOLD} characters");
|
||||||
return None
|
return None;
|
||||||
}
|
}
|
||||||
Some(self.make_task_with(arg, self.position_tags_for(position), true))
|
Some(self.make_task_with(arg, self.position_tags_for(position), true))
|
||||||
}
|
}
|
||||||
|
@ -617,12 +634,10 @@ impl Tasks {
|
||||||
/// Returns all recent events from history until the first event at or before the given timestamp.
|
/// Returns all recent events from history until the first event at or before the given timestamp.
|
||||||
fn history_from(&self, stamp: Timestamp) -> impl Iterator<Item=&Event> {
|
fn history_from(&self, stamp: Timestamp) -> impl Iterator<Item=&Event> {
|
||||||
self.history.get(&self.sender.pubkey()).map(|hist| {
|
self.history.get(&self.sender.pubkey()).map(|hist| {
|
||||||
hist.iter().rev().take_while_inclusive(move |e| e.created_at > stamp)
|
hist.values().rev().take_while_inclusive(move |e| e.created_at > stamp)
|
||||||
}).into_iter().flatten()
|
}).into_iter().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_OFFSET: u64 = 9;
|
|
||||||
|
|
||||||
pub(crate) fn move_to(&mut self, target: Option<EventId>) {
|
pub(crate) fn move_to(&mut self, target: Option<EventId>) {
|
||||||
self.view.clear();
|
self.view.clear();
|
||||||
let pos = self.get_position_ref();
|
let pos = self.get_position_ref();
|
||||||
|
@ -638,8 +653,8 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Timestamp::now();
|
let now = Timestamp::now();
|
||||||
let offset: u64 = self.history_from(now).skip_while(|e| e.created_at.as_u64() > now.as_u64() + Self::MAX_OFFSET).count() as u64;
|
let offset: u64 = self.history_from(now).skip_while(|e| e.created_at.as_u64() > now.as_u64() + MAX_OFFSET).count() as u64;
|
||||||
if offset >= Self::MAX_OFFSET {
|
if offset >= MAX_OFFSET {
|
||||||
warn!("Whoa you are moving around quickly! Give me a few seconds to process.")
|
warn!("Whoa you are moving around quickly! Give me a few seconds to process.")
|
||||||
}
|
}
|
||||||
self.submit(
|
self.submit(
|
||||||
|
@ -714,7 +729,7 @@ impl Tasks {
|
||||||
let id = self.submit(
|
let id = self.submit(
|
||||||
build_task(input, input_tags, None)
|
build_task(input, input_tags, None)
|
||||||
.add_tags(self.tags.iter().cloned())
|
.add_tags(self.tags.iter().cloned())
|
||||||
.add_tags(tags.into_iter())
|
.add_tags(tags)
|
||||||
);
|
);
|
||||||
if set_state {
|
if set_state {
|
||||||
self.state.as_option().inspect(|s| self.set_state_for_with(id, s));
|
self.state.as_option().inspect(|s| self.set_state_for_with(id, s));
|
||||||
|
@ -726,22 +741,46 @@ impl Tasks {
|
||||||
self.tasks.get(id).map_or(id.to_string(), |t| t.get_title())
|
self.tasks.get(id).map_or(id.to_string(), |t| t.get_title())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse string and set tracking
|
/// Parse relative time string and track for current position
|
||||||
|
///
|
||||||
/// Returns false and prints a message if parsing failed
|
/// Returns false and prints a message if parsing failed
|
||||||
pub(crate) fn track_from(&mut self, str: &str) -> bool {
|
pub(crate) fn track_from(&mut self, str: &str) -> bool {
|
||||||
parse_tracking_stamp(str)
|
parse_tracking_stamp(str)
|
||||||
.map(|stamp| self.track_at(stamp, self.get_position()))
|
.and_then(|stamp| self.track_at(stamp, self.get_position()))
|
||||||
.is_some()
|
.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn track_at(&mut self, time: Timestamp, task: Option<EventId>) -> EventId {
|
pub(crate) fn track_at(&mut self, mut time: Timestamp, target: Option<EventId>) -> Option<EventId> {
|
||||||
info!("{} {}", task.map_or(
|
if target.is_none() {
|
||||||
String::from("Stopping time-tracking at"),
|
time = time - 1;
|
||||||
|id| format!("Tracking \"{}\" from", self.get_task_title(&id))), format_timestamp_relative(&time));
|
} else if let Some(hist) = self.history.get(&self.sender.pubkey()) {
|
||||||
|
while hist.get(&time).is_some() {
|
||||||
|
time = time + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let current_pos = self.get_position_at(time);
|
||||||
|
if (time < Timestamp::now() || target.is_none()) && current_pos.1 == target.as_ref() {
|
||||||
|
warn!("Already {} from {}",
|
||||||
|
target.map_or("stopped time-tracking".to_string(),
|
||||||
|
|id| format!("tracking \"{}\"", self.get_task_title(&id))),
|
||||||
|
format_timestamp_relative(¤t_pos.0),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
info!("{}", match target {
|
||||||
|
None => format!("Stopping time-tracking of \"{}\" at {}",
|
||||||
|
current_pos.1.map_or("???".to_string(), |id| self.get_task_title(id)),
|
||||||
|
format_timestamp_relative(&time)),
|
||||||
|
Some(new_id) => format!("Tracking \"{}\" from {}{}",
|
||||||
|
self.get_task_title(&new_id),
|
||||||
|
format_timestamp_relative(&time),
|
||||||
|
current_pos.1.filter(|id| id != &&new_id).map(
|
||||||
|
|id| format!(" replacing \"{}\"", self.get_task_title(id))).unwrap_or_default()),
|
||||||
|
});
|
||||||
self.submit(
|
self.submit(
|
||||||
build_tracking(task)
|
build_tracking(target)
|
||||||
.custom_created_at(time)
|
.custom_created_at(time)
|
||||||
)
|
).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign and queue the event to the relay, returning its id
|
/// Sign and queue the event to the relay, returning its id
|
||||||
|
@ -757,8 +796,8 @@ impl Tasks {
|
||||||
TASK_KIND => self.add_task(event),
|
TASK_KIND => self.add_task(event),
|
||||||
TRACKING_KIND =>
|
TRACKING_KIND =>
|
||||||
match self.history.get_mut(&event.pubkey) {
|
match self.history.get_mut(&event.pubkey) {
|
||||||
Some(c) => { c.insert(event); }
|
Some(c) => { c.insert(event.created_at, event); }
|
||||||
None => { self.history.insert(event.pubkey, BTreeSet::from([event])); }
|
None => { self.history.insert(event.pubkey, BTreeMap::from([(event.created_at, event)])); }
|
||||||
},
|
},
|
||||||
METADATA_KIND =>
|
METADATA_KIND =>
|
||||||
match Metadata::from_json(event.content()) {
|
match Metadata::from_json(event.content()) {
|
||||||
|
@ -795,8 +834,8 @@ impl Tasks {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_own_history(&mut self) -> Option<&mut BTreeSet<Event>> {
|
fn get_own_history(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
|
||||||
self.history.get_mut(&self.sender.pubkey())
|
self.history.get(&self.sender.pubkey()).into_iter().flat_map(|t| t.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn undo(&mut self) {
|
pub(crate) fn undo(&mut self) {
|
||||||
|
@ -810,8 +849,8 @@ impl Tasks {
|
||||||
|
|
||||||
fn remove(&mut self, event: &Event) {
|
fn remove(&mut self, event: &Event) {
|
||||||
self.tasks.remove(&event.id);
|
self.tasks.remove(&event.id);
|
||||||
self.get_own_history().map(
|
self.history.get_mut(&self.sender.pubkey())
|
||||||
|t| t.retain(|e| e != event &&
|
.map(|t| t.retain(|t, e| e != event &&
|
||||||
!referenced_events(e).is_some_and(|id| id == &event.id)));
|
!referenced_events(e).is_some_and(|id| id == &event.id)));
|
||||||
self.referenced_tasks(event, |t| { t.props.remove(event); });
|
self.referenced_tasks(event, |t| { t.props.remove(event); });
|
||||||
}
|
}
|
||||||
|
@ -832,13 +871,13 @@ impl Tasks {
|
||||||
|
|
||||||
pub(crate) fn update_state(&mut self, comment: &str, state: State) -> Option<EventId> {
|
pub(crate) fn update_state(&mut self, comment: &str, state: State) -> Option<EventId> {
|
||||||
let id = self.get_position_ref()?;
|
let id = self.get_position_ref()?;
|
||||||
Some(self.set_state_for(id.clone(), comment, state))
|
Some(self.set_state_for(*id, comment, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn make_note(&mut self, note: &str) {
|
pub(crate) fn make_note(&mut self, note: &str) {
|
||||||
if let Some(id) = self.get_position_ref() {
|
if let Some(id) = self.get_position_ref() {
|
||||||
if self.get_by_id(id).is_some_and(|t| t.is_task()) {
|
if self.get_by_id(id).is_some_and(|t| t.is_task()) {
|
||||||
let prop = build_prop(Kind::TextNote, note.trim(), id.clone());
|
let prop = build_prop(Kind::TextNote, note.trim(), *id);
|
||||||
self.submit(prop);
|
self.submit(prop);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -944,7 +983,7 @@ pub(crate) fn join_tasks<'a>(
|
||||||
None.into_iter()
|
None.into_iter()
|
||||||
})
|
})
|
||||||
.fold(None, |acc, val| {
|
.fold(None, |acc, val| {
|
||||||
Some(acc.map_or_else(|| val.clone(), |cur| format!("{}>{}", val, cur)))
|
Some(acc.map_or_else(|| val.clone(), |cur| format!("{}{}{}", val, ">".dimmed(), cur)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -955,7 +994,7 @@ fn referenced_events(event: &Event) -> Option<&EventId> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn matching_tag_id<'a>(event: &'a Event, ids: &'a Vec<&'a EventId>) -> Option<&'a EventId> {
|
fn matching_tag_id<'a>(event: &'a Event, ids: &'a [&'a EventId]) -> Option<&'a EventId> {
|
||||||
event.tags.iter().find_map(|tag| match tag.as_standardized() {
|
event.tags.iter().find_map(|tag| match tag.as_standardized() {
|
||||||
Some(TagStandard::Event { event_id, .. }) if ids.contains(&event_id) => Some(event_id),
|
Some(TagStandard::Event { event_id, .. }) if ids.contains(&event_id) => Some(event_id),
|
||||||
_ => None
|
_ => None
|
||||||
|
@ -963,10 +1002,10 @@ fn matching_tag_id<'a>(event: &'a Event, ids: &'a Vec<&'a EventId>) -> Option<&'
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Filters out event timestamps to those that start or stop one of the given events
|
/// Filters out event timestamps to those that start or stop one of the given events
|
||||||
fn timestamps<'a>(events: impl Iterator<Item=&'a Event>, ids: &'a Vec<&'a EventId>) -> impl Iterator<Item=(&Timestamp, Option<&EventId>)> {
|
fn timestamps<'a>(events: impl Iterator<Item=&'a Event>, ids: &'a [&'a EventId]) -> impl Iterator<Item=(&Timestamp, Option<&EventId>)> {
|
||||||
events.map(|event| (&event.created_at, matching_tag_id(event, ids)))
|
events.map(|event| (&event.created_at, matching_tag_id(event, ids)))
|
||||||
.dedup_by(|(_, e1), (_, e2)| e1 == e2)
|
.dedup_by(|(_, e1), (_, e2)| e1 == e2)
|
||||||
.skip_while(|element| element.1 == None)
|
.skip_while(|element| element.1.is_none())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Iterates Events to accumulate times tracked
|
/// Iterates Events to accumulate times tracked
|
||||||
|
@ -1003,7 +1042,7 @@ impl Iterator for Durations<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let now = self.threshold.unwrap_or(Timestamp::now()).as_u64();
|
let now = self.threshold.unwrap_or(Timestamp::now()).as_u64();
|
||||||
return start.filter(|t| t < &now).map(|stamp| Duration::from_secs(now.saturating_sub(stamp)));
|
start.filter(|t| t < &now).map(|stamp| Duration::from_secs(now.saturating_sub(stamp)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1052,7 +1091,7 @@ impl<'a> Iterator for ChildIterator<'a> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let id = self.queue[self.index];
|
let id = self.queue[self.index];
|
||||||
if let Some(task) = self.tasks.get(&id) {
|
if let Some(task) = self.tasks.get(id) {
|
||||||
self.queue.reserve(task.children.len());
|
self.queue.reserve(task.children.len());
|
||||||
self.queue.extend(task.children.iter());
|
self.queue.extend(task.children.iter());
|
||||||
} else {
|
} else {
|
||||||
|
@ -1084,7 +1123,7 @@ impl<'a> Iterator for ParentIterator<'a> {
|
||||||
|
|
||||||
fn next(&mut self) -> Option<Self::Item> {
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
self.current.and_then(|id| self.tasks.get(&id)).map(|t| {
|
self.current.and_then(|id| self.tasks.get(&id)).map(|t| {
|
||||||
self.prev.map(|id| assert!(t.children.contains(&id)));
|
self.prev.inspect(|id| assert!(t.children.contains(id)));
|
||||||
self.prev = self.current;
|
self.prev = self.current;
|
||||||
self.current = t.parent_id().cloned();
|
self.current = t.parent_id().cloned();
|
||||||
t
|
t
|
||||||
|
@ -1121,7 +1160,7 @@ mod tasks_test {
|
||||||
fn test_procedures() {
|
fn test_procedures() {
|
||||||
let mut tasks = stub_tasks();
|
let mut tasks = stub_tasks();
|
||||||
tasks.make_task_and_enter("proc: tags", State::Procedure);
|
tasks.make_task_and_enter("proc: tags", State::Procedure);
|
||||||
assert_eq!(tasks.get_own_history().unwrap().len(), 1);
|
assert_eq!(tasks.get_own_history().count(), 1);
|
||||||
let side = tasks.submit(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
|
let side = tasks.submit(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
|
||||||
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new());
|
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new());
|
||||||
let sub_id = tasks.make_task("sub");
|
let sub_id = tasks.make_task("sub");
|
||||||
|
@ -1137,7 +1176,7 @@ mod tasks_test {
|
||||||
let zeros = EventId::all_zeros();
|
let zeros = EventId::all_zeros();
|
||||||
let zero = Some(&zeros);
|
let zero = Some(&zeros);
|
||||||
|
|
||||||
let id1 = tasks.filter_or_create(zero, "new");
|
let id1 = tasks.filter_or_create(zero, "newer");
|
||||||
assert_eq!(tasks.len(), 1);
|
assert_eq!(tasks.len(), 1);
|
||||||
assert_eq!(tasks.visible_tasks().len(), 0);
|
assert_eq!(tasks.visible_tasks().len(), 0);
|
||||||
assert_eq!(tasks.get_by_id(&id1.unwrap()).unwrap().parent_id(), zero);
|
assert_eq!(tasks.get_by_id(&id1.unwrap()).unwrap().parent_id(), zero);
|
||||||
|
@ -1154,6 +1193,13 @@ mod tasks_test {
|
||||||
assert_eq!(tasks.visible_tasks().len(), 2);
|
assert_eq!(tasks.visible_tasks().len(), 2);
|
||||||
let new2 = tasks.get_by_id(&id2.unwrap()).unwrap();
|
let new2 = tasks.get_by_id(&id2.unwrap()).unwrap();
|
||||||
assert_eq!(new2.props, Default::default());
|
assert_eq!(new2.props, Default::default());
|
||||||
|
|
||||||
|
assert_eq!(tasks.get_own_history().count(), 1);
|
||||||
|
let idagain = tasks.filter_or_create(None, "newer");
|
||||||
|
assert_eq!(idagain, None);
|
||||||
|
assert_position!(tasks, id1.unwrap());
|
||||||
|
assert_eq!(tasks.get_own_history().count(), 2);
|
||||||
|
assert_eq!(tasks.len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -1162,7 +1208,7 @@ mod tasks_test {
|
||||||
let zero = EventId::all_zeros();
|
let zero = EventId::all_zeros();
|
||||||
|
|
||||||
tasks.track_at(Timestamp::from(0), None);
|
tasks.track_at(Timestamp::from(0), None);
|
||||||
assert_eq!(tasks.history.len(), 1);
|
assert_eq!(tasks.history.len(), 0);
|
||||||
|
|
||||||
let almost_now: Timestamp = Timestamp::now() - 12u64;
|
let almost_now: Timestamp = Timestamp::now() - 12u64;
|
||||||
tasks.track_at(Timestamp::from(11), Some(zero));
|
tasks.track_at(Timestamp::from(11), Some(zero));
|
||||||
|
@ -1170,9 +1216,19 @@ mod tasks_test {
|
||||||
assert_position!(tasks, zero);
|
assert_position!(tasks, zero);
|
||||||
assert!(tasks.time_tracked(zero) > almost_now.as_u64());
|
assert!(tasks.time_tracked(zero) > almost_now.as_u64());
|
||||||
|
|
||||||
tasks.track_at(Timestamp::from(22), None);
|
// Because None is backtracked by one to avoid conflicts
|
||||||
assert_eq!(tasks.get_own_history().unwrap().len(), 4);
|
tasks.track_at(Timestamp::from(22 + 1), None);
|
||||||
|
assert_eq!(tasks.get_own_history().count(), 2);
|
||||||
assert_eq!(tasks.time_tracked(zero), 11);
|
assert_eq!(tasks.time_tracked(zero), 11);
|
||||||
|
tasks.track_at(Timestamp::from(22 + 1), Some(zero));
|
||||||
|
assert_eq!(tasks.get_own_history().count(), 3);
|
||||||
|
assert!(tasks.time_tracked(zero) > 999);
|
||||||
|
|
||||||
|
let some = tasks.make_task("some");
|
||||||
|
tasks.track_at(Timestamp::from(22 + 1), Some(some));
|
||||||
|
assert_eq!(tasks.get_own_history().count(), 4);
|
||||||
|
assert_eq!(tasks.time_tracked(zero), 12);
|
||||||
|
assert!(tasks.time_tracked(some) > 999);
|
||||||
|
|
||||||
// TODO test received events
|
// TODO test received events
|
||||||
}
|
}
|
||||||
|
@ -1184,7 +1240,7 @@ mod tasks_test {
|
||||||
let zero = EventId::all_zeros();
|
let zero = EventId::all_zeros();
|
||||||
|
|
||||||
tasks.track_at(Timestamp::from(Timestamp::now().as_u64() + 100), Some(zero));
|
tasks.track_at(Timestamp::from(Timestamp::now().as_u64() + 100), Some(zero));
|
||||||
assert_eq!(timestamps(tasks.history.values().nth(0).unwrap().into_iter(), &vec![&zero]).collect_vec().len(), 2)
|
assert_eq!(timestamps(tasks.get_own_history(), &vec![&zero]).collect_vec().len(), 2)
|
||||||
// TODO Does not show both future and current tracking properly, need to split by current time
|
// TODO Does not show both future and current tracking properly, need to split by current time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1234,7 +1290,7 @@ mod tasks_test {
|
||||||
|
|
||||||
tasks.move_to(Some(t1));
|
tasks.move_to(Some(t1));
|
||||||
assert_position!(tasks, t1);
|
assert_position!(tasks, t1);
|
||||||
assert_eq!(tasks.get_own_history().unwrap().len(), 3);
|
assert_eq!(tasks.get_own_history().count(), 3);
|
||||||
assert_eq!(tasks.relative_path(t4), "t2>t4");
|
assert_eq!(tasks.relative_path(t4), "t2>t4");
|
||||||
assert_eq!(tasks.visible_tasks().len(), 2);
|
assert_eq!(tasks.visible_tasks().len(), 2);
|
||||||
tasks.depth = 2;
|
tasks.depth = 2;
|
||||||
|
|
Loading…
Reference in New Issue