Compare commits

..

14 Commits

5 changed files with 287 additions and 117 deletions

View File

@ -107,20 +107,23 @@ To stop time-tracking completely, simply move to the root of all tasks.
Dot or slash can be repeated to move to parent tasks before acting. Dot or slash can be repeated to move to parent tasks before acting.
- `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND ( - `:[IND][PROP]` - add property column PROP at IND or end,
1-indexed), empty: list properties if it already exists remove property column PROP or IND; empty: list properties
- `::[PROP]` - sort by property PROP (multiple space-separated values allowed) - `::[PROP]` - sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - list tracked times or insert timetracking with the specified offset - `([TIME]` - list tracked times or insert timetracking with the specified offset
such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights` such as `-1d`, `-15 minutes`, `yesterday 17:20`, `in 2 fortnights`
- `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root - `)[TIME]` - stop timetracking with optional offset - also convenience helper to move to root
- `>[TEXT]` - complete active task and move up, with optional status description - `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional status description - `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set status for current task from text and move up (empty: Open) - `!TEXT` - set status for current task from text and move up; empty: Open
- `,[TEXT]` - list notes or add text note (comment / description)
- TBI: `*[INT]` - set priority - can also be used in task creation, with any digit - TBI: `*[INT]` - set priority - can also be used in task creation, with any digit
- `,[TEXT]` - list notes or add text note (stateless task / task description)
- TBI: `;[TEXT]` - list comments or comment on task - TBI: `;[TEXT]` - list comments or comment on task
- TBI: show status history and creation with attribution - TBI: show status history and creation with attribution
- `&` - undo last action (moving in place or upwards confirms pending actions) - `&` - revert
- with string argument, find first matching task in history
- with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions)
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one) - `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters: Property Filters:
@ -129,9 +132,8 @@ Property Filters:
- `+TAG` - add tag filter (empty: list all used tags) - `+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|TIME]` - filter by time or author (pubkey, or `@` for self, TBI: id prefix, name prefix)
- TBI: `**INT` - filter by priority - TBI: `**INT` - filter by priority
- TBI: Filter by time
Status descriptions can be used for example for Kanban columns or review flows. Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks. An active tag or status filter will also set that attribute for newly created tasks.
@ -206,12 +208,15 @@ Suggestions welcome!
- TUI: Clear Terminal? Refresh on empty prompt after timeout? - TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar - Kanban, GANTT, Calendar
- Web Interface, Messenger integrations - Web Interface
- Messenger Integrations (Telegram Bot)
- n8n node
- Caldav Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward
## Exemplary Workflows ## Exemplary Workflows
- Freelancer - Freelancer
- Family Chore management - Family Chore Management
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays - Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
+ Permissions via status or assignment (reassignment?) + Permissions via status or assignment (reassignment?)
+ Tasks can be blocked while having a status (e.g. kanban column) + Tasks can be blocked while having a status (e.g. kanban column)

View File

@ -5,26 +5,27 @@ use nostr_sdk::TagStandard::Hashtag;
use crate::task::{MARKER_PARENT, State}; use crate::task::{MARKER_PARENT, State};
pub const METADATA_KIND: u16 = 0; pub const TASK_KIND: Kind = Kind::GitIssue;
pub const NOTE_KIND: u16 = 1; pub const PROCEDURE_KIND_ID: u16 = 1639;
pub const TASK_KIND: u16 = 1621; pub const PROCEDURE_KIND: Kind = Kind::Regular(PROCEDURE_KIND_ID);
pub const TRACKING_KIND: u16 = 1650; pub const TRACKING_KIND: Kind = Kind::Regular(1650);
pub const KINDS: [u16; 3] = [ pub const BASIC_KINDS: [Kind; 4] = [
METADATA_KIND, Kind::Metadata,
NOTE_KIND, Kind::TextNote,
TASK_KIND, TASK_KIND,
Kind::Bookmarks,
]; ];
pub const PROP_KINDS: [u16; 6] = [ pub const PROP_KINDS: [Kind; 6] = [
TRACKING_KIND, TRACKING_KIND,
State::Open as u16, Kind::GitStatusOpen,
State::Done as u16, Kind::GitStatusApplied,
State::Closed as u16, Kind::GitStatusClosed,
State::Pending as u16, Kind::GitStatusDraft,
State::Procedure as u16 PROCEDURE_KIND,
]; ];
// TODO: use formatting - bold / heading / italics - and generate from code
/// Helper for available properties. /// Helper for available properties.
/// TODO: use formatting - bold / heading / italics - and generate from code
pub const PROPERTY_COLUMNS: &str = pub const PROPERTY_COLUMNS: &str =
"# Available Properties "# Available Properties
Immutable: Immutable:
@ -66,7 +67,7 @@ pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>)
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(format_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(TASK_KIND), name, tags)
} }
pub(crate) fn build_prop( pub(crate) fn build_prop(

View File

@ -28,7 +28,7 @@ use tokio::time::timeout;
use xdg::BaseDirectories; use xdg::BaseDirectories;
use crate::helpers::*; use crate::helpers::*;
use crate::kinds::{KINDS, PROP_KINDS, PROPERTY_COLUMNS, TRACKING_KIND}; use crate::kinds::{BASIC_KINDS, PROP_KINDS, PROPERTY_COLUMNS, TRACKING_KIND};
use crate::task::{MARKER_DEPENDS, State}; use crate::task::{MARKER_DEPENDS, State};
use crate::tasks::{PropertyCollection, StateFilter, Tasks}; use crate::tasks::{PropertyCollection, StateFilter, Tasks};
@ -95,9 +95,9 @@ impl EventSender {
} }
let mut queue = self.queue.borrow_mut(); let mut queue = self.queue.borrow_mut();
Ok(event_builder.to_event(&self.keys).inspect(|event| { Ok(event_builder.to_event(&self.keys).inspect(|event| {
if event.kind.as_u16() == TRACKING_KIND { if event.kind == TRACKING_KIND {
queue.retain(|e| { queue.retain(|e| {
e.kind.as_u16() != TRACKING_KIND e.kind != TRACKING_KIND
}); });
} }
queue.push(event.clone()); queue.push(event.clone());
@ -115,7 +115,7 @@ impl EventSender {
} }
/// Sends all pending events if there is a non-tracking event /// Sends all pending events if there is a non-tracking event
fn flush(&self) { fn flush(&self) {
if self.queue.borrow().iter().any(|event| event.kind.as_u16() != TRACKING_KIND) { if self.queue.borrow().iter().any(|event| event.kind != TRACKING_KIND) {
self.force_flush() self.force_flush()
} }
} }
@ -240,14 +240,10 @@ async fn main() -> Result<()> {
let mut notifications = client.notifications(); let mut notifications = client.notifications();
client.connect().await; client.connect().await;
let sub1 = client.subscribe(vec![ let sub1 = client.subscribe(vec![Filter::new().kinds(BASIC_KINDS)], None).await;
Filter::new().kinds(KINDS.into_iter().map(Kind::from))
], 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)], None).await;
Filter::new().kinds(PROP_KINDS.into_iter().map(Kind::from))
], None).await;
info!("Subscribed to updates with {:?}", sub2); info!("Subscribed to updates with {:?}", sub2);
let metadata = var("USER").ok().map( let metadata = var("USER").ok().map(
@ -379,7 +375,7 @@ async fn main() -> Result<()> {
match op { match op {
None => { None => {
debug!("Flushing Tasks because of empty command"); debug!("Flushing Tasks because of empty command");
tasks.flush() tasks.flush();
} }
Some(':') => { Some(':') => {
@ -437,17 +433,30 @@ async fn main() -> Result<()> {
} }
Some('&') => { Some('&') => {
tasks.undo(); match arg {
None => tasks.undo(),
Some(text) => match text.parse::<u8>() {
Ok(int) => {
tasks.move_back_by(int as usize);
}
_ => {
if !tasks.move_back_to(text) {
warn!("Did not find a match in history for \"{text}\"");
continue;
}
}
}
}
} }
Some('@') => { Some('@') => {
match arg { match arg {
None => { None => {
let today = Timestamp::now() - 80_000; let today = Timestamp::now() - 80_000;
info!("Filtering for tasks created in the last 22 hours"); info!("Filtering for tasks opened 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.last_state_update() > today)
.map(|t| t.event.id) .map(|t| t.event.id)
.collect() .collect()
); );
@ -475,11 +484,11 @@ async fn main() -> Result<()> {
parse_hour(arg, 1) parse_hour(arg, 1)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local))) .or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
.map(|time| { .map(|time| {
info!("Filtering for tasks created after {}", format_datetime_relative(time)); info!("Filtering for tasks opened after {}", format_datetime_relative(time));
let threshold = time.to_utc().timestamp(); let threshold = time.to_utc().timestamp();
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.as_u64() as i64 > threshold) .filter(|t| t.last_state_update().as_u64() as i64 > threshold)
.map(|t| t.event.id) .map(|t| t.event.id)
.collect() .collect()
); );
@ -490,7 +499,19 @@ async fn main() -> Result<()> {
} }
Some('*') => { Some('*') => {
info!("Setting priority not yet implemented") match arg {
None => match tasks.get_position_ref() {
None => {
info!("Filtering for bookmarked tasks");
tasks.set_filter_bookmarks()
},
Some(pos) => {
info!("Toggling bookmark");
or_warn!(tasks.toggle_bookmark(*pos));
}
},
Some(arg) => info!("Setting priority not yet implemented"),
}
} }
Some('|') => Some('|') =>
@ -530,9 +551,20 @@ async fn main() -> Result<()> {
Some('!') => Some('!') =>
match tasks.get_position() { match tasks.get_position() {
None => warn!("First select a task to set its state!"), None => {
warn!("First select a task to set its state!");
info!("Usage: ![(Open|Procedure|Pending|Done|Closed): ][Statename]");
}
Some(id) => { Some(id) => {
tasks.set_state_for_with(id, arg_default); 'block: {
if let Some((left, right)) = arg_default.split_once(": ") {
if let Ok(state) = left.try_into() {
tasks.set_state_for(id, right, state);
break 'block;
}
}
tasks.set_state_for_with(id, arg_default);
}
tasks.move_up(); tasks.move_up();
} }
} }
@ -628,11 +660,6 @@ async fn main() -> Result<()> {
if dots > 1 { if dots > 1 {
info!("Moving up {} tasks", dots - 1) info!("Moving up {} tasks", dots - 1)
} }
} else if let Ok(depth) = slice.parse::<i8>() {
if pos != tasks.get_position_ref() {
tasks.move_to(pos.cloned());
}
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().any(|c| c.is_ascii_uppercase()) { if !slice.chars().any(|c| c.is_ascii_uppercase()) {

View File

@ -11,7 +11,7 @@ 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::{format_timestamp_local, some_non_empty}; use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{is_hashtag, TASK_KIND}; use crate::kinds::{is_hashtag, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
pub static MARKER_PARENT: &str = "parent"; pub static MARKER_PARENT: &str = "parent";
pub static MARKER_DEPENDS: &str = "depends"; pub static MARKER_DEPENDS: &str = "depends";
@ -76,6 +76,7 @@ impl Task {
self.find_refs(MARKER_DEPENDS).collect() self.find_refs(MARKER_DEPENDS).collect()
} }
/// Trimmed event content or stringified id
pub(crate) fn get_title(&self) -> String { pub(crate) fn get_title(&self) -> String {
Some(self.event.content.trim().to_string()) Some(self.event.content.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
@ -91,7 +92,7 @@ impl Task {
} }
pub(crate) fn is_task(&self) -> bool { pub(crate) fn is_task(&self) -> bool {
self.event.kind.as_u16() == TASK_KIND || self.event.kind == TASK_KIND ||
self.states().next().is_some() self.states().next().is_some()
} }
@ -105,8 +106,12 @@ impl Task {
}) })
} }
pub(crate) fn last_state_update(&self) -> Timestamp {
self.state().map(|s| s.time).unwrap_or(self.event.created_at)
}
pub(crate) fn state(&self) -> Option<TaskState> { pub(crate) fn state(&self) -> Option<TaskState> {
self.states().max_by_key(|t| t.time) self.states().last()
} }
pub(crate) fn pure_state(&self) -> State { pub(crate) fn pure_state(&self) -> State {
@ -215,7 +220,6 @@ impl Display for TaskState {
} }
} }
pub const PROCEDURE_KIND: u16 = 1639;
#[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)] #[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
pub(crate) enum State { pub(crate) enum State {
/// Actionable /// Actionable
@ -227,16 +231,19 @@ pub(crate) enum State {
/// Temporarily not actionable /// Temporarily not actionable
Pending, Pending,
/// Actionable ordered task list /// Actionable ordered task list
Procedure = PROCEDURE_KIND as isize, Procedure = PROCEDURE_KIND_ID as isize,
} }
impl From<&str> for State { impl TryFrom<&str> for State {
fn from(value: &str) -> Self { type Error = ();
match value {
"Closed" => State::Closed, fn try_from(value: &str) -> Result<Self, Self::Error> {
"Done" => State::Done, match value.to_ascii_lowercase().as_str() {
"Pending" => State::Pending, "closed" => Ok(State::Closed),
"Proc" | "Procedure" | "List" => State::Procedure, "done" => Ok(State::Done),
_ => State::Open, "pending" => Ok(State::Pending),
"proc" | "procedure" | "list" => Ok(State::Procedure),
"open" => Ok(State::Open),
_ => Err(()),
} }
} }
} }
@ -244,13 +251,18 @@ impl TryFrom<Kind> for State {
type Error = (); type Error = ();
fn try_from(value: Kind) -> Result<Self, Self::Error> { fn try_from(value: Kind) -> Result<Self, Self::Error> {
match value.as_u16() { match value {
1630 => Ok(State::Open), Kind::GitStatusOpen => Ok(State::Open),
1631 => Ok(State::Done), Kind::GitStatusApplied => Ok(State::Done),
1632 => Ok(State::Closed), Kind::GitStatusClosed => Ok(State::Closed),
1633 => Ok(State::Pending), Kind::GitStatusDraft => Ok(State::Pending),
PROCEDURE_KIND => Ok(State::Procedure), _ => {
_ => Err(()), if value == PROCEDURE_KIND {
Ok(State::Procedure)
} else {
Err(())
}
}
} }
} }
} }

View File

@ -32,6 +32,8 @@ pub(crate) struct Tasks {
history: HashMap<PublicKey, BTreeMap<Timestamp, 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>,
/// Own pinned tasks
bookmarks: Vec<EventId>,
/// The task properties currently visible /// The task properties currently visible
properties: Vec<String>, properties: Vec<String>,
@ -104,7 +106,12 @@ 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::from(url, tx, keys)); let mut new = Self::with_sender(EventSender::from(url, tx, keys));
metadata.map(|m| new.users.insert(keys.public_key(), m)); metadata.map(|m| new.users.insert(keys.public_key(), m));
new new
@ -115,6 +122,8 @@ impl Tasks {
tasks: Default::default(), tasks: Default::default(),
history: Default::default(), history: Default::default(),
users: Default::default(), users: Default::default(),
bookmarks: Default::default(),
properties: [ properties: [
"author", "author",
"state", "state",
@ -130,6 +139,7 @@ impl Tasks {
"rtime", "rtime",
"name", "name",
].into_iter().map(|s| s.to_string()).collect(), ].into_iter().map(|s| s.to_string()).collect(),
view: Default::default(), view: Default::default(),
tags: Default::default(), tags: Default::default(),
tags_excluded: Default::default(), tags_excluded: Default::default(),
@ -155,13 +165,15 @@ impl Tasks {
self.get_position_at(now()).1 self.get_position_at(now()).1
} }
// TODO binary search
/// Gets last position change before the given timestamp
fn get_position_at(&self, timestamp: Timestamp) -> (Timestamp, Option<&EventId>) { fn get_position_at(&self, timestamp: Timestamp) -> (Timestamp, Option<&EventId>) {
self.history_from(timestamp) self.history_from(timestamp)
.last() .last()
.filter(|e| e.created_at <= timestamp) .filter(|e| e.created_at <= timestamp)
.map_or_else( .map_or_else(
|| (Timestamp::now(), None), || (Timestamp::now(), None),
|e| (e.created_at, referenced_events(e))) |e| (e.created_at, referenced_event(e)))
} }
/// Ids of all subtasks recursively found for id, including itself /// Ids of all subtasks recursively found for id, including itself
@ -226,7 +238,7 @@ impl Tasks {
/// 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.get_own_history(), &vec![&id]).sum::<Duration>().as_secs() Durations::from(self.get_own_events_history(), &vec![&id]).sum::<Duration>().as_secs()
} }
@ -366,20 +378,31 @@ impl Tasks {
} }
pub(crate) fn filtered_tasks<'a>(&'a self, position: Option<&'a EventId>) -> impl Iterator<Item=&Task> + 'a { pub(crate) fn filtered_tasks<'a>(&'a self, position: Option<&'a EventId>) -> impl Iterator<Item=&Task> + 'a {
let current: HashMap<&EventId, &Task> = self.resolve_tasks(self.children_of(position)).map(|t| (t.get_id(), t)).collect();
let bookmarks =
if current.is_empty() {
vec![]
} else {
self.bookmarks.iter()
.filter(|id| !position.is_some_and(|p| &p == id) && !current.contains_key(id))
.filter_map(|id| self.get_by_id(id))
.collect_vec()
};
// TODO use ChildIterator // TODO use ChildIterator
self.resolve_tasks(self.children_of(position)) current.into_values().chain(
.filter(move |t| { bookmarks
// TODO apply filters in transit ).filter(move |t| {
self.state.matches(t) && // TODO apply filters in transit
t.tags.as_ref().map_or(true, |tags| { self.state.matches(t) &&
!tags.iter().any(|tag| self.tags_excluded.contains(tag)) t.tags.as_ref().map_or(true, |tags| {
}) && !tags.iter().any(|tag| self.tags_excluded.contains(tag))
(self.tags.is_empty() || }) &&
t.tags.as_ref().map_or(false, |tags| { (self.tags.is_empty() ||
let mut iter = tags.iter(); t.tags.as_ref().map_or(false, |tags| {
self.tags.iter().all(|tag| iter.any(|t| t == tag)) let mut iter = tags.iter();
})) self.tags.iter().all(|tag| iter.any(|t| t == tag))
}) }))
})
} }
pub(crate) fn visible_tasks(&self) -> Vec<&Task> { pub(crate) fn visible_tasks(&self) -> Vec<&Task> {
@ -399,7 +422,7 @@ impl Tasks {
let now = &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.get_own_history(), &[t.get_id()]) timestamps(self.get_own_events_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;
@ -507,6 +530,20 @@ impl Tasks {
// Movement and Selection // Movement and Selection
pub(crate) fn toggle_bookmark(&mut self, id: EventId) -> nostr_sdk::Result<Event> {
match self.bookmarks.iter().position(|b| b == &id) {
None => self.bookmarks.push(id),
Some(pos) => { self.bookmarks.remove(pos); }
}
self.sender.submit(
EventBuilder::new(Kind::Bookmarks, "mostr pins",
self.bookmarks.iter().map(|id| Tag::event(*id))))
}
pub(crate) fn set_filter_bookmarks(&mut self) {
self.set_filter(self.bookmarks.clone())
}
pub(crate) fn set_filter(&mut self, view: Vec<EventId>) { pub(crate) fn set_filter(&mut self, view: Vec<EventId>) {
if view.is_empty() { if view.is_empty() {
warn!("No match for filter!") warn!("No match for filter!")
@ -579,7 +616,7 @@ impl Tasks {
let has_space = lowercase_arg.split_ascii_whitespace().count() > 1; let has_space = lowercase_arg.split_ascii_whitespace().count() > 1;
let mut filtered: Vec<EventId> = Vec::with_capacity(32); let mut filtered: Vec<EventId> = Vec::with_capacity(32);
let mut filtered_more: Vec<EventId> = Vec::with_capacity(32); let mut filtered_fuzzy: 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();
if lowercase == lowercase_arg { if lowercase == lowercase_arg {
@ -587,18 +624,26 @@ impl Tasks {
} 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 if has_space { lowercase.starts_with(&lowercase_arg) } else { lowercase.split_ascii_whitespace().any(|word| word.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_fuzzy.push(task.event.id)
} }
} }
for task in self.tasks.values() { for task in self.tasks.values() {
// Find global exact match
if task.event.content.to_ascii_lowercase() == lowercase_arg && if task.event.content.to_ascii_lowercase() == lowercase_arg &&
!self.traverse_up_from(Some(*task.get_id())).any(|t| t.pure_state() == State::Closed) { !self.traverse_up_from(Some(*task.get_id())).any(|t| t.pure_state() == State::Closed) {
// exclude closed tasks and their subtasks // exclude closed tasks and their subtasks
return vec![task.event.id]; return vec![task.event.id];
} }
} }
if filtered.is_empty() { if filtered.is_empty() {
return filtered_more; filtered = filtered_fuzzy;
}
let pos = self.get_position_ref();
let immediate = filtered.iter().filter(
|t| self.get_by_id(t).is_some_and(|t| t.parent_id() == pos)).collect_vec();
if immediate.len() == 1 {
return immediate.into_iter().cloned().collect_vec();
} }
filtered filtered
} }
@ -792,19 +837,28 @@ impl Tasks {
} }
pub(crate) fn add(&mut self, event: Event) { pub(crate) fn add(&mut self, event: Event) {
match event.kind.as_u16() { match event.kind {
TASK_KIND => self.add_task(event), Kind::GitIssue => self.add_task(event),
TRACKING_KIND => Kind::Metadata =>
match self.history.get_mut(&event.pubkey) {
Some(c) => { c.insert(event.created_at, event); }
None => { self.history.insert(event.pubkey, BTreeMap::from([(event.created_at, event)])); }
},
METADATA_KIND =>
match Metadata::from_json(event.content()) { match Metadata::from_json(event.content()) {
Ok(metadata) => { self.users.insert(event.pubkey, metadata); } Ok(metadata) => { self.users.insert(event.pubkey, metadata); }
Err(e) => warn!("Cannot parse metadata: {} from {:?}", e, event) Err(e) => warn!("Cannot parse metadata: {} from {:?}", e, event)
} }
_ => self.add_prop(event), Kind::Bookmarks => {
if event.pubkey == self.sender.pubkey() {
self.bookmarks = referenced_events(&event).cloned().collect_vec()
}
}
_ => {
if event.kind == TRACKING_KIND {
match self.history.get_mut(&event.pubkey) {
Some(c) => { c.insert(event.created_at, event); }
None => { self.history.insert(event.pubkey, BTreeMap::from([(event.created_at, event)])); }
}
} else {
self.add_prop(event)
}
}
} }
} }
@ -826,7 +880,7 @@ impl Tasks {
t.props.insert(event.clone()); t.props.insert(event.clone());
}); });
if !found { if !found {
if event.kind.as_u16() == NOTE_KIND { if event.kind.as_u16() == 1 {
self.add_task(event); self.add_task(event);
return; return;
} }
@ -834,10 +888,40 @@ impl Tasks {
} }
} }
fn get_own_history(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ { fn get_own_history(&self) -> Option<&BTreeMap<Timestamp, Event>> {
self.history.get(&self.sender.pubkey())
}
fn get_own_events_history(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ {
self.history.get(&self.sender.pubkey()).into_iter().flat_map(|t| t.values()) self.history.get(&self.sender.pubkey()).into_iter().flat_map(|t| t.values())
} }
fn history_before_now(&self) -> impl Iterator<Item=&Event> {
self.get_own_history().into_iter().flat_map(|hist| {
let now = now();
hist.values().rev().skip_while(move |e| e.created_at > now)
})
}
pub(crate) fn move_back_to(&mut self, str: &str) -> bool {
let lower = str.to_ascii_lowercase();
let found = self.history_before_now()
.find(|e| referenced_event(e)
.and_then(|id| self.get_by_id(id))
.is_some_and(|t| t.event.content.to_ascii_lowercase().contains(&lower)));
if let Some(event) = found {
self.move_to(referenced_event(event).cloned());
return true;
}
false
}
pub(crate) fn move_back_by(&mut self, steps: usize) {
let id = self.history_before_now().nth(steps)
.and_then(|e| referenced_event(e));
self.move_to(id.cloned())
}
pub(crate) fn undo(&mut self) { pub(crate) fn undo(&mut self) {
let mut count = 0; let mut count = 0;
self.sender.clear().into_iter().rev().for_each(|event| { self.sender.clear().into_iter().rev().for_each(|event| {
@ -851,12 +935,12 @@ impl Tasks {
self.tasks.remove(&event.id); self.tasks.remove(&event.id);
self.history.get_mut(&self.sender.pubkey()) self.history.get_mut(&self.sender.pubkey())
.map(|t| t.retain(|t, e| e != event && .map(|t| t.retain(|t, e| e != event &&
!referenced_events(e).is_some_and(|id| id == &event.id))); !referenced_event(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); });
} }
pub(crate) fn set_state_for_with(&mut self, id: EventId, comment: &str) { pub(crate) fn set_state_for_with(&mut self, id: EventId, comment: &str) {
self.set_state_for(id, comment, comment.into()); self.set_state_for(id, comment, comment.try_into().unwrap_or(State::Open));
} }
pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId { pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId {
@ -893,8 +977,13 @@ impl Tasks {
// Properties // Properties
pub(crate) fn set_depth(&mut self, depth: i8) { pub(crate) fn set_depth(&mut self, depth: i8) {
if depth < self.depth && !self.view.is_empty() {
self.view.clear();
info!("Cleared search and reduced view depth to {depth}");
} else {
info!("Changed view depth to {depth}");
}
self.depth = depth; self.depth = depth;
info!("Changed view depth to {depth}");
} }
pub(crate) fn get_columns(&mut self) -> &mut Vec<String> { pub(crate) fn get_columns(&mut self) -> &mut Vec<String> {
@ -987,18 +1076,19 @@ pub(crate) fn join_tasks<'a>(
}) })
} }
fn referenced_events(event: &Event) -> Option<&EventId> { fn referenced_events(event: &Event) -> impl Iterator<Item=&EventId> {
event.tags.iter().find_map(|tag| match tag.as_standardized() { event.tags.iter().filter_map(|tag| match tag.as_standardized() {
Some(TagStandard::Event { event_id, .. }) => Some(event_id), Some(TagStandard::Event { event_id, .. }) => Some(event_id),
_ => None _ => None
}) })
} }
fn referenced_event(event: &Event) -> Option<&EventId> {
referenced_events(event).next()
}
fn matching_tag_id<'a>(event: &'a Event, ids: &'a [&'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() { referenced_events(event).find(|id| ids.contains(id))
Some(TagStandard::Event { event_id, .. }) if ids.contains(&event_id) => Some(event_id),
_ => None
})
} }
/// 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
@ -1156,11 +1246,46 @@ mod tasks_test {
}; };
} }
#[test]
fn test_bookmarks() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
let test = tasks.make_task("test: tag");
let parent = tasks.make_task("parent");
assert_eq!(tasks.visible_tasks().len(), 2);
tasks.move_to(Some(parent));
let pin = tasks.make_task("pin");
assert_eq!(tasks.filtered_tasks(None).count(), 2);
assert_eq!(tasks.filtered_tasks(Some(&zero)).count(), 0);
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.filtered_tasks(Some(&pin)).count(), 0);
assert_eq!(tasks.filtered_tasks(Some(&zero)).count(), 0);
tasks.submit(EventBuilder::new(Kind::Bookmarks, "", [Tag::event(pin), Tag::event(zero)]));
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.filtered_tasks(Some(&pin)).count(), 0);
assert_eq!(tasks.filtered_tasks(Some(&zero)).count(), 0);
tasks.move_to(None);
assert_eq!(tasks.visible_tasks().len(), 3);
tasks.set_depth(2);
assert_eq!(tasks.visible_tasks().len(), 3);
tasks.add_tag("tag".to_string());
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.filtered_tasks(None).collect_vec(), vec![tasks.get_by_id(&test).unwrap()]);
tasks.submit(EventBuilder::new(Kind::Bookmarks, "", []));
tasks.clear_filters();
assert_eq!(tasks.visible_tasks().len(), 3);
tasks.set_depth(1);
assert_eq!(tasks.visible_tasks().len(), 2);
}
#[test] #[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().count(), 1); assert_eq!(tasks.get_own_events_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");
@ -1194,11 +1319,11 @@ mod tasks_test {
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); assert_eq!(tasks.get_own_events_history().count(), 1);
let idagain = tasks.filter_or_create(None, "newer"); let idagain = tasks.filter_or_create(None, "newer");
assert_eq!(idagain, None); assert_eq!(idagain, None);
assert_position!(tasks, id1.unwrap()); assert_position!(tasks, id1.unwrap());
assert_eq!(tasks.get_own_history().count(), 2); assert_eq!(tasks.get_own_events_history().count(), 2);
assert_eq!(tasks.len(), 3); assert_eq!(tasks.len(), 3);
} }
@ -1218,15 +1343,15 @@ mod tasks_test {
// Because None is backtracked by one to avoid conflicts // Because None is backtracked by one to avoid conflicts
tasks.track_at(Timestamp::from(22 + 1), None); tasks.track_at(Timestamp::from(22 + 1), None);
assert_eq!(tasks.get_own_history().count(), 2); assert_eq!(tasks.get_own_events_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)); tasks.track_at(Timestamp::from(22 + 1), Some(zero));
assert_eq!(tasks.get_own_history().count(), 3); assert_eq!(tasks.get_own_events_history().count(), 3);
assert!(tasks.time_tracked(zero) > 999); assert!(tasks.time_tracked(zero) > 999);
let some = tasks.make_task("some"); let some = tasks.make_task("some");
tasks.track_at(Timestamp::from(22 + 1), Some(some)); tasks.track_at(Timestamp::from(22 + 1), Some(some));
assert_eq!(tasks.get_own_history().count(), 4); assert_eq!(tasks.get_own_events_history().count(), 4);
assert_eq!(tasks.time_tracked(zero), 12); assert_eq!(tasks.time_tracked(zero), 12);
assert!(tasks.time_tracked(some) > 999); assert!(tasks.time_tracked(some) > 999);
@ -1240,7 +1365,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.get_own_history(), &vec![&zero]).collect_vec().len(), 2) assert_eq!(timestamps(tasks.get_own_events_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
} }
@ -1290,7 +1415,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().count(), 3); assert_eq!(tasks.get_own_events_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;