forked from janek/mostr
Compare commits
23 Commits
49d8eef29c
...
5dbea00562
Author | SHA1 | Date |
---|---|---|
xeruf | 5dbea00562 | |
xeruf | cc1e9d4d69 | |
xeruf | d5e6bd2578 | |
xeruf | 60b33b1dd3 | |
xeruf | 561fd9e1e5 | |
xeruf | 91b6047f9a | |
xeruf | 5294d9081f | |
xeruf | b81e5a27bf | |
xeruf | 8f0a169677 | |
xeruf | ae525c870f | |
xeruf | b9307b7b5d | |
xeruf | e9bee3c114 | |
xeruf | dc8df51e0f | |
xeruf | cc64c0f493 | |
xeruf | 5a8fa69e4c | |
xeruf | f33d890d7f | |
xeruf | dd78a2f460 | |
xeruf | 5303d0cb41 | |
xeruf | 2053f045b2 | |
xeruf | baf93bd788 | |
xeruf | d8eebcfb6a | |
xeruf | 7f33bdc9ab | |
xeruf | 306e0e0421 |
|
@ -6,6 +6,7 @@ readme = "README.md"
|
|||
license = "GPL 3.0"
|
||||
authors = ["melonion"]
|
||||
version = "0.5.0"
|
||||
rust-version = "1.82"
|
||||
edition = "2021"
|
||||
default-run = "mostr"
|
||||
|
||||
|
|
|
@ -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
101
README.md
|
@ -2,22 +2,25 @@
|
|||
|
||||
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
|
||||
|
||||
First, start a nostr relay, such as
|
||||
- https://github.com/coracle-social/bucket for local development
|
||||
- https://github.com/rnostr/rnostr for production use
|
||||
|
||||
Run development build with:
|
||||
Install rustup and run a development build with:
|
||||
|
||||
cargo run
|
||||
|
||||
A `relay` list and private `key` can be placed in config files
|
||||
under `${XDG_CONFIG_HOME:-$HOME/.config}/mostr/`.
|
||||
Currently, all relays are fetched and synced to,
|
||||
separation is planned -
|
||||
ideally for any project with different collaborators,
|
||||
an own relay will be used.
|
||||
Ideally any project with different collaborators has its own relay.
|
||||
If not saved, mostr will ask for a relay url
|
||||
(entering none is fine too, but your data will not be persisted between sessions)
|
||||
and a private key, alternatively generating one on the fly.
|
||||
|
@ -27,6 +30,11 @@ Install latest build:
|
|||
|
||||
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:
|
||||
`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.
|
||||
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
|
||||
|
||||
### Command Syntax
|
||||
|
||||
`TASK` creation syntax: `NAME: TAG1 TAG2 ...`
|
||||
`TASK` creation syntax: `NAME #TAG *PRIO # TAG1 TAG2 ...`
|
||||
|
||||
- `TASK` - create task
|
||||
+ 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
|
||||
- `.TASK`
|
||||
+ 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)
|
||||
- `/[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]` - (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.
|
||||
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` - set status for current task from text and move up; empty: Open
|
||||
- `!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 note (stateless task / task description)
|
||||
- `,[TEXT]` - list notes or add text (activity / task description)
|
||||
- TBI: `;[TEXT]` - list comments or comment on task
|
||||
- TBI: show status history and creation with attribution
|
||||
- `&` - 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)
|
||||
- `*` - (un)bookmark current task or list all bookmarks
|
||||
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
|
||||
|
||||
Property Filters:
|
||||
|
@ -133,9 +182,9 @@ Property Filters:
|
|||
- `#TAG1 TAG2` - set tag filter
|
||||
- `+TAG` - add tag filter (empty: list all used tags)
|
||||
- `-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)
|
||||
- TBI: `**INT` - filter by priority
|
||||
|
||||
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.
|
||||
|
@ -145,21 +194,6 @@ An active tag or status filter will also set that attribute for newly created ta
|
|||
- TBI = To Be Implemented
|
||||
- `. 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
|
||||
|
||||
- Handle event sending rejections (e.g. permissions)
|
||||
|
@ -197,14 +231,17 @@ Suggestions welcome!
|
|||
+ Subtask progress immediate/all/leafs
|
||||
+ path full / leaf / top
|
||||
|
||||
### Interfaces
|
||||
### Interfaces & Integrations
|
||||
|
||||
- TUI: Clear Terminal? Refresh on empty prompt after timeout?
|
||||
- Kanban, GANTT, Calendar
|
||||
- Web Interface
|
||||
- Messenger Integrations (Telegram Bot)
|
||||
- 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
|
||||
|
||||
|
@ -249,7 +286,7 @@ since they will automatically take on that context.
|
|||
By automating these contexts based on triggers, scripts or time,
|
||||
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,
|
||||
and you like to do sports in the morning.
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2024-11-09"
|
|
@ -118,7 +118,7 @@ pub fn format_as_datetime<F>(stamp: &Timestamp, formatter: F) -> String
|
|||
where
|
||||
F: Fn(DateTime<Local>) -> String,
|
||||
{
|
||||
match Local.timestamp_opt(stamp.as_u64() as i64, 0) {
|
||||
match Local.timestamp_opt(stamp.as_u64() as i64 + 1, 0) {
|
||||
Single(time) => formatter(time),
|
||||
_ => 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"),
|
||||
_ => format_timestamp_local(stamp),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
70
src/kinds.rs
70
src/kinds.rs
|
@ -1,10 +1,10 @@
|
|||
use crate::task::{State, MARKER_PARENT};
|
||||
use crate::tasks::HIGH_PRIO;
|
||||
use itertools::Itertools;
|
||||
use log::info;
|
||||
use nostr_sdk::TagStandard::Hashtag;
|
||||
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagStandard};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::task::{State, MARKER_PARENT};
|
||||
use nostr_sdk::{Alphabet, EventBuilder, EventId, Kind, Tag, TagKind, TagStandard};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub const TASK_KIND: Kind = Kind::GitIssue;
|
||||
pub const PROCEDURE_KIND_ID: u16 = 1639;
|
||||
|
@ -25,6 +25,9 @@ pub const PROP_KINDS: [Kind; 6] = [
|
|||
PROCEDURE_KIND,
|
||||
];
|
||||
|
||||
pub type Prio = u16;
|
||||
pub const PRIO: &str = "priority";
|
||||
|
||||
// TODO: use formatting - bold / heading / italics - and generate from code
|
||||
/// Helper for available properties.
|
||||
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)
|
||||
}
|
||||
|
||||
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.
|
||||
pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
|
||||
input.split_ascii_whitespace()
|
||||
|
@ -91,19 +82,35 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
|
|||
.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.
|
||||
pub(crate) fn extract_tags(input: &str) -> (&str, Vec<Tag>) {
|
||||
match input.split_once(": ") {
|
||||
None => (input, extract_hashtags(input).collect_vec()),
|
||||
Some((name, tags)) => {
|
||||
let tags = extract_hashtags(name)
|
||||
.chain(tags.split_ascii_whitespace().map(to_hashtag))
|
||||
.collect();
|
||||
(name, tags)
|
||||
pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
|
||||
let words = input.split_ascii_whitespace();
|
||||
let mut prio = None;
|
||||
let result = words.filter(|s| {
|
||||
if s.starts_with('*') {
|
||||
if s.len() == 1 {
|
||||
prio = Some(HIGH_PRIO);
|
||||
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 {
|
||||
|
@ -136,9 +143,16 @@ pub(crate) fn is_hashtag(tag: &Tag) -> bool {
|
|||
.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]
|
||||
fn test_extract_tags() {
|
||||
assert_eq!(extract_tags("Hello from #mars with #greetings: yeah done-it"),
|
||||
("Hello from #mars with #greetings", ["mars", "greetings", "yeah", "done-it"].into_iter().map(to_hashtag).collect()))
|
||||
assert_eq!(extract_tags("Hello from #mars with #greetings *4 # # yeah done-it"),
|
||||
("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![]));
|
||||
}
|
84
src/main.rs
84
src/main.rs
|
@ -4,7 +4,6 @@ use std::env::{args, var};
|
|||
use std::fs;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::iter::once;
|
||||
use std::ops::Sub;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
@ -27,8 +26,8 @@ use tokio::time::error::Elapsed;
|
|||
use tokio::time::timeout;
|
||||
|
||||
use crate::helpers::*;
|
||||
use crate::kinds::{BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
|
||||
use crate::task::{State, Task, TaskState, MARKER_DEPENDS};
|
||||
use crate::kinds::{Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
|
||||
use crate::task::{State, Task, TaskState};
|
||||
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay};
|
||||
|
||||
mod helpers;
|
||||
|
@ -81,11 +80,12 @@ impl EventSender {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO this direly needs testing
|
||||
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
|
||||
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()) {
|
||||
drop(borrow);
|
||||
debug!("Flushing event queue because it is older than a minute");
|
||||
|
@ -94,10 +94,9 @@ impl EventSender {
|
|||
}
|
||||
let mut queue = self.queue.borrow_mut();
|
||||
Ok(event_builder.to_event(&self.keys).inspect(|event| {
|
||||
if event.kind == TRACKING_KIND {
|
||||
queue.retain(|e| {
|
||||
e.kind != TRACKING_KIND
|
||||
});
|
||||
if event.kind == TRACKING_KIND && event.created_at > min && event.created_at < tasks::now() {
|
||||
// Do not send redundant movements
|
||||
queue.retain(|e| e.kind != TRACKING_KIND);
|
||||
}
|
||||
queue.push(event.clone());
|
||||
})?)
|
||||
|
@ -346,10 +345,11 @@ async fn main() -> Result<()> {
|
|||
println!();
|
||||
let tasks = relays.get(&selected_relay).unwrap();
|
||||
let prompt = format!(
|
||||
"{} {}{}) ",
|
||||
"{} {}{}{}",
|
||||
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_prompt_suffix().italic(),
|
||||
"❯ ".dimmed()
|
||||
);
|
||||
match rl.readline(&prompt) {
|
||||
Ok(input) => {
|
||||
|
@ -409,6 +409,16 @@ async fn main() -> Result<()> {
|
|||
let arg_default = arg.unwrap_or("");
|
||||
match operator {
|
||||
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 next = iter.next();
|
||||
let remaining = iter.collect::<String>().trim().to_string();
|
||||
|
@ -437,20 +447,18 @@ async fn main() -> Result<()> {
|
|||
Some(',') =>
|
||||
match arg {
|
||||
None => {
|
||||
match tasks.get_current_task() {
|
||||
None => {
|
||||
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(task) => {
|
||||
if let Some(task) = tasks.get_current_task() {
|
||||
let mut desc = task.description_events().peekable();
|
||||
if desc.peek().is_some() {
|
||||
println!("{}",
|
||||
task.description_events()
|
||||
.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
|
||||
desc.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
|
||||
.join("\n"));
|
||||
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) => {
|
||||
if arg.len() < CHARACTER_THRESHOLD {
|
||||
|
@ -526,7 +534,7 @@ async fn main() -> Result<()> {
|
|||
match arg {
|
||||
None => match tasks.get_position() {
|
||||
None => {
|
||||
info!("Filtering for bookmarked tasks");
|
||||
info!("Showing only bookmarked tasks");
|
||||
tasks.set_view_bookmarks();
|
||||
}
|
||||
Some(pos) =>
|
||||
|
@ -536,7 +544,15 @@ async fn main() -> Result<()> {
|
|||
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 {
|
||||
None => match tasks.get_position() {
|
||||
None => {
|
||||
info!("Use | to create dependent sibling task and || to create a procedure");
|
||||
tasks.set_state_filter(
|
||||
StateFilter::State(State::Procedure.to_string()));
|
||||
}
|
||||
|
@ -553,12 +570,7 @@ async fn main() -> Result<()> {
|
|||
},
|
||||
Some(arg) => 'arm: {
|
||||
if !arg.starts_with('|') {
|
||||
if let Some(pos) = tasks.get_position() {
|
||||
tasks.move_up();
|
||||
tasks.make_task_with(
|
||||
arg,
|
||||
once(tasks.make_event_tag_from_id(pos, MARKER_DEPENDS)),
|
||||
true);
|
||||
if tasks.make_dependent_sibling(arg) {
|
||||
break 'arm;
|
||||
}
|
||||
}
|
||||
|
@ -608,18 +620,21 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
Some('#') =>
|
||||
tasks.set_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())),
|
||||
Some('#') => {
|
||||
if !tasks.update_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
Some('+') =>
|
||||
match arg {
|
||||
Some(arg) => tasks.add_tag(arg.to_string()),
|
||||
None => {
|
||||
println!("Hashtags of all known tasks:\n{}", tasks.all_hashtags().join(" ").italic());
|
||||
tasks.print_hashtags();
|
||||
if tasks.has_tag_filter() {
|
||||
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,
|
||||
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(),
|
||||
times.rev().take(max).collect_vec().iter().rev().join("\n"));
|
||||
} else if let Ok(key) = PublicKey::parse(arg) { // TODO also match name
|
||||
|
@ -653,7 +668,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
let (label, mut times) = tasks.times_tracked();
|
||||
let (label, times) = tasks.times_tracked();
|
||||
println!("{}\n{}", label.italic(),
|
||||
times.rev().take(80).collect_vec().iter().rev().join("\n"));
|
||||
}
|
||||
|
@ -726,7 +741,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
let filtered =
|
||||
tasks.get_filtered(|t| {
|
||||
tasks.get_filtered(pos, |t| {
|
||||
transform(&t.event.content).contains(&remaining) ||
|
||||
t.tags.iter().flatten().any(
|
||||
|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()) {
|
||||
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))) {
|
||||
selected_relay.clone_from(url);
|
||||
println!("{}", tasks);
|
||||
|
@ -775,7 +789,7 @@ async fn main() -> Result<()> {
|
|||
println!("{}", tasks);
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
|
22
src/task.rs
22
src/task.rs
|
@ -1,8 +1,9 @@
|
|||
use fmt::Display;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::iter::once;
|
||||
use std::string::ToString;
|
||||
|
||||
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 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_DEPENDS: &str = "depends";
|
||||
pub static MARKER_PROPERTY: &str = "property";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Task {
|
||||
|
@ -101,12 +103,25 @@ impl Task {
|
|||
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 {
|
||||
self.is_task_kind() ||
|
||||
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> + '_ {
|
||||
self.props.iter().filter_map(|event| {
|
||||
event.kind.try_into().ok().map(|s| TaskState {
|
||||
|
@ -179,6 +194,7 @@ impl Task {
|
|||
"created" => Some(format_timestamp_local(&self.event.created_at)),
|
||||
"kind" => Some(self.event.kind.to_string()),
|
||||
// Dynamic
|
||||
"priority" => self.priority_raw().map(|c| c.to_string()),
|
||||
"status" => self.state_label().map(|c| c.to_string()),
|
||||
"desc" => self.descriptions().last().cloned(),
|
||||
"description" => Some(self.descriptions().join(" ")),
|
||||
|
|
257
src/tasks.rs
257
src/tasks.rs
|
@ -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::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 colored::Colorize;
|
||||
use itertools::{Either, Itertools};
|
||||
|
@ -19,8 +19,12 @@ use regex::bytes::Regex;
|
|||
use tokio::sync::mpsc::Sender;
|
||||
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;
|
||||
fn now() -> Timestamp {
|
||||
pub(crate) fn now() -> Timestamp {
|
||||
Timestamp::now() + MAX_OFFSET
|
||||
}
|
||||
|
||||
|
@ -76,6 +80,8 @@ pub(crate) struct TasksRelay {
|
|||
tags_excluded: BTreeSet<Tag>,
|
||||
/// Current active state
|
||||
state: StateFilter,
|
||||
/// Current priority for filtering and new tasks
|
||||
priority: Option<Prio>,
|
||||
|
||||
sender: EventSender,
|
||||
overflow: VecDeque<Event>,
|
||||
|
@ -149,6 +155,7 @@ impl TasksRelay {
|
|||
|
||||
properties: [
|
||||
"author",
|
||||
"prio",
|
||||
"state",
|
||||
"rtime",
|
||||
"hashtags",
|
||||
|
@ -156,7 +163,8 @@ impl TasksRelay {
|
|||
"desc",
|
||||
].into_iter().map(|s| s.to_string()).collect(),
|
||||
sorting: [
|
||||
"state",
|
||||
"priority",
|
||||
"status",
|
||||
"author",
|
||||
"hashtags",
|
||||
"rtime",
|
||||
|
@ -167,6 +175,8 @@ impl TasksRelay {
|
|||
tags: Default::default(),
|
||||
tags_excluded: Default::default(),
|
||||
state: Default::default(),
|
||||
priority: None,
|
||||
|
||||
search_depth: 4,
|
||||
view_depth: 0,
|
||||
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))))
|
||||
.join(" "));
|
||||
if new != last {
|
||||
// TODO omit intervals <2min - but I think I need threeway for that
|
||||
// TODO alternate color with grey between days
|
||||
full.push(format!("{} {}", format_timestamp_local(&event.created_at), new.as_ref().unwrap_or(&"---".to_string())));
|
||||
last = new;
|
||||
|
@ -274,10 +285,13 @@ impl TasksRelay {
|
|||
let mut vec = Vec::with_capacity(set.len() / 2);
|
||||
let mut iter = timestamps(set.values(), &ids).tuples();
|
||||
while let Some(((start, _), (end, _))) = iter.next() {
|
||||
vec.push(format!("{} - {} by {}",
|
||||
format_timestamp_local(start),
|
||||
format_timestamp_relative_to(end, start),
|
||||
self.get_username(key)))
|
||||
// Filter out intervals <2 mins
|
||||
if start.as_u64() + 120 < end.as_u64() {
|
||||
vec.push(format!("{} - {} by {}",
|
||||
format_timestamp_local(start),
|
||||
format_timestamp_relative_to(end, start),
|
||||
self.get_username(key)))
|
||||
}
|
||||
}
|
||||
iter.into_buffer()
|
||||
.for_each(|(stamp, _)|
|
||||
|
@ -294,7 +308,6 @@ impl TasksRelay {
|
|||
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.
|
||||
fn total_time_tracked(&self, id: EventId) -> u64 {
|
||||
let mut total = 0;
|
||||
|
@ -349,6 +362,7 @@ impl TasksRelay {
|
|||
.chain(self.tags_excluded.iter()
|
||||
.map(|t| format!(" -#{}", t.content().unwrap())))
|
||||
.chain(once(self.state.indicator()))
|
||||
.chain(self.priority.map(|p| format!(" *{:02}", p)))
|
||||
.join("")
|
||||
}
|
||||
|
||||
|
@ -385,27 +399,27 @@ impl TasksRelay {
|
|||
) -> Vec<&'a Task> {
|
||||
iter.sorted_by_cached_key(|task| self.sorting_key(task))
|
||||
.flat_map(move |task| {
|
||||
if !self.state.matches(task) {
|
||||
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;
|
||||
if !self.state.matches(task) {
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
if new_depth > 0 {
|
||||
let mut children = self.resolve_tasks_rec(self.tasks.children_of(&task), sparse, new_depth);
|
||||
if !children.is_empty() {
|
||||
if !sparse {
|
||||
children.push(task);
|
||||
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;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
}
|
||||
return if self.filter(task) { vec![task] } else { vec![] };
|
||||
}).collect_vec()
|
||||
if new_depth > 0 {
|
||||
let mut children = self.resolve_tasks_rec(self.tasks.children_of(&task), sparse, new_depth);
|
||||
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.
|
||||
|
@ -414,7 +428,7 @@ impl TasksRelay {
|
|||
let mut found = false;
|
||||
for tag in event.tags.iter() {
|
||||
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| {
|
||||
found = true;
|
||||
f(t)
|
||||
|
@ -432,6 +446,9 @@ impl TasksRelay {
|
|||
|
||||
fn filter(&self, task: &Task) -> bool {
|
||||
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| {
|
||||
!tags.iter().any(|tag| self.tags_excluded.contains(tag))
|
||||
}) &&
|
||||
|
@ -478,7 +495,8 @@ impl TasksRelay {
|
|||
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 {
|
||||
return vec![];
|
||||
}
|
||||
|
@ -524,7 +542,13 @@ impl TasksRelay {
|
|||
}
|
||||
"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)),
|
||||
"rpath" => self.relative_path(task.event.id),
|
||||
// TODO format strings configurable
|
||||
|
@ -577,11 +601,11 @@ impl TasksRelay {
|
|||
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
|
||||
P: Fn(&&Task) -> bool,
|
||||
{
|
||||
self.filtered_tasks(self.get_position_ref(), false)
|
||||
self.filtered_tasks(position, false)
|
||||
.into_iter()
|
||||
.filter(predicate)
|
||||
.map(|t| t.event.id)
|
||||
|
@ -592,7 +616,7 @@ impl TasksRelay {
|
|||
where
|
||||
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 {
|
||||
|
@ -623,7 +647,26 @@ impl TasksRelay {
|
|||
!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.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) {
|
||||
self.view.clear();
|
||||
info!("Filtering for {}", state);
|
||||
|
@ -830,15 +882,34 @@ impl TasksRelay {
|
|||
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
|
||||
///
|
||||
/// Sanitizes input
|
||||
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 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(
|
||||
build_task(input, input_tags, None)
|
||||
build_task(&input, input_tags, None)
|
||||
.add_tags(self.tags.iter().cloned())
|
||||
.add_tags(tags)
|
||||
.add_tags(prio)
|
||||
);
|
||||
if set_state {
|
||||
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> {
|
||||
if target.is_none() {
|
||||
// Prevent random overlap with tracking started in the same second
|
||||
time = time - 1;
|
||||
} else if let Some(hist) = self.history.get(&self.sender.pubkey()) {
|
||||
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 {
|
||||
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(),
|
||||
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 \"{}\"{}",
|
||||
TaskState::get_label_for(&state, comment),
|
||||
self.get_task_title(&id),
|
||||
|
@ -1033,13 +1113,17 @@ impl TasksRelay {
|
|||
pub(crate) fn make_note(&mut self, note: &str) -> EventId {
|
||||
if let Some(id) = self.get_position_ref() {
|
||||
if self.get_by_id(id).is_some_and(|t| t.is_task()) {
|
||||
let prop = build_prop(Kind::TextNote, note.trim(), *id);
|
||||
return self.submit(prop)
|
||||
let prop = EventBuilder::new(
|
||||
Kind::TextNote,
|
||||
note.trim(),
|
||||
[Tag::event(*id)],
|
||||
);
|
||||
return self.submit(prop);
|
||||
}
|
||||
}
|
||||
let (input, tags) = extract_tags(note.trim());
|
||||
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.tags.iter().cloned())
|
||||
)
|
||||
|
@ -1105,8 +1189,8 @@ impl Display for TasksRelay {
|
|||
}
|
||||
|
||||
let position = self.get_position_ref();
|
||||
let mut current = vec![];
|
||||
let mut roots = self.view.iter().flat_map(|id| self.get_by_id(id)).collect_vec();
|
||||
let mut current: Vec<&Task>;
|
||||
let roots = self.view.iter().flat_map(|id| self.get_by_id(id)).collect_vec();
|
||||
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);
|
||||
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 ids: HashSet<&EventId> = tree.map(|t| t.get_id()).chain(position).collect();
|
||||
let mut bookmarks =
|
||||
// TODO add recent tasks (most time tracked + recently created)
|
||||
self.bookmarks.iter()
|
||||
.chain(self.tasks.values().sorted_unstable().take(3).map(|t| t.get_id()))
|
||||
.filter(|id| !ids.contains(id))
|
||||
.filter_map(|id| self.get_by_id(id))
|
||||
.filter(|t| self.filter(t))
|
||||
.sorted_by_cached_key(|t| self.sorting_key(t))
|
||||
.dedup()
|
||||
.peekable();
|
||||
if bookmarks.peek().is_some() {
|
||||
writeln!(lock, "{}", Colorize::bold("Quick Access"))?;
|
||||
for task in bookmarks {
|
||||
writeln!(
|
||||
lock,
|
||||
"{}",
|
||||
self.properties.iter()
|
||||
.map(|p| self.get_property(task, p.as_str()))
|
||||
.join(" \t")
|
||||
)?;
|
||||
if self.view.is_empty() {
|
||||
let mut bookmarks =
|
||||
// TODO add recent tasks (most time tracked + recently created)
|
||||
self.bookmarks.iter()
|
||||
.chain(self.tasks.values().sorted_unstable().take(3).map(|t| t.get_id()))
|
||||
.filter(|id| !ids.contains(id))
|
||||
.filter_map(|id| self.get_by_id(id))
|
||||
.filter(|t| self.filter(t))
|
||||
.sorted_by_cached_key(|t| self.sorting_key(t))
|
||||
.dedup()
|
||||
.peekable();
|
||||
if bookmarks.peek().is_some() {
|
||||
writeln!(lock, "{}", Colorize::bold("Quick Access"))?;
|
||||
for task in bookmarks {
|
||||
writeln!(
|
||||
lock,
|
||||
"{}",
|
||||
self.properties.iter()
|
||||
.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.
|
||||
/// MMM - minutes
|
||||
/// MM - minutes of the hour
|
||||
/// HH - hours
|
||||
/// Returns an empty string if under a minute.
|
||||
/// - MMM - minutes
|
||||
/// - MM - minutes of the hour
|
||||
/// - HH - hours
|
||||
///
|
||||
/// Returns an empty string if under one minute.
|
||||
fn display_time(format: &str, secs: u64) -> String {
|
||||
Some(secs / 60)
|
||||
.filter(|t| t > &0)
|
||||
|
@ -1266,6 +1353,7 @@ fn referenced_event(event: &Event) -> Option<&EventId> {
|
|||
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> {
|
||||
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]
|
||||
fn test_bookmarks() {
|
||||
let mut tasks = stub_tasks();
|
||||
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");
|
||||
assert_eq!(tasks.visible_tasks().len(), 2);
|
||||
tasks.move_to(Some(parent));
|
||||
|
@ -1584,15 +1700,14 @@ mod tasks_test {
|
|||
#[test]
|
||||
fn test_procedures() {
|
||||
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);
|
||||
let side = tasks.submit(
|
||||
build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
|
||||
assert_eq!(tasks.visible_tasks(),
|
||||
Vec::<&Task>::new());
|
||||
let sub_id = tasks.make_task("sub");
|
||||
assert_eq!(tasks.visible_tasks().iter().map(|t| t.event.id).collect_vec(),
|
||||
Vec::from([sub_id]));
|
||||
assert_tasks!(tasks, [sub_id]);
|
||||
assert_eq!(tasks.len(), 3);
|
||||
let sub = tasks.get_by_id(&sub_id).unwrap();
|
||||
assert_eq!(sub.get_dependendees(), Vec::<&EventId>::new());
|
||||
|
@ -1701,7 +1816,7 @@ mod tasks_test {
|
|||
assert_position!(tasks, t1);
|
||||
tasks.search_depth = 2;
|
||||
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.get_task_path(Some(t11)), "t1>t11");
|
||||
assert_eq!(tasks.relative_path(t11), "t11");
|
||||
|
@ -1797,4 +1912,4 @@ mod tasks_test {
|
|||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue