feat: undo function with @

This commit is contained in:
xeruf 2024-08-01 14:07:40 +03:00
parent afd6f2f77a
commit 0ee4daea1b
4 changed files with 174 additions and 145 deletions

View File

@ -101,8 +101,9 @@ Dots can be repeated to move to parent tasks.
- `:[IND][COL]` - add property column COL at IND or end, if it already exists remove property column COL or IND - `:[IND][COL]` - add property column COL at IND or end, if it already exists remove property column COL or IND
- `>[TEXT]` - Complete active task and move to parent, with optional state description - `>[TEXT]` - Complete active task and move to parent, with optional state description
- `<[TEXT]` - Close active task and move to parent, with optional state description - `<[TEXT]` - Close active task and move to parent, with optional state description
- `|TEXT` - Set state for current task from text (also aliased to `/` for now) - `!TEXT` - Set state for current task from text
- `-TEXT` - add text note (comment / description) - `-TEXT` - add text note (comment / description)
- `@` - undoes last action (moving in place or upwards confirms pending actions)
Property Filters: Property Filters:
@ -147,10 +148,8 @@ Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/
## Plans ## Plans
- Task markdown support? - Task markdown support? - colored
- Time tracking: Active not as task state, ability to postpone task and add planned timestamps (calendar entry) - Time tracking: Ability to postpone task and add planned timestamps (calendar entry)
+ Personal time tracking
+ Postponing Tasks
- Parse Hashtag tags from task name - Parse Hashtag tags from task name
- Unified Filter object - Unified Filter object
-> include subtasks of matched tasks -> include subtasks of matched tasks

View File

@ -1,3 +1,4 @@
use std::cell::RefCell;
use std::env::{args, var}; use std::env::{args, var};
use std::fmt::Display; use std::fmt::Display;
use std::fs; use std::fs;
@ -22,21 +23,36 @@ mod tasks;
const TASK_KIND: u64 = 1621; const TASK_KIND: u64 = 1621;
const TRACKING_KIND: u64 = 1650; const TRACKING_KIND: u64 = 1650;
type Events = Vec<Event>;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct EventSender { struct EventSender {
tx: Sender<Event>, tx: Sender<Events>,
keys: Keys, keys: Keys,
queue: RefCell<Events>,
} }
impl EventSender { impl EventSender {
fn submit(&self, event_builder: EventBuilder) -> Option<Event> { fn submit(&self, event_builder: EventBuilder) -> Event {
or_print(event_builder.to_event(&self.keys)).inspect(|event| { event_builder.to_event(&self.keys)
or_print(self.tx.send(event.clone())); .inspect(|e| self.queue.borrow_mut().push(e.clone()))
}) .unwrap()
}
fn flush(&self) {
or_print(self.tx.send(self.clear()));
}
fn clear(&self) -> Events {
debug!("Cleared queue {:?}", self.queue.borrow());
self.queue.replace(Vec::with_capacity(3))
} }
pub(crate) fn pubkey(&self) -> PublicKey { pub(crate) fn pubkey(&self) -> PublicKey {
self.keys.public_key() self.keys.public_key()
} }
} }
impl Drop for EventSender {
fn drop(&mut self) {
self.flush()
}
}
fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> { fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> {
match result { match result {
@ -137,10 +153,11 @@ async fn main() {
client.connect().await; client.connect().await;
let (tx, rx) = mpsc::channel::<Event>(); let (tx, rx) = mpsc::channel();
let mut tasks: Tasks = Tasks::from(EventSender { let mut tasks: Tasks = Tasks::from(EventSender {
keys: keys.clone(), keys,
tx, tx,
queue: Default::default(),
}); });
let sub_id: SubscriptionId = client.subscribe(vec![Filter::new()], None).await; let sub_id: SubscriptionId = client.subscribe(vec![Filter::new()], None).await;
@ -168,9 +185,9 @@ async fn main() {
let sender = tokio::spawn(async move { let sender = tokio::spawn(async move {
while let Ok(e) = rx.recv() { while let Ok(e) = rx.recv() {
trace!("Sending {}", e.id); trace!("Sending {:?}", e);
// TODO send in batches // TODO batch up further
let _ = client.send_event(e).await; let _ = client.batch_event(e, RelaySendOptions::new()).await;
} }
info!("Stopping listeners..."); info!("Stopping listeners...");
client.unsubscribe_all().await; client.unsubscribe_all().await;
@ -220,7 +237,9 @@ async fn main() {
"" ""
}; };
match op { match op {
None => {} None => {
tasks.flush()
}
Some(':') => match iter.next().and_then(|s| s.to_digit(10)) { Some(':') => match iter.next().and_then(|s| s.to_digit(10)) {
Some(digit) => { Some(digit) => {
@ -268,29 +287,36 @@ async fn main() {
} }
}, },
Some('?') => {
tasks.set_state_filter(Some(arg.to_string()).filter(|s| !s.is_empty()));
}
Some('-') => tasks.add_note(arg), Some('-') => tasks.add_note(arg),
Some('>') => { Some('>') => {
tasks.update_state(arg, |_| Some(State::Done)); tasks.update_state(arg, State::Done);
tasks.move_up(); tasks.move_up();
} }
Some('<') => { Some('<') => {
tasks.update_state(arg, |_| Some(State::Closed)); tasks.update_state(arg, State::Closed);
tasks.move_up(); tasks.move_up();
} }
Some('|') | Some('/') => match tasks.get_position() { Some('@') => {
tasks.undo();
}
Some('?') => {
tasks.set_state_filter(Some(arg.to_string()).filter(|s| !s.is_empty()));
}
Some('!') => match tasks.get_position() {
None => { None => {
warn!("First select a task to set its state!"); warn!("First select a task to set its state!");
} }
Some(id) => { Some(id) => {
tasks.set_state_for(&id, arg); tasks.set_state_for(id, arg, match arg {
tasks.move_to(tasks.get_position()); "Closed" => State::Closed,
"Done" => State::Done,
_ => State::Open,
});
} }
}, },
@ -303,7 +329,7 @@ async fn main() {
let mut pos = tasks.get_position(); let mut pos = tasks.get_position();
for _ in iter.take_while(|c| c == &'.') { for _ in iter.take_while(|c| c == &'.') {
dots += 1; dots += 1;
pos = tasks.get_parent(pos); pos = tasks.get_parent(pos).cloned();
} }
let slice = &input[dots..]; let slice = &input[dots..];
if slice.is_empty() { if slice.is_empty() {
@ -316,9 +342,7 @@ async fn main() {
continue; continue;
} }
pos = EventId::parse(slice).ok().or_else(|| { pos = EventId::parse(slice).ok().or_else(|| {
// TODO check what is more intuitive: // TODO rebuild and use for plaintext too
// currently resets filters before filtering again, maybe keep them
tasks.move_to(pos);
let mut filtered: Vec<EventId> = tasks let mut filtered: Vec<EventId> = tasks
.current_tasks() .current_tasks()
.into_iter() .into_iter()
@ -339,7 +363,7 @@ async fn main() {
match filtered.len() { match filtered.len() {
0 => { 0 => {
// No match, new task // No match, new task
tasks.make_task(slice) Some(tasks.make_task(slice))
} }
1 => { 1 => {
// One match, activate // One match, activate

View File

@ -39,8 +39,8 @@ impl Task {
&self.event.id &self.event.id
} }
pub(crate) fn parent_id(&self) -> Option<EventId> { pub(crate) fn parent_id(&self) -> Option<&EventId> {
self.parents.first().cloned() self.parents.first()
} }
pub(crate) fn get_title(&self) -> String { pub(crate) fn get_title(&self) -> String {
@ -68,7 +68,7 @@ impl Task {
}) })
}) })
} }
pub(crate) fn state(&self) -> Option<TaskState> { pub(crate) fn state(&self) -> Option<TaskState> {
self.states().max_by_key(|t| t.time) self.states().max_by_key(|t| t.time)
} }
@ -77,21 +77,8 @@ impl Task {
self.state().map_or(State::Open, |s| s.state) self.state().map_or(State::Open, |s| s.state)
} }
pub(crate) fn set_state( pub(crate) fn state_or_default(&self) -> TaskState {
&mut self, self.state().unwrap_or_else(|| self.default_state())
sender: &EventSender,
state: State,
comment: &str,
) -> Option<Event> {
sender
.submit(EventBuilder::new(
state.into(),
comment,
vec![Tag::event(self.event.id)],
))
.inspect(|e| {
self.props.insert(e.clone());
})
} }
fn default_state(&self) -> TaskState { fn default_state(&self) -> TaskState {
@ -119,7 +106,7 @@ impl Task {
match property { match property {
"id" => Some(self.event.id.to_string()), "id" => Some(self.event.id.to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()), "parentid" => self.parent_id().map(|i| i.to_string()),
"state" => self.state().map(|s| s.to_string()), "state" => Some(self.state_or_default().get_label()),
"name" => Some(self.event.content.clone()), "name" => Some(self.event.content.clone()),
"desc" => self.descriptions().last().cloned(), "desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().join(" ")), "description" => Some(self.descriptions().join(" ")),

View File

@ -56,7 +56,7 @@ impl Tasks {
position: None, position: None,
view: Default::default(), view: Default::default(),
tags: Default::default(), tags: Default::default(),
state: Some(State::Open.to_string()), state: None,
depth: 1, depth: 1,
sender, sender,
} }
@ -67,14 +67,13 @@ impl Tasks {
// Accessors // Accessors
#[inline] #[inline]
pub(crate) fn get_by_id(&self, id: &EventId) -> Option<&Task> { pub(crate) fn get_by_id(&self, id: &EventId) -> Option<&Task> { self.tasks.get(id) }
self.tasks.get(id)
}
#[inline] #[inline]
pub(crate) fn get_position(&self) -> Option<EventId> { pub(crate) fn get_position(&self) -> Option<EventId> { self.position }
self.position
} #[inline]
pub(crate) fn len(&self) -> usize { self.tasks.len() }
/// Ids of all subtasks found for id, including itself /// Ids of all subtasks found for id, including itself
fn get_subtasks(&self, id: EventId) -> Vec<EventId> { fn get_subtasks(&self, id: EventId) -> Vec<EventId> {
@ -164,7 +163,7 @@ impl Tasks {
// Parents // Parents
pub(crate) fn get_parent(&self, id: Option<EventId>) -> Option<EventId> { pub(crate) fn get_parent(&self, id: Option<EventId>) -> Option<&EventId> {
id.and_then(|id| self.get_by_id(&id)) id.and_then(|id| self.get_by_id(&id))
.and_then(|t| t.parent_id()) .and_then(|t| t.parent_id())
} }
@ -263,12 +262,18 @@ impl Tasks {
self.resolve_tasks( self.resolve_tasks(
self.tasks self.tasks
.values() .values()
.filter(|t| t.parent_id() == self.position) .filter(|t| t.parent_id() == self.position.as_ref())
.map(|t| t.get_id()), .map(|t| t.get_id()),
).into_iter() ).into_iter()
.filter(|t| { .filter(|t| {
self.state.as_ref().map_or(true, |state| { let state = t.pure_state();
t.state().is_some_and(|t| t.matches_label(state)) self.state.as_ref().map_or_else(|| {
state == State::Open || (
state == State::Done &&
t.parent_id() != None
)
}, |filter| {
t.state().is_some_and(|t| t.matches_label(filter))
}) && (self.tags.is_empty() }) && (self.tags.is_empty()
|| t.tags.as_ref().map_or(false, |tags| { || t.tags.as_ref().map_or(false, |tags| {
let mut iter = tags.iter(); let mut iter = tags.iter();
@ -281,31 +286,30 @@ impl Tasks {
pub(crate) fn print_tasks(&self) -> Result<(), Error> { pub(crate) fn print_tasks(&self) -> Result<(), Error> {
let mut lock = stdout().lock(); let mut lock = stdout().lock();
if let Some(t) = self.current_task() { if let Some(t) = self.current_task() {
if let Some(state) = t.state() { let state = t.state_or_default();
writeln!( writeln!(
lock, lock,
"{} since {} (total tracked time {}m)", "{} since {} (total tracked time {}m)",
state.get_label(), state.get_label(),
match Local.timestamp_opt(state.time.as_i64(), 0) { match Local.timestamp_opt(state.time.as_i64(), 0) {
Single(time) => { Single(time) => {
let date = time.date_naive(); let date = time.date_naive();
let prefix = match Local::now() let prefix = match Local::now()
.date_naive() .date_naive()
.signed_duration_since(date) .signed_duration_since(date)
.num_days() .num_days()
{ {
0 => "".into(), 0 => "".into(),
1 => "yesterday ".into(), 1 => "yesterday ".into(),
2..=6 => date.format("%a ").to_string(), 2..=6 => date.format("%a ").to_string(),
_ => date.format("%y-%m-%d ").to_string(), _ => date.format("%y-%m-%d ").to_string(),
}; };
format!("{}{}", prefix, time.format("%H:%M")) format!("{}{}", prefix, time.format("%H:%M"))
} }
_ => state.time.to_human_datetime(), _ => state.time.to_human_datetime(),
}, },
self.time_tracked(t.get_id()) / 60 self.time_tracked(t.get_id()) / 60
)?; )?;
}
writeln!(lock, "{}", t.descriptions().join("\n"))?; writeln!(lock, "{}", t.descriptions().join("\n"))?;
} }
// TODO proper columns // TODO proper columns
@ -366,13 +370,18 @@ impl Tasks {
} }
pub(crate) fn move_up(&mut self) { pub(crate) fn move_up(&mut self) {
self.move_to(self.current_task().and_then(|t| t.parent_id())) self.move_to(self.current_task().and_then(|t| t.parent_id()).cloned());
}
pub(crate) fn flush(&self) {
self.sender.flush();
} }
pub(crate) fn move_to(&mut self, id: Option<EventId>) { pub(crate) fn move_to(&mut self, id: Option<EventId>) {
self.view.clear(); self.view.clear();
self.tags.clear(); // TODO unsure if this is needed, needs alternative way to clear self.tags.clear(); // TODO unsure if this is needed, needs alternative way to clear
if id == self.position { if id == self.position {
self.flush();
return; return;
} }
self.position = id; self.position = id;
@ -382,9 +391,10 @@ impl Tasks {
"", "",
id.iter().map(|id| Tag::event(id.clone())), id.iter().map(|id| Tag::event(id.clone())),
) )
).map(|e| { );
self.add(e); if !id.and_then(|id| self.tasks.get(&id)).is_some_and(|t| t.parent_id() == self.position.as_ref()) {
}); self.flush();
}
} }
// Updates // Updates
@ -409,14 +419,28 @@ impl Tasks {
} }
/// Sanitizes input /// Sanitizes input
pub(crate) fn make_task(&mut self, input: &str) -> Option<EventId> { pub(crate) fn make_task(&mut self, input: &str) -> EventId {
self.sender.submit(self.build_task(input.trim())).map(|e| { self.submit(self.build_task(input.trim()))
let id = e.id; }
self.add_task(e);
let state = self.state.clone().unwrap_or("Open".to_string()); pub(crate) fn build_prop(
self.set_state_for(&id, &state); &mut self,
id kind: Kind,
}) comment: &str,
id: EventId,
) -> EventBuilder {
EventBuilder::new(
kind,
comment,
vec![Tag::event(id)],
)
}
fn submit(&mut self, builder: EventBuilder) -> EventId {
let event = self.sender.submit(builder);
let id = event.id;
self.add(event);
id
} }
pub(crate) fn add(&mut self, event: Event) { pub(crate) fn add(&mut self, event: Event) {
@ -448,49 +472,43 @@ impl Tasks {
}); });
} }
pub(crate) fn set_state_for(&mut self, id: &EventId, comment: &str) -> Option<Event> { pub(crate) fn undo(&mut self) {
let t = self.tasks.get_mut(id); self.sender.clear().into_iter().rev().for_each(|event| {
t.and_then(|task| { if let Some(pos) = self.position {
task.set_state( if pos == event.id {
&self.sender, self.move_up()
match comment { }
"Closed" => State::Closed, }
"Done" => State::Done, self.remove(&event)
_ => State::Open, });
},
comment,
)
})
} }
pub(crate) fn update_state_for<F>(&mut self, id: &EventId, comment: &str, f: F) -> Option<Event> fn remove(&mut self, event: &Event) {
where self.tasks.remove(&event.id);
F: FnOnce(&Task) -> Option<State>, self.history.get_mut(&self.sender.pubkey()).map(|t| t.remove(event));
{ self.referenced_tasks(event, |t| { t.props.remove(event); });
self.tasks
.get_mut(id)
.and_then(|task| f(task).and_then(|state| task.set_state(&self.sender, state, comment)))
} }
pub(crate) fn update_state<F>(&mut self, comment: &str, f: F) -> Option<Event> pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId {
where let prop = self.build_prop(
F: FnOnce(&Task) -> Option<State>, state.into(),
comment,
id,
);
self.submit(prop)
}
pub(crate) fn update_state(&mut self, comment: &str, state: State)
{ {
self.position self.position
.and_then(|id| self.update_state_for(&id, comment, f)) .map(|id| self.set_state_for(id, comment, state));
} }
pub(crate) fn add_note(&mut self, note: &str) { pub(crate) fn add_note(&mut self, note: &str) {
match self.position { match self.position {
None => warn!("Cannot add note '{}' without active task", note), None => warn!("Cannot add note '{}' without active task", note),
Some(id) => { Some(id) => {
self.sender self.submit(EventBuilder::text_note(note, vec![]));
.submit(EventBuilder::text_note(note, vec![]))
.map(|e| {
self.tasks.get_mut(&id).map(|t| {
t.props.insert(e.clone());
});
});
} }
} }
} }
@ -541,7 +559,7 @@ impl<'a> Iterator for ParentIterator<'a> {
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.map(|id| assert!(t.children.contains(&id)));
self.prev = self.current; self.prev = self.current;
self.current = t.parent_id(); self.current = t.parent_id().cloned();
t t
}) })
} }
@ -556,50 +574,51 @@ fn test_depth() {
let mut tasks = Tasks::from(EventSender { let mut tasks = Tasks::from(EventSender {
tx, tx,
keys: Keys::generate(), keys: Keys::generate(),
queue: Default::default(),
}); });
let t1 = tasks.make_task("t1"); let t1 = tasks.make_task("t1");
let task1 = tasks.get_by_id(&t1.unwrap()).unwrap(); let task1 = tasks.get_by_id(&t1).unwrap();
assert_eq!(tasks.depth, 1); assert_eq!(tasks.depth, 1);
assert_eq!(task1.state().unwrap().get_label(), "Open"); assert_eq!(task1.pure_state(), State::Open);
debug!("{:?}", tasks); debug!("{:?}", tasks);
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
tasks.depth = 0; tasks.depth = 0;
assert_eq!(tasks.current_tasks().len(), 0); assert_eq!(tasks.current_tasks().len(), 0);
tasks.move_to(t1); tasks.move_to(Some(t1));
tasks.depth = 2; tasks.depth = 2;
assert_eq!(tasks.current_tasks().len(), 0); assert_eq!(tasks.current_tasks().len(), 0);
let t2 = tasks.make_task("t2"); let t2 = tasks.make_task("t2");
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
assert_eq!(tasks.get_task_path(t2), "t1>t2"); assert_eq!(tasks.get_task_path(Some(t2)), "t1>t2");
assert_eq!(tasks.relative_path(t2.unwrap()), "t2"); assert_eq!(tasks.relative_path(t2), "t2");
let t3 = tasks.make_task("t3"); let t3 = tasks.make_task("t3");
assert_eq!(tasks.current_tasks().len(), 2); assert_eq!(tasks.current_tasks().len(), 2);
tasks.move_to(t2); tasks.move_to(Some(t2));
assert_eq!(tasks.current_tasks().len(), 0); assert_eq!(tasks.current_tasks().len(), 0);
let t4 = tasks.make_task("t4"); let t4 = tasks.make_task("t4");
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
assert_eq!(tasks.get_task_path(t4), "t1>t2>t4"); assert_eq!(tasks.get_task_path(Some(t4)), "t1>t2>t4");
assert_eq!(tasks.relative_path(t4.unwrap()), "t4"); assert_eq!(tasks.relative_path(t4), "t4");
tasks.depth = 2; tasks.depth = 2;
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
tasks.depth = -1; tasks.depth = -1;
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
tasks.move_to(t1); tasks.move_to(Some(t1));
assert_eq!(tasks.relative_path(t4.unwrap()), "t2>t4"); assert_eq!(tasks.relative_path(t4), "t2>t4");
assert_eq!(tasks.current_tasks().len(), 2); assert_eq!(tasks.current_tasks().len(), 2);
tasks.depth = 2; tasks.depth = 2;
assert_eq!(tasks.current_tasks().len(), 3); assert_eq!(tasks.current_tasks().len(), 3);
tasks.set_filter(vec![t2.unwrap()]); tasks.set_filter(vec![t2]);
assert_eq!(tasks.current_tasks().len(), 2); assert_eq!(tasks.current_tasks().len(), 2);
tasks.depth = 1; tasks.depth = 1;
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
tasks.depth = -1; tasks.depth = -1;
assert_eq!(tasks.current_tasks().len(), 1); assert_eq!(tasks.current_tasks().len(), 1);
tasks.set_filter(vec![t2.unwrap(), t3.unwrap()]); tasks.set_filter(vec![t2, t3]);
assert_eq!(tasks.current_tasks().len(), 2); assert_eq!(tasks.current_tasks().len(), 2);
tasks.depth = 2; tasks.depth = 2;
assert_eq!(tasks.current_tasks().len(), 3); assert_eq!(tasks.current_tasks().len(), 3);
@ -618,20 +637,20 @@ fn test_depth() {
assert_eq!(tasks.current_tasks().len(), 2); assert_eq!(tasks.current_tasks().len(), 2);
let empty = tasks.make_task(""); let empty = tasks.make_task("");
let empty_task = tasks.get_by_id(&empty.unwrap()).unwrap(); let empty_task = tasks.get_by_id(&empty).unwrap();
let empty_id = empty_task.event.id.to_string(); let empty_id = empty_task.event.id.to_string();
assert_eq!(empty_task.get_title(), empty_id); assert_eq!(empty_task.get_title(), empty_id);
assert_eq!(tasks.get_task_path(empty), empty_id); assert_eq!(tasks.get_task_path(Some(empty)), empty_id);
let zero = EventId::all_zeros(); let zero = EventId::all_zeros();
assert_eq!(tasks.get_task_path(Some(zero)), zero.to_string()); assert_eq!(tasks.get_task_path(Some(zero)), zero.to_string());
tasks.move_to(Some(zero)); tasks.move_to(Some(zero));
let dangling = tasks.make_task("test"); let dangling = tasks.make_task("test");
assert_eq!( assert_eq!(
tasks.get_task_path(dangling), tasks.get_task_path(Some(dangling)),
"0000000000000000000000000000000000000000000000000000000000000000>test" "0000000000000000000000000000000000000000000000000000000000000000>test"
); );
assert_eq!(tasks.relative_path(dangling.unwrap()), "test"); assert_eq!(tasks.relative_path(dangling), "test");
use itertools::Itertools; use itertools::Itertools;
assert_eq!("test toast".split(' ').collect_vec().len(), 3); assert_eq!("test toast".split(' ').collect_vec().len(), 3);