Compare commits
13 commits
baf93bd788
...
33a1e89c16
Author | SHA1 | Date | |
---|---|---|---|
|
33a1e89c16 | ||
|
b81e5a27bf | ||
|
8f0a169677 | ||
|
ae525c870f | ||
|
b9307b7b5d | ||
|
e9bee3c114 | ||
|
dc8df51e0f | ||
|
cc64c0f493 | ||
|
5a8fa69e4c | ||
|
f33d890d7f | ||
|
dd78a2f460 | ||
|
5303d0cb41 | ||
|
2053f045b2 |
8 changed files with 306 additions and 139 deletions
44
DESIGN.md
Normal file
44
DESIGN.md
Normal 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.
|
69
README.md
69
README.md
|
@ -82,16 +82,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
|
||||
|
@ -100,7 +140,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.
|
||||
|
@ -115,7 +156,6 @@ 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 (activity / task description)
|
||||
- TBI: `;[TEXT]` - list comments or comment on task
|
||||
- TBI: show status history and creation with attribution
|
||||
|
@ -130,9 +170,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
|
||||
- `@[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.
|
||||
|
@ -142,21 +182,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)
|
||||
|
@ -249,7 +274,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.
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
[toolchain]
|
||||
channel = "1.81.0"
|
||||
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(),
|
||||
}
|
||||
|
|
67
src/kinds.rs
67
src/kinds.rs
|
@ -1,10 +1,11 @@
|
|||
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;
|
||||
use std::iter::once;
|
||||
|
||||
pub const TASK_KIND: Kind = Kind::GitIssue;
|
||||
pub const PROCEDURE_KIND_ID: u16 = 1639;
|
||||
|
@ -25,6 +26,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 +75,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 +83,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 +144,14 @@ 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(once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))).collect()))
|
||||
}
|
48
src/main.rs
48
src/main.rs
|
@ -5,7 +5,7 @@ use std::fs;
|
|||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::iter::once;
|
||||
use std::ops::Sub;
|
||||
use std::ops::{Add, Sub};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
|
@ -27,7 +27,7 @@ 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::kinds::{Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
|
||||
use crate::task::{State, Task, TaskState, MARKER_DEPENDS};
|
||||
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay};
|
||||
|
||||
|
@ -81,11 +81,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 +95,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 +346,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) => {
|
||||
|
@ -536,8 +537,7 @@ async fn main() -> Result<()> {
|
|||
match arg {
|
||||
None => match tasks.get_position() {
|
||||
None => {
|
||||
info!("Filtering for bookmarked tasks");
|
||||
tasks.set_view_bookmarks();
|
||||
tasks.set_priority(None);
|
||||
}
|
||||
Some(pos) =>
|
||||
match or_warn!(tasks.toggle_bookmark(pos)) {
|
||||
|
@ -546,7 +546,16 @@ async fn main() -> Result<()> {
|
|||
None => {}
|
||||
}
|
||||
},
|
||||
Some(arg) => info!("Setting priority not yet implemented"),
|
||||
Some(arg) => {
|
||||
if arg == "*" {
|
||||
info!("Showing only bookmarked tasks");
|
||||
tasks.set_view_bookmarks();
|
||||
} 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 })));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -554,6 +563,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()));
|
||||
}
|
||||
|
@ -563,12 +573,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;
|
||||
}
|
||||
}
|
||||
|
@ -648,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
|
||||
|
@ -663,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"));
|
||||
}
|
||||
|
@ -736,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)))
|
||||
|
@ -754,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);
|
||||
|
@ -785,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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ use crate::kinds::{is_hashtag, 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 {
|
||||
|
|
130
src/tasks.rs
130
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>,
|
||||
|
@ -167,6 +173,8 @@ impl TasksRelay {
|
|||
tags: Default::default(),
|
||||
tags_excluded: Default::default(),
|
||||
state: Default::default(),
|
||||
priority: None,
|
||||
|
||||
search_depth: 4,
|
||||
view_depth: 0,
|
||||
recurse_activities: true,
|
||||
|
@ -298,7 +306,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;
|
||||
|
@ -353,6 +360,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("")
|
||||
}
|
||||
|
||||
|
@ -418,7 +426,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)
|
||||
|
@ -482,7 +490,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![];
|
||||
}
|
||||
|
@ -581,11 +590,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)
|
||||
|
@ -596,7 +605,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 {
|
||||
|
@ -652,6 +661,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);
|
||||
|
@ -834,15 +852,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().find(|t| t.kind().to_string() == PRIO).is_some() { 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));
|
||||
|
@ -865,6 +902,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() {
|
||||
|
@ -1017,11 +1055,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),
|
||||
|
@ -1037,13 +1083,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())
|
||||
)
|
||||
|
@ -1109,8 +1159,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() {
|
||||
|
@ -1141,6 +1191,7 @@ 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();
|
||||
if self.view.is_empty() {
|
||||
let mut bookmarks =
|
||||
// TODO add recent tasks (most time tracked + recently created)
|
||||
self.bookmarks.iter()
|
||||
|
@ -1163,6 +1214,7 @@ impl Display for TasksRelay {
|
|||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO proper column alignment
|
||||
// TODO hide empty columns
|
||||
|
@ -1223,10 +1275,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)
|
||||
|
@ -1545,11 +1598,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));
|
||||
|
@ -1589,15 +1670,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());
|
||||
|
@ -1706,7 +1786,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");
|
||||
|
|
Loading…
Add table
Reference in a new issue