forked from janek/mostr
1
0
Fork 0

Compare commits

...

23 Commits

Author SHA1 Message Date
xeruf 5dbea00562 feat: make hashtag interactions more dynamic 2024-11-10 20:41:13 +01:00
xeruf cc1e9d4d69 docs(readme): beta hints 2024-11-10 20:29:09 +01:00
xeruf d5e6bd2578 fix(main): fallback when listing empty description 2024-11-10 20:20:34 +01:00
xeruf 60b33b1dd3 fix: make bookmark and priority filter commands more consistent 2024-11-10 20:19:02 +01:00
xeruf 561fd9e1e5 feat: implement priority filtering 2024-11-09 20:41:22 +01:00
xeruf 91b6047f9a feat: implement priority property for task 2024-11-09 20:33:29 +01:00
xeruf 5294d9081f chore(rust): upgrade to nightly to fix build 2024-11-09 20:10:43 +01:00
xeruf b81e5a27bf fix(main): retain current movement when tracking for another time 2024-11-09 20:00:06 +01:00
xeruf 8f0a169677 fix(main): hide Quick Access in a custom search
Matching items are included anyway
2024-11-09 19:36:52 +01:00
xeruf ae525c870f fix: filter from correct position with multiple slashes 2024-11-09 19:36:06 +01:00
xeruf b9307b7b5d feat(main): improve prompt symbol 2024-11-09 19:20:12 +01:00
xeruf e9bee3c114 feat: allow setting priority context for creating tasks 2024-11-09 19:18:42 +01:00
xeruf dc8df51e0f fix: slight interaction and documentation improvements 2024-11-09 18:02:33 +01:00
xeruf cc64c0f493 style(tasks): reformat 2024-11-09 18:01:40 +01:00
xeruf 5a8fa69e4c feat: implement recursive closing and property marker 2024-11-09 18:00:17 +01:00
xeruf f33d890d7f feat: implement priority parsing from task string 2024-11-09 17:06:20 +01:00
xeruf dd78a2f460 fix(tasks): revamp tag delimiter in task creation syntax
Prevent accidental interpretation of title parts as tags
2024-11-08 12:15:32 +01:00
xeruf 5303d0cb41 fix(tasks): set parent for dependent sibling 2024-11-08 11:49:49 +01:00
xeruf 2053f045b2 fix(helpers): add one second to displayed timestamp to produce round times on stopping
Internally, tracking is stopped one second earlier
to prevent random accidental overlaps.
This brings the interface in line with the user input.
2024-11-08 11:35:07 +01:00
xeruf baf93bd788 docs(readme): notes about interfaces 2024-10-18 18:14:24 +02:00
xeruf d8eebcfb6a feat(tasks): filter out tracked intervals smaller than 2mins 2024-10-18 18:13:35 +02:00
xeruf 7f33bdc9ab feat(main): relay switching by substring match 2024-10-18 18:07:37 +02:00
xeruf 306e0e0421 chore: create rust-toolchain.toml to pin rust 1.81 2024-10-16 22:05:48 +02:00
9 changed files with 414 additions and 171 deletions

View File

@ -6,6 +6,7 @@ readme = "README.md"
license = "GPL 3.0" license = "GPL 3.0"
authors = ["melonion"] authors = ["melonion"]
version = "0.5.0" version = "0.5.0"
rust-version = "1.82"
edition = "2021" edition = "2021"
default-run = "mostr" default-run = "mostr"

44
DESIGN.md Normal file
View File

@ -0,0 +1,44 @@
# Mostr Design & Internals
## Nostr Reference
All used nostr kinds are listed on the top of [kinds.rs](./src/kinds.rs)
Mostr mainly uses the following NIPs:
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
+ Tasks have Kind 1621 (originally: git issue - currently no markdown support implemented)
+ TBI: Kind 1622 for task comments
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
- Own Kind 1650 for time-tracking
Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/52.md
- Kind 31922 for GANTT, since it has only Date
- Kind 31923 for Calendar, since it has a time
## Immutability
Apart from user-specific temporary utilities such as the Bookmark List (Kind 10003),
all shared data is immutable, and modifications are recorded as separate events,
providing full audit security.
Deletions are not considered.
### Timestamps
Mostr provides convenient helpers to backdate an action to a limited extent.
But when closing one task with `)10` at 10:00 of the current day
and starting another with `(10` on the same day,
depending on the order of the event ids,
the started task would be terminated immediately
due to the equal timestamp.
That is why I decided to subtract one second from the timestamp
whenever timetracking is stopped,
making sure that the stop event always happens before the start event
when the same timestamp is provided in the interface.
Since the user interface is anyways focused on comprehensible output
and thus slightly fuzzy,
I then also add one second to each timestamp displayed
to make the displayed timestamps more intuitive.

101
README.md
View File

@ -2,22 +2,25 @@
An immutable nested collaborative task manager, powered by nostr! An immutable nested collaborative task manager, powered by nostr!
> Mostr is beta software.
> Do not entrust it exclusively with your data unless you know what you are doing!
> Intermediate versions might not properly persist all changes.
> A failed relay connection currently looses all intermediate changes.
## Quickstart ## Quickstart
First, start a nostr relay, such as First, start a nostr relay, such as
- https://github.com/coracle-social/bucket for local development - https://github.com/coracle-social/bucket for local development
- https://github.com/rnostr/rnostr for production use - https://github.com/rnostr/rnostr for production use
Run development build with: Install rustup and run a development build with:
cargo run cargo run
A `relay` list and private `key` can be placed in config files A `relay` list and private `key` can be placed in config files
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`. under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
Currently, all relays are fetched and synced to, Ideally any project with different collaborators has its own relay.
separation is planned -
ideally for any project with different collaborators,
an own relay will be used.
If not saved, mostr will ask for a relay url If not saved, mostr will ask for a relay url
(entering none is fine too, but your data will not be persisted between sessions) (entering none is fine too, but your data will not be persisted between sessions)
and a private key, alternatively generating one on the fly. and a private key, alternatively generating one on the fly.
@ -27,6 +30,11 @@ Install latest build:
cargo install --path . cargo install --path .
This one-liner can help you stay on the latest version
(optionally add a `cd` to your mostr-directory in front):
git pull && cargo install --path . && mostr
Creating a test task externally: Creating a test task externally:
`nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736` `nostril --envelope --content "test task" --kind 1621 | websocat ws://localhost:4736`
@ -85,16 +93,56 @@ as you work.
The currently active task is automatically time-tracked. The currently active task is automatically time-tracked.
To stop time-tracking completely, simply move to the root of all tasks. To stop time-tracking completely, simply move to the root of all tasks.
Time-tracking by default recursively summarizes
### Priorities
Task priorities can be set as any natural number,
with higher numbers denoting higher priorities.
The syntax here allows for very convenient incremental usage:
By default, using priorities between 1 and 9 is recommended,
with an exemplary interpretation like this:
* 1 Ideas / "Someday"
* 2 Later
* 3 Soon
* 4 Relevant
* 5 Important
* 9 DO NOW
Internally, when giving a single digit, a 0 is appended,
so that the default priorities increment in steps of 10.
So in case you need more than 10 priorities,
instead of stacking them on top,
you can granularly add them in between.
For example, `12` is in between `1` and `2`
which are equivalent to `10` and `20`,
not above `9` but above `09`!
By default, only tasks with priority `35` and upward are shown
so you can focus on what matters,
but you can temporarily override that using `**PRIO`.
### Quick Access
Paper-based lists are often popular because you can quickly put down a bunch of items.
Mostr offers three useful workflows depending on the use-case:
If you want to TBC...
- temporary task with subtasks (especially handy for progression)
- Filter by recently created
- Pin to bookmarks
- high priority
## Reference ## Reference
### Command Syntax ### Command Syntax
`TASK` creation syntax: `NAME: TAG1 TAG2 ...` `TASK` creation syntax: `NAME #TAG *PRIO # TAG1 TAG2 ...`
- `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 + paste text with newlines to create one task per line
- `.` - clear all filters - `.` - clear all filters
- `.TASK` - `.TASK`
+ activate task by id + activate task by id
@ -103,7 +151,8 @@ To stop time-tracking completely, simply move to the root of all tasks.
- `.2` - set view depth to the given number (how many subtask levels to show, default is 1) - `.2` - set view depth to the given number (how many subtask levels to show, default is 1)
- `/[TEXT]` - activate task or filter by smart-case substring match (empty: move to root) - `/[TEXT]` - activate task or filter by smart-case substring match (empty: move to root)
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task) - `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - (un)mark current task as procedure or create a sibling task depending on the current one and move up - `|[TASK]` - mark current task as procedure or create a sibling task depending on the current one and move up
- sibling task shortcut?
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.
Append `@TIME` to any task creation or change command to record the action with the given time. Append `@TIME` to any task creation or change command to record the action with the given time.
@ -118,14 +167,14 @@ Append `@TIME` to any task creation or change command to record the action with
- `<[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
- `!TIME: REASON` - defer current task to date - `!TIME: REASON` - defer current task to date
- TBI: `*[INT]` - set priority - can also be used in task creation, with any digit - `,[TEXT]` - list notes or add text (activity / task description)
- `,[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
- `&` - revert - `&` - revert
- with string argument, find first matching task in history - with string argument, find first matching task in history
- with int argument, jump back X tasks in history - with int argument, jump back X tasks in history
- undo last action (moving in place or upwards confirms pending actions) - undo last action (moving in place or upwards confirms pending actions)
- `*` - (un)bookmark current task or list all bookmarks
- `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:
@ -133,9 +182,9 @@ Property Filters:
- `#TAG1 TAG2` - set tag filter - `#TAG1 TAG2` - set tag filter
- `+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` - set status filter (type or description) - plain `?` to reset, `??` to show all
- `*INT` - set priority filter - `**` to reset
- `@[AUTHOR|TIME]` - 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
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.
@ -145,21 +194,6 @@ An active tag or status filter will also set that attribute for newly created ta
- TBI = To Be Implemented - TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one - `. TASK` - create and enter a new task even if the name matches an existing one
## Nostr reference
Mostr mainly uses the following NIPs:
- Kind 1 for task descriptions and permanent tasks, can contain task property updates (tags, priority)
- Issue Tracking: https://github.com/nostr-protocol/nips/blob/master/34.md
+ Tasks have Kind 1621 (originally: git issue - currently no markdown support implemented)
+ TBI: Kind 1622 for task comments
+ Kind 1630-1633: Task Status (1630 Open, 1631 Done, 1632 Closed, 1633 Pending)
- Own Kind 1650 for time-tracking
Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/52.md
- Kind 31922 for GANTT, since it has only Date
- Kind 31923 for Calendar, since it has a time
## Plans ## Plans
- Handle event sending rejections (e.g. permissions) - Handle event sending rejections (e.g. permissions)
@ -197,14 +231,17 @@ Suggestions welcome!
+ Subtask progress immediate/all/leafs + Subtask progress immediate/all/leafs
+ path full / leaf / top + path full / leaf / top
### Interfaces ### Interfaces & Integrations
- 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 (Telegram Bot)
- n8n node - n8n node
- Caldav Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward - Webcal Feed: Scheduled (planning) / Tracked (events, timetracking) with args for how far back/forward
Interfaces:
- text-based REPL for terminal and messengers
- interactive UI for web, mobile, desktop e.g. https://docs.slint.dev/latest/docs/slint/src/introduction/
## Exemplary Workflows - User Stories ## Exemplary Workflows - User Stories
@ -249,7 +286,7 @@ since they will automatically take on that context.
By automating these contexts based on triggers, scripts or time, By automating these contexts based on triggers, scripts or time,
relevant tasks can be surfaced automatically. relevant tasks can be surfaced automatically.
#### Example #### Vision of Work-Life-Balance for Freelancer
In the morning, your groggy brain is good at divergent thinking, In the morning, your groggy brain is good at divergent thinking,
and you like to do sports in the morning. and you like to do sports in the morning.

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly-2024-11-09"

View File

@ -118,7 +118,7 @@ pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
where where
F: Fn(DateTime<Local>) -> String, F: Fn(DateTime<Local>) -> String,
{ {
match Local.timestamp_opt(stamp.as_u64() as i64, 0) { match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
Single(time) => formatter(time), Single(time) => formatter(time),
_ => stamp.to_human_datetime(), _ => stamp.to_human_datetime(),
} }
@ -149,4 +149,4 @@ pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) ->
-3..=3 => format_timestamp(stamp, "%a %H:%M"), -3..=3 => format_timestamp(stamp, "%a %H:%M"),
_ => format_timestamp_local(stamp), _ => format_timestamp_local(stamp),
} }
} }

View File

@ -1,10 +1,10 @@
use crate::task::{State, MARKER_PARENT};
use crate::tasks::HIGH_PRIO;
use itertools::Itertools; use itertools::Itertools;
use log::info; use log::info;
use nostr_sdk::TagStandard::Hashtag; use nostr_sdk::TagStandard::Hashtag;
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard}; use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
use std::collections::HashSet; use std::borrow::Cow;
use crate::task::{State, MARKER_PARENT};
pub const TASK_KIND: Kind = Kind::GitIssue; pub const TASK_KIND: Kind = Kind::GitIssue;
pub const PROCEDURE_KIND_ID: u16 = 1639; pub const PROCEDURE_KIND_ID: u16 = 1639;
@ -25,6 +25,9 @@ pub const PROP_KINDS: [Kind; 6] = [
PROCEDURE_KIND, PROCEDURE_KIND,
]; ];
pub type Prio = u16;
pub const PRIO: &str = "priority";
// TODO: use formatting - bold / heading / italics - and generate from code // TODO: use formatting - bold / heading / italics - and generate from code
/// Helper for available properties. /// Helper for available properties.
pub const PROPERTY_COLUMNS: &str = pub const PROPERTY_COLUMNS: &str =
@ -71,18 +74,6 @@ pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>)
EventBuilder::new(kind.map(|k| k.1).unwrap_or(TASK_KIND), name, tags) EventBuilder::new(kind.map(|k| k.1).unwrap_or(TASK_KIND), name, tags)
} }
pub(crate) fn build_prop(
kind: Kind,
comment: &str,
id: EventId,
) -> EventBuilder {
EventBuilder::new(
kind,
comment,
vec![Tag::event(id)],
)
}
/// Return Hashtags embedded in the string. /// Return Hashtags embedded in the string.
pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ { pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
input.split_ascii_whitespace() input.split_ascii_whitespace()
@ -91,19 +82,35 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
.map(to_hashtag) .map(to_hashtag)
} }
/// Extracts everything after a ": " as a list of tags. /// Extracts everything after a " # " as a list of tags
/// as well as various embedded tags.
/// ///
/// Expects sanitized input. /// Expects sanitized input.
pub(crate) fn extract_tags(input: &str) -> (&str, Vec<Tag>) { pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
match input.split_once(": ") { let words = input.split_ascii_whitespace();
None => (input, extract_hashtags(input).collect_vec()), let mut prio = None;
Some((name, tags)) => { let result = words.filter(|s| {
let tags = extract_hashtags(name) if s.starts_with('*') {
.chain(tags.split_ascii_whitespace().map(to_hashtag)) if s.len() == 1 {
.collect(); prio = Some(HIGH_PRIO);
(name, tags) return false
}
return match s[1..].parse::<Prio>() {
Ok(num) => {
prio = Some(num * (if s.len() > 2 { 1 } else { 10 }));
false
},
_ => true,
}
} }
} true
}).collect_vec();
let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" ");
let tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag(&s)))
.chain(prio.map(|p| to_prio_tag(p))).collect();
(main, tags)
} }
fn to_hashtag(tag: &str) -> Tag { fn to_hashtag(tag: &str) -> Tag {
@ -136,9 +143,16 @@ pub(crate) fn is_hashtag(tag: &Tag) -> bool {
.is_some_and(|letter| letter.character == Alphabet::T) .is_some_and(|letter| letter.character == Alphabet::T)
} }
pub(crate) fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
}
#[test] #[test]
fn test_extract_tags() { fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings: yeah done-it"), assert_eq!(extract_tags("Hello from #mars with #greetings *4 # # yeah done-it"),
("Hello from #mars with #greetings", ["mars", "greetings", "yeah", "done-it"].into_iter().map(to_hashtag).collect())) ("Hello from #mars with #greetings".to_string(),
["mars", "greetings", "yeah", "done-it"].into_iter().map(to_hashtag)
.chain(std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))).collect()));
assert_eq!(extract_tags("So tagless #"),
("So tagless".to_string(), vec![]));
} }

View File

@ -4,7 +4,6 @@ use std::env::{args, var};
use std::fs; use std::fs;
use std::fs::File; use std::fs::File;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::iter::once;
use std::ops::Sub; use std::ops::Sub;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr; use std::str::FromStr;
@ -27,8 +26,8 @@ use tokio::time::error::Elapsed;
use tokio::time::timeout; use tokio::time::timeout;
use crate::helpers::*; use crate::helpers::*;
use crate::kinds::{BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND}; use crate::kinds::{Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
use crate::task::{State, Task, TaskState, MARKER_DEPENDS}; use crate::task::{State, Task, TaskState};
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay}; use crate::tasks::{PropertyCollection, StateFilter, TasksRelay};
mod helpers; mod helpers;
@ -81,11 +80,12 @@ impl EventSender {
} }
} }
// TODO this direly needs testing
fn submit(&self, event_builder: EventBuilder) -> Result<Event> { fn submit(&self, event_builder: EventBuilder) -> Result<Event> {
let min = Timestamp::now().sub(UNDO_DELAY);
{ {
// Always flush if oldest event older than a minute or newer than now // Always flush if oldest event older than a minute or newer than now
let borrow = self.queue.borrow(); let borrow = self.queue.borrow();
let min = Timestamp::now().sub(UNDO_DELAY);
if borrow.iter().any(|e| e.created_at < min || e.created_at > Timestamp::now()) { if borrow.iter().any(|e| e.created_at < min || e.created_at > Timestamp::now()) {
drop(borrow); drop(borrow);
debug!("Flushing event queue because it is older than a minute"); debug!("Flushing event queue because it is older than a minute");
@ -94,10 +94,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 == TRACKING_KIND { if event.kind == TRACKING_KIND && event.created_at > min && event.created_at < tasks::now() {
queue.retain(|e| { // Do not send redundant movements
e.kind != TRACKING_KIND queue.retain(|e| e.kind != TRACKING_KIND);
});
} }
queue.push(event.clone()); queue.push(event.clone());
})?) })?)
@ -346,10 +345,11 @@ async fn main() -> Result<()> {
println!(); println!();
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()).dimmed(), 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(),
" ".dimmed()
); );
match rl.readline(&prompt) { match rl.readline(&prompt) {
Ok(input) => { Ok(input) => {
@ -409,6 +409,16 @@ async fn main() -> Result<()> {
let arg_default = arg.unwrap_or(""); let arg_default = arg.unwrap_or("");
match operator { match operator {
Some(':') => { Some(':') => {
if command.starts_with("://") {
if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_ref().is_some_and(|url| url.as_str().contains(&command))) {
selected_relay.clone_from(url);
println!("{}", tasks);
continue 'repl;
}
warn!("No connected relay contains {:?}", command);
continue 'repl;
}
let mut iter = arg_default.chars(); let mut iter = arg_default.chars();
let next = iter.next(); let next = iter.next();
let remaining = iter.collect::<String>().trim().to_string(); let remaining = iter.collect::<String>().trim().to_string();
@ -437,20 +447,18 @@ async fn main() -> Result<()> {
Some(',') => Some(',') =>
match arg { match arg {
None => { None => {
match tasks.get_current_task() { if let Some(task) = tasks.get_current_task() {
None => { let mut desc = task.description_events().peekable();
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes"); if desc.peek().is_some() {
tasks.recurse_activities = !tasks.recurse_activities;
info!("Toggled activities recursion to {}", tasks.recurse_activities);
}
Some(task) => {
println!("{}", println!("{}",
task.description_events() desc.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
.join("\n")); .join("\n"));
continue 'repl; continue 'repl;
} }
} }
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes");
tasks.recurse_activities = !tasks.recurse_activities;
info!("Toggled activities recursion to {}", tasks.recurse_activities);
} }
Some(arg) => { Some(arg) => {
if arg.len() < CHARACTER_THRESHOLD { if arg.len() < CHARACTER_THRESHOLD {
@ -526,7 +534,7 @@ async fn main() -> Result<()> {
match arg { match arg {
None => match tasks.get_position() { None => match tasks.get_position() {
None => { None => {
info!("Filtering for bookmarked tasks"); info!("Showing only bookmarked tasks");
tasks.set_view_bookmarks(); tasks.set_view_bookmarks();
} }
Some(pos) => Some(pos) =>
@ -536,7 +544,15 @@ async fn main() -> Result<()> {
None => {} None => {}
} }
}, },
Some(arg) => info!("Setting priority not yet implemented"), Some(arg) => {
if arg == "*" {
tasks.set_priority(None);
} else {
tasks.set_priority(arg.parse()
.inspect_err(|e| warn!("Invalid Priority {arg}: {e}")).ok()
.map(|p: Prio| p * (if arg.len() < 2 { 10 } else { 1 })));
}
},
} }
} }
@ -544,6 +560,7 @@ async fn main() -> Result<()> {
match arg { match arg {
None => match tasks.get_position() { None => match tasks.get_position() {
None => { None => {
info!("Use | to create dependent sibling task and || to create a procedure");
tasks.set_state_filter( tasks.set_state_filter(
StateFilter::State(State::Procedure.to_string())); StateFilter::State(State::Procedure.to_string()));
} }
@ -553,12 +570,7 @@ async fn main() -> Result<()> {
}, },
Some(arg) => 'arm: { Some(arg) => 'arm: {
if !arg.starts_with('|') { if !arg.starts_with('|') {
if let Some(pos) = tasks.get_position() { if tasks.make_dependent_sibling(arg) {
tasks.move_up();
tasks.make_task_with(
arg,
once(tasks.make_event_tag_from_id(pos, MARKER_DEPENDS)),
true);
break 'arm; break 'arm;
} }
} }
@ -608,18 +620,21 @@ async fn main() -> Result<()> {
} }
} }
Some('#') => Some('#') => {
tasks.set_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())), if !tasks.update_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())) {
continue;
}
}
Some('+') => Some('+') =>
match arg { match arg {
Some(arg) => tasks.add_tag(arg.to_string()), Some(arg) => tasks.add_tag(arg.to_string()),
None => { None => {
println!("Hashtags of all known tasks:\n{}", tasks.all_hashtags().join(" ").italic()); tasks.print_hashtags();
if tasks.has_tag_filter() { if tasks.has_tag_filter() {
println!("Use # to remove tag filters and . to remove all filters.") println!("Use # to remove tag filters and . to remove all filters.")
} }
continue 'repl; continue;
} }
} }
@ -638,7 +653,7 @@ async fn main() -> Result<()> {
Ok(number) => max = number, Ok(number) => max = number,
Err(e) => warn!("Unsure what to do with {:?}", e), Err(e) => warn!("Unsure what to do with {:?}", e),
} }
let (label, mut times) = tasks.times_tracked(); let (label, times) = tasks.times_tracked();
println!("{}\n{}", label.italic(), println!("{}\n{}", label.italic(),
times.rev().take(max).collect_vec().iter().rev().join("\n")); times.rev().take(max).collect_vec().iter().rev().join("\n"));
} else if let Ok(key) = PublicKey::parse(arg) { // TODO also match name } else if let Ok(key) = PublicKey::parse(arg) { // TODO also match name
@ -653,7 +668,7 @@ async fn main() -> Result<()> {
} }
} }
} else { } else {
let (label, mut times) = tasks.times_tracked(); let (label, times) = tasks.times_tracked();
println!("{}\n{}", label.italic(), println!("{}\n{}", label.italic(),
times.rev().take(80).collect_vec().iter().rev().join("\n")); times.rev().take(80).collect_vec().iter().rev().join("\n"));
} }
@ -726,7 +741,7 @@ async fn main() -> Result<()> {
} }
let filtered = let filtered =
tasks.get_filtered(|t| { tasks.get_filtered(pos, |t| {
transform(&t.event.content).contains(&remaining) || transform(&t.event.content).contains(&remaining) ||
t.tags.iter().flatten().any( t.tags.iter().flatten().any(
|tag| tag.content().is_some_and(|s| transform(s).contains(&remaining))) |tag| tag.content().is_some_and(|s| transform(s).contains(&remaining)))
@ -744,7 +759,6 @@ async fn main() -> Result<()> {
_ => _ =>
if Regex::new("^wss?://").unwrap().is_match(command.trim()) { if Regex::new("^wss?://").unwrap().is_match(command.trim()) {
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(&command))) { if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_ref().is_some_and(|url| url.as_str().starts_with(&command))) {
selected_relay.clone_from(url); selected_relay.clone_from(url);
println!("{}", tasks); println!("{}", tasks);
@ -775,7 +789,7 @@ async fn main() -> Result<()> {
println!("{}", tasks); println!("{}", tasks);
} }
Err(ReadlineError::Eof) => break 'repl, Err(ReadlineError::Eof) => break 'repl,
Err(ReadlineError::Interrupted) => break 'repl, // TODO exit if prompt was empty, or clear Err(ReadlineError::Interrupted) => break 'repl, // TODO exit only if prompt is empty, or clear
Err(e) => warn!("{}", e), Err(e) => warn!("{}", e),
} }
} }

View File

@ -1,8 +1,9 @@
use fmt::Display; use fmt::Display;
use std::cmp::Ordering; use std::cmp::Ordering;
use std::collections::{BTreeSet, HashSet}; use std::collections::BTreeSet;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::iter::once;
use std::string::ToString; use std::string::ToString;
use colored::{ColoredString, Colorize}; use colored::{ColoredString, Colorize};
@ -12,10 +13,11 @@ 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, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND}; use crate::kinds::{is_hashtag, Prio, PRIO, 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";
pub static MARKER_PROPERTY: &str = "property";
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Task { pub(crate) struct Task {
@ -101,12 +103,25 @@ impl Task {
self.event.kind == TASK_KIND self.event.kind == TASK_KIND
} }
/// Whether this is an actionable task - false if stateless /// Whether this is an actionable task - false if stateless activity
pub(crate) fn is_task(&self) -> bool { pub(crate) fn is_task(&self) -> bool {
self.is_task_kind() || self.is_task_kind() ||
self.props.iter().any(|event| State::try_from(event.kind).is_ok()) self.props.iter().any(|event| State::try_from(event.kind).is_ok())
} }
pub(crate) fn priority(&self) -> Option<Prio> {
self.priority_raw().and_then(|s| s.parse().ok())
}
pub(crate) fn priority_raw(&self) -> Option<&str> {
self.props.iter().rev()
.chain(once(&self.event))
.find_map(|p| {
p.tags.iter().find_map(|t|
t.content().take_if(|_| { t.kind().to_string() == PRIO }))
})
}
fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ { fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
self.props.iter().filter_map(|event| { self.props.iter().filter_map(|event| {
event.kind.try_into().ok().map(|s| TaskState { event.kind.try_into().ok().map(|s| TaskState {
@ -179,6 +194,7 @@ impl Task {
"created" => Some(format_timestamp_local(&self.event.created_at)), "created" => Some(format_timestamp_local(&self.event.created_at)),
"kind" => Some(self.event.kind.to_string()), "kind" => Some(self.event.kind.to_string()),
// Dynamic // Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()),
"status" => self.state_label().map(|c| c.to_string()), "status" => self.state_label().map(|c| c.to_string()),
"desc" => self.descriptions().last().cloned(), "desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().join(" ")), "description" => Some(self.descriptions().join(" ")),

View File

@ -8,7 +8,7 @@ use std::time::Duration;
use crate::helpers::{format_timestamp_local, format_timestamp_relative, format_timestamp_relative_to, parse_tracking_stamp, some_non_empty, CHARACTER_THRESHOLD}; use crate::helpers::{format_timestamp_local, format_timestamp_relative, format_timestamp_relative_to, parse_tracking_stamp, some_non_empty, CHARACTER_THRESHOLD};
use crate::kinds::*; use crate::kinds::*;
use crate::task::{State, Task, TaskState, MARKER_DEPENDS, MARKER_PARENT}; use crate::task::{State, Task, TaskState, MARKER_DEPENDS, MARKER_PARENT, MARKER_PROPERTY};
use crate::{EventSender, MostrMessage}; use crate::{EventSender, MostrMessage};
use colored::Colorize; use colored::Colorize;
use itertools::{Either, Itertools}; use itertools::{Either, Itertools};
@ -19,8 +19,12 @@ use regex::bytes::Regex;
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use TagStandard::Hashtag; use TagStandard::Hashtag;
const DEFAULT_PRIO: Prio = 25;
pub const HIGH_PRIO: Prio = 85;
/// Amount of seconds to treat as "now"
const MAX_OFFSET: u64 = 9; const MAX_OFFSET: u64 = 9;
fn now() -> Timestamp { pub(crate) fn now() -> Timestamp {
Timestamp::now() + MAX_OFFSET Timestamp::now() + MAX_OFFSET
} }
@ -76,6 +80,8 @@ pub(crate) struct TasksRelay {
tags_excluded: BTreeSet<Tag>, tags_excluded: BTreeSet<Tag>,
/// Current active state /// Current active state
state: StateFilter, state: StateFilter,
/// Current priority for filtering and new tasks
priority: Option<Prio>,
sender: EventSender, sender: EventSender,
overflow: VecDeque<Event>, overflow: VecDeque<Event>,
@ -149,6 +155,7 @@ impl TasksRelay {
properties: [ properties: [
"author", "author",
"prio",
"state", "state",
"rtime", "rtime",
"hashtags", "hashtags",
@ -156,7 +163,8 @@ impl TasksRelay {
"desc", "desc",
].into_iter().map(|s| s.to_string()).collect(), ].into_iter().map(|s| s.to_string()).collect(),
sorting: [ sorting: [
"state", "priority",
"status",
"author", "author",
"hashtags", "hashtags",
"rtime", "rtime",
@ -167,6 +175,8 @@ impl TasksRelay {
tags: Default::default(), tags: Default::default(),
tags_excluded: Default::default(), tags_excluded: Default::default(),
state: Default::default(), state: Default::default(),
priority: None,
search_depth: 4, search_depth: 4,
view_depth: 0, view_depth: 0,
recurse_activities: true, recurse_activities: true,
@ -255,6 +265,7 @@ impl TasksRelay {
.map(|str| EventId::from_str(str).ok().map_or(str.to_string(), |id| self.get_task_path(Some(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 omit intervals <2min - but I think I need threeway for that
// TODO alternate color with grey between days // TODO alternate color with grey between days
full.push(format!("{} {}", format_timestamp_local(&event.created_at), new.as_ref().unwrap_or(&"---".to_string()))); full.push(format!("{} {}", format_timestamp_local(&event.created_at), new.as_ref().unwrap_or(&"---".to_string())));
last = new; last = new;
@ -274,10 +285,13 @@ impl TasksRelay {
let mut vec = Vec::with_capacity(set.len() / 2); let mut vec = Vec::with_capacity(set.len() / 2);
let mut iter = timestamps(set.values(), &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 {}", // Filter out intervals <2 mins
format_timestamp_local(start), if start.as_u64() + 120 < end.as_u64() {
format_timestamp_relative_to(end, start), vec.push(format!("{} - {} by {}",
self.get_username(key))) format_timestamp_local(start),
format_timestamp_relative_to(end, start),
self.get_username(key)))
}
} }
iter.into_buffer() iter.into_buffer()
.for_each(|(stamp, _)| .for_each(|(stamp, _)|
@ -294,7 +308,6 @@ impl TasksRelay {
Durations::from(self.get_own_events_history(), &vec![&id]).sum::<Duration>().as_secs() Durations::from(self.get_own_events_history(), &vec![&id]).sum::<Duration>().as_secs()
} }
/// Total time in seconds tracked on this task and its subtasks by all users. /// Total time in seconds tracked on this task and its subtasks by all users.
fn total_time_tracked(&self, id: EventId) -> u64 { fn total_time_tracked(&self, id: EventId) -> u64 {
let mut total = 0; let mut total = 0;
@ -349,6 +362,7 @@ impl TasksRelay {
.chain(self.tags_excluded.iter() .chain(self.tags_excluded.iter()
.map(|t| format!(" -#{}", t.content().unwrap()))) .map(|t| format!(" -#{}", t.content().unwrap())))
.chain(once(self.state.indicator())) .chain(once(self.state.indicator()))
.chain(self.priority.map(|p| format!(" *{:02}", p)))
.join("") .join("")
} }
@ -385,27 +399,27 @@ impl TasksRelay {
) -> Vec<&'a Task> { ) -> Vec<&'a Task> {
iter.sorted_by_cached_key(|task| self.sorting_key(task)) iter.sorted_by_cached_key(|task| self.sorting_key(task))
.flat_map(move |task| { .flat_map(move |task| {
if !self.state.matches(task) { if !self.state.matches(task) {
return vec![]; return vec![];
}
let mut new_depth = depth;
if depth > 0 && (!self.recurse_activities || task.is_task()) {
new_depth = depth - 1;
if sparse && new_depth > self.view_depth && self.filter(task) {
new_depth = self.view_depth;
} }
} let mut new_depth = depth;
if new_depth > 0 { if depth > 0 && (!self.recurse_activities || task.is_task()) {
let mut children = self.resolve_tasks_rec(self.tasks.children_of(&task), sparse, new_depth); new_depth = depth - 1;
if !children.is_empty() { if sparse && new_depth > self.view_depth && self.filter(task) {
if !sparse { new_depth = self.view_depth;
children.push(task);
} }
return children;
} }
} if new_depth > 0 {
return if self.filter(task) { vec![task] } else { vec![] }; let mut children = self.resolve_tasks_rec(self.tasks.children_of(&task), sparse, new_depth);
}).collect_vec() if !children.is_empty() {
if !sparse {
children.push(task);
}
return children;
}
}
return if self.filter(task) { vec![task] } else { vec![] };
}).collect_vec()
} }
/// Executes the given function with each task referenced by this event without marker. /// Executes the given function with each task referenced by this event without marker.
@ -414,7 +428,7 @@ impl TasksRelay {
let mut found = false; let mut found = false;
for tag in event.tags.iter() { for tag in event.tags.iter() {
if let Some(TagStandard::Event { event_id, marker, .. }) = tag.as_standardized() { if let Some(TagStandard::Event { event_id, marker, .. }) = tag.as_standardized() {
if marker.is_none() { if marker.as_ref().is_none_or(|m| m.to_string() == MARKER_PROPERTY) {
self.tasks.get_mut(event_id).map(|t| { self.tasks.get_mut(event_id).map(|t| {
found = true; found = true;
f(t) f(t)
@ -432,6 +446,9 @@ impl TasksRelay {
fn filter(&self, task: &Task) -> bool { fn filter(&self, task: &Task) -> bool {
self.state.matches(task) && self.state.matches(task) &&
self.priority.is_none_or(|prio| {
task.priority().unwrap_or(DEFAULT_PRIO) >= prio
}) &&
task.tags.as_ref().map_or(true, |tags| { task.tags.as_ref().map_or(true, |tags| {
!tags.iter().any(|tag| self.tags_excluded.contains(tag)) !tags.iter().any(|tag| self.tags_excluded.contains(tag))
}) && }) &&
@ -478,7 +495,8 @@ impl TasksRelay {
current current
} }
pub(crate) fn visible_tasks(&self) -> Vec<&Task> { // TODO this is a relict for tests
fn visible_tasks(&self) -> Vec<&Task> {
if self.search_depth == 0 { if self.search_depth == 0 {
return vec![]; return vec![];
} }
@ -524,7 +542,13 @@ impl TasksRelay {
} }
"progress" => prog_string.clone(), "progress" => prog_string.clone(),
"author" => format!("{:.6}", self.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment "author" | "creator" => format!("{:.6}", self.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment
"prio" => task.priority_raw().map(|p| p.to_string()).unwrap_or_else(||
if self.priority.is_some() {
DEFAULT_PRIO.to_string().dimmed().to_string()
} else {
"".to_string()
}),
"path" => self.get_task_path(Some(task.event.id)), "path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id), "rpath" => self.relative_path(task.event.id),
// TODO format strings configurable // TODO format strings configurable
@ -577,11 +601,11 @@ impl TasksRelay {
self.set_filter(|t| t.last_state_update() > time) self.set_filter(|t| t.last_state_update() > time)
} }
pub(crate) fn get_filtered<P>(&self, predicate: P) -> Vec<EventId> pub(crate) fn get_filtered<P>(&self, position: Option<&EventId>, predicate: P) -> Vec<EventId>
where where
P: Fn(&&Task) -> bool, P: Fn(&&Task) -> bool,
{ {
self.filtered_tasks(self.get_position_ref(), false) self.filtered_tasks(position, false)
.into_iter() .into_iter()
.filter(predicate) .filter(predicate)
.map(|t| t.event.id) .map(|t| t.event.id)
@ -592,7 +616,7 @@ impl TasksRelay {
where where
P: Fn(&&Task) -> bool, P: Fn(&&Task) -> bool,
{ {
self.set_view(self.get_filtered(predicate)) self.set_view(self.get_filtered(self.get_position_ref(), predicate))
} }
pub(crate) fn set_view_bookmarks(&mut self) -> bool { pub(crate) fn set_view_bookmarks(&mut self) -> bool {
@ -623,7 +647,26 @@ impl TasksRelay {
!self.tags.is_empty() || !self.tags_excluded.is_empty() !self.tags.is_empty() || !self.tags_excluded.is_empty()
} }
pub(crate) fn set_tags(&mut self, tags: impl IntoIterator<Item=Tag>) { pub(crate) fn print_hashtags(&self) {
println!("Hashtags of all known tasks:\n{}", self.all_hashtags().join(" ").italic());
}
/// Returns true if tags have been updated, false if it printed something
pub(crate) fn update_tags(&mut self, tags: impl IntoIterator<Item=Tag>) -> bool {
let mut peekable = tags.into_iter().peekable();
if self.tags.is_empty() && peekable.peek().is_none() {
if !self.tags_excluded.is_empty() {
self.tags_excluded.clear();
}
self.print_hashtags();
false
} else {
self.set_tags(peekable);
true
}
}
fn set_tags(&mut self, tags: impl IntoIterator<Item=Tag>) {
self.tags.clear(); self.tags.clear();
self.tags.extend(tags); self.tags.extend(tags);
} }
@ -648,6 +691,15 @@ impl TasksRelay {
} }
} }
pub(crate) fn set_priority(&mut self, priority: Option<Prio>) {
self.view.clear();
match priority {
None => info!("Removing priority filter"),
Some(prio) => info!("Filtering for priority {}", prio),
}
self.priority = priority;
}
pub(crate) fn set_state_filter(&mut self, state: StateFilter) { pub(crate) fn set_state_filter(&mut self, state: StateFilter) {
self.view.clear(); self.view.clear();
info!("Filtering for {}", state); info!("Filtering for {}", state);
@ -830,15 +882,34 @@ impl TasksRelay {
self.move_to(Some(id)); self.move_to(Some(id));
} }
/// Moves up and creates a sibling task dependent on the current one
///
/// Returns true if successful, false if there is no current task
pub(crate) fn make_dependent_sibling(&mut self, input: &str) -> bool {
if let Some(pos) = self.get_position() {
self.move_up();
self.make_task_with(
input,
self.get_position().map(|par| self.make_event_tag_from_id(par, MARKER_PARENT))
.into_iter().chain(once(self.make_event_tag_from_id(pos, MARKER_DEPENDS))),
true);
return true;
}
false
}
/// Creates a task including current tag filters /// Creates a task including current tag filters
/// ///
/// Sanitizes input /// Sanitizes input
pub(crate) fn make_task_with(&mut self, input: &str, tags: impl IntoIterator<Item=Tag>, set_state: bool) -> EventId { pub(crate) fn make_task_with(&mut self, input: &str, tags: impl IntoIterator<Item=Tag>, set_state: bool) -> EventId {
let (input, input_tags) = extract_tags(input.trim()); let (input, input_tags) = extract_tags(input.trim());
let prio =
if input_tags.iter().any(|t| t.kind().to_string() == PRIO) { None } else { self.priority.map(|p| to_prio_tag(p)) };
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) .add_tags(tags)
.add_tags(prio)
); );
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));
@ -861,6 +932,7 @@ impl TasksRelay {
pub(crate) fn track_at(&mut self, mut time: Timestamp, target: Option<EventId>) -> Option<EventId> { pub(crate) fn track_at(&mut self, mut time: Timestamp, target: Option<EventId>) -> Option<EventId> {
if target.is_none() { if target.is_none() {
// Prevent random overlap with tracking started in the same second
time = time - 1; time = time - 1;
} else if let Some(hist) = self.history.get(&self.sender.pubkey()) { } else if let Some(hist) = self.history.get(&self.sender.pubkey()) {
while hist.get(&time).is_some() { while hist.get(&time).is_some() {
@ -1013,11 +1085,19 @@ impl TasksRelay {
} }
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 {
let prop = build_prop( let ids =
if state == State::Closed {
// Close whole subtree
ChildIterator::from(self, &id).get_all()
} else {
vec![&id]
};
let prop = EventBuilder::new(
state.into(), state.into(),
comment, comment,
id, ids.into_iter().map(|e| self.make_event_tag_from_id(*e, MARKER_PROPERTY)),
); );
// if self.custom_time.is_none() && self.get_by_id(id).map(|task| {}) {}
info!("Task status {} set for \"{}\"{}", info!("Task status {} set for \"{}\"{}",
TaskState::get_label_for(&state, comment), TaskState::get_label_for(&state, comment),
self.get_task_title(&id), self.get_task_title(&id),
@ -1033,13 +1113,17 @@ impl TasksRelay {
pub(crate) fn make_note(&mut self, note: &str) -> EventId { pub(crate) fn make_note(&mut self, note: &str) -> EventId {
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); let prop = EventBuilder::new(
return self.submit(prop) Kind::TextNote,
note.trim(),
[Tag::event(*id)],
);
return self.submit(prop);
} }
} }
let (input, tags) = extract_tags(note.trim()); let (input, tags) = extract_tags(note.trim());
self.submit( self.submit(
build_task(input, tags, Some(("activity", Kind::TextNote))) build_task(&input, tags, Some(("activity", Kind::TextNote)))
.add_tags(self.parent_tag()) .add_tags(self.parent_tag())
.add_tags(self.tags.iter().cloned()) .add_tags(self.tags.iter().cloned())
) )
@ -1105,8 +1189,8 @@ impl Display for TasksRelay {
} }
let position = self.get_position_ref(); let position = self.get_position_ref();
let mut current = vec![]; let mut current: Vec<&Task>;
let mut roots = self.view.iter().flat_map(|id| self.get_by_id(id)).collect_vec(); let roots = self.view.iter().flat_map(|id| self.get_by_id(id)).collect_vec();
if self.search_depth > 0 && roots.is_empty() { if self.search_depth > 0 && roots.is_empty() {
current = self.resolve_tasks_rec(self.tasks.children_for(position), true, self.search_depth + self.view_depth); current = self.resolve_tasks_rec(self.tasks.children_for(position), true, self.search_depth + self.view_depth);
if current.is_empty() { if current.is_empty() {
@ -1137,26 +1221,28 @@ impl Display for TasksRelay {
let tree = current.iter().flat_map(|task| self.traverse_up_from(Some(task.event.id))).unique(); let tree = current.iter().flat_map(|task| self.traverse_up_from(Some(task.event.id))).unique();
let ids: HashSet<&EventId> = tree.map(|t| t.get_id()).chain(position).collect(); let ids: HashSet<&EventId> = tree.map(|t| t.get_id()).chain(position).collect();
let mut bookmarks = if self.view.is_empty() {
// TODO add recent tasks (most time tracked + recently created) let mut bookmarks =
self.bookmarks.iter() // TODO add recent tasks (most time tracked + recently created)
.chain(self.tasks.values().sorted_unstable().take(3).map(|t| t.get_id())) self.bookmarks.iter()
.filter(|id| !ids.contains(id)) .chain(self.tasks.values().sorted_unstable().take(3).map(|t| t.get_id()))
.filter_map(|id| self.get_by_id(id)) .filter(|id| !ids.contains(id))
.filter(|t| self.filter(t)) .filter_map(|id| self.get_by_id(id))
.sorted_by_cached_key(|t| self.sorting_key(t)) .filter(|t| self.filter(t))
.dedup() .sorted_by_cached_key(|t| self.sorting_key(t))
.peekable(); .dedup()
if bookmarks.peek().is_some() { .peekable();
writeln!(lock, "{}", Colorize::bold("Quick Access"))?; if bookmarks.peek().is_some() {
for task in bookmarks { writeln!(lock, "{}", Colorize::bold("Quick Access"))?;
writeln!( for task in bookmarks {
lock, writeln!(
"{}", lock,
self.properties.iter() "{}",
.map(|p| self.get_property(task, p.as_str())) self.properties.iter()
.join(" \t") .map(|p| self.get_property(task, p.as_str()))
)?; .join(" \t")
)?;
}
} }
} }
@ -1219,10 +1305,11 @@ where
} }
/// Formats the given seconds according to the given format. /// Formats the given seconds according to the given format.
/// MMM - minutes /// - MMM - minutes
/// MM - minutes of the hour /// - MM - minutes of the hour
/// HH - hours /// - HH - hours
/// Returns an empty string if under a minute. ///
/// Returns an empty string if under one minute.
fn display_time(format: &str, secs: u64) -> String { fn display_time(format: &str, secs: u64) -> String {
Some(secs / 60) Some(secs / 60)
.filter(|t| t > &0) .filter(|t| t > &0)
@ -1266,6 +1353,7 @@ fn referenced_event(event: &Event) -> Option<&EventId> {
referenced_events(event).next() referenced_events(event).next()
} }
/// Returns the id of a referenced event if it is contained in the provided ids list.
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> {
referenced_events(event).find(|id| ids.contains(id)) referenced_events(event).find(|id| ids.contains(id))
} }
@ -1540,11 +1628,39 @@ mod tasks_test {
}; };
} }
#[test]
fn test_recursive_closing() {
let mut tasks = stub_tasks();
let parent = tasks.make_task("parent #tag1");
tasks.move_to(Some(parent));
let sub = tasks.make_task("sub # tag2");
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["tag1", "tag2"]);
tasks.update_state("Closing Down", State::Closed);
assert_eq!(tasks.get_by_id(&sub).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.all_hashtags().next(), None);
}
#[test]
fn test_sibling_dependency() {
let mut tasks = stub_tasks();
let parent = tasks.make_task("parent");
let sub = tasks.submit(
build_task("sub", vec![tasks.make_event_tag_from_id(parent, MARKER_PARENT)], None));
assert_eq!(tasks.visible_tasks().len(), 1);
tasks.track_at(Timestamp::now(), Some(sub));
assert_eq!(tasks.get_own_events_history().count(), 1);
tasks.make_dependent_sibling("sibling");
assert_eq!(tasks.len(), 3);
assert_eq!(tasks.visible_tasks().len(), 2);
}
#[test] #[test]
fn test_bookmarks() { fn test_bookmarks() {
let mut tasks = stub_tasks(); let mut tasks = stub_tasks();
let zero = EventId::all_zeros(); let zero = EventId::all_zeros();
let test = tasks.make_task("test: tag"); let test = tasks.make_task("test # tag");
let parent = tasks.make_task("parent"); let parent = tasks.make_task("parent");
assert_eq!(tasks.visible_tasks().len(), 2); assert_eq!(tasks.visible_tasks().len(), 2);
tasks.move_to(Some(parent)); tasks.move_to(Some(parent));
@ -1584,15 +1700,14 @@ mod tasks_test {
#[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_events_history().count(), 1); assert_eq!(tasks.get_own_events_history().count(), 1);
let side = tasks.submit( let side = tasks.submit(
build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None)); build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
assert_eq!(tasks.visible_tasks(), assert_eq!(tasks.visible_tasks(),
Vec::<&Task>::new()); Vec::<&Task>::new());
let sub_id = tasks.make_task("sub"); let sub_id = tasks.make_task("sub");
assert_eq!(tasks.visible_tasks().iter().map(|t| t.event.id).collect_vec(), assert_tasks!(tasks, [sub_id]);
Vec::from([sub_id]));
assert_eq!(tasks.len(), 3); assert_eq!(tasks.len(), 3);
let sub = tasks.get_by_id(&sub_id).unwrap(); let sub = tasks.get_by_id(&sub_id).unwrap();
assert_eq!(sub.get_dependendees(), Vec::<&EventId>::new()); assert_eq!(sub.get_dependendees(), Vec::<&EventId>::new());
@ -1701,7 +1816,7 @@ mod tasks_test {
assert_position!(tasks, t1); assert_position!(tasks, t1);
tasks.search_depth = 2; tasks.search_depth = 2;
assert_eq!(tasks.visible_tasks().len(), 0); assert_eq!(tasks.visible_tasks().len(), 0);
let t11 = tasks.make_task("t11: tag"); let t11 = tasks.make_task("t11 # tag");
assert_eq!(tasks.visible_tasks().len(), 1); assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11"); assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11");
assert_eq!(tasks.relative_path(t11), "t11"); assert_eq!(tasks.relative_path(t11), "t11");
@ -1797,4 +1912,4 @@ mod tasks_test {
2 2
); );
} }
} }