forked from janek/mostr
Compare commits
17 Commits
c2f775e891
...
49d8eef29c
Author | SHA1 | Date |
---|---|---|
xeruf | 49d8eef29c | |
xeruf | 74fff5a2b1 | |
xeruf | bdb8b6e814 | |
xeruf | b0c92e64fa | |
xeruf | 4e4ad7099f | |
xeruf | 613a8b3822 | |
xeruf | 1533676bff | |
xeruf | 52be8c53eb | |
xeruf | 5f25e116a1 | |
xeruf | d1720f89ae | |
xeruf | f6082f12f2 | |
xeruf | 3d389e8d52 | |
xeruf | 28d1f4c983 | |
xeruf | 93fde86169 | |
xeruf | 769b9578fe | |
xeruf | c27ccb8282 | |
xeruf | d744fb8457 |
|
@ -65,8 +65,8 @@ where
|
||||||
|
|
||||||
/// Build a task with informational output and optional labeled kind
|
/// Build a task with informational output and optional labeled kind
|
||||||
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
pub(crate) fn build_task(name: &str, tags: Vec<Tag>, kind: Option<(&str, Kind)>) -> EventBuilder {
|
||||||
info!("Created {}task \"{name}\" with tags [{}]",
|
info!("Created {} \"{name}\" with tags [{}]",
|
||||||
kind.map(|k| k.0).unwrap_or_default(),
|
kind.map(|k| k.0).unwrap_or("task"),
|
||||||
tags.iter().map(format_tag).join(", "));
|
tags.iter().map(format_tag).join(", "));
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
63
src/main.rs
63
src/main.rs
|
@ -25,7 +25,6 @@ use tokio::sync::mpsc;
|
||||||
use tokio::sync::mpsc::Sender;
|
use tokio::sync::mpsc::Sender;
|
||||||
use tokio::time::error::Elapsed;
|
use tokio::time::error::Elapsed;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use xdg::BaseDirectories;
|
|
||||||
|
|
||||||
use crate::helpers::*;
|
use crate::helpers::*;
|
||||||
use crate::kinds::{BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
|
use crate::kinds::{BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS, TRACKING_KIND};
|
||||||
|
@ -169,7 +168,7 @@ async fn main() -> Result<()> {
|
||||||
);
|
);
|
||||||
builder.init();
|
builder.init();
|
||||||
|
|
||||||
let config_dir = or_warn!(BaseDirectories::new(), "Could not determine config directory")
|
let config_dir = or_warn!(xdg::BaseDirectories::new(), "Could not determine config directory")
|
||||||
.and_then(|d| or_warn!(d.create_config_directory("mostr"), "Could not create config directory"))
|
.and_then(|d| or_warn!(d.create_config_directory("mostr"), "Could not create config directory"))
|
||||||
.unwrap_or(PathBuf::new());
|
.unwrap_or(PathBuf::new());
|
||||||
let keysfile = config_dir.join("key");
|
let keysfile = config_dir.join("key");
|
||||||
|
@ -300,7 +299,7 @@ async fn main() -> Result<()> {
|
||||||
queue_events.append(&mut events);
|
queue_events.append(&mut events);
|
||||||
queue = Some((queue_url, queue_events));
|
queue = Some((queue_url, queue_events));
|
||||||
} else {
|
} else {
|
||||||
info!("Sending {} events to {url} due to relay change", queue_events.len());
|
info!("Sending {} events to {queue_url} due to relay change", queue_events.len());
|
||||||
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
|
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
|
||||||
queue = None;
|
queue = None;
|
||||||
}
|
}
|
||||||
|
@ -387,7 +386,7 @@ async fn main() -> Result<()> {
|
||||||
None => {
|
None => {
|
||||||
debug!("Flushing Tasks because of empty command");
|
debug!("Flushing Tasks because of empty command");
|
||||||
tasks.flush();
|
tasks.flush();
|
||||||
or_warn!(tasks.print_tasks());
|
println!("{}", tasks);
|
||||||
continue 'repl;
|
continue 'repl;
|
||||||
}
|
}
|
||||||
Some('@') => {}
|
Some('@') => {}
|
||||||
|
@ -438,18 +437,27 @@ async fn main() -> Result<()> {
|
||||||
Some(',') =>
|
Some(',') =>
|
||||||
match arg {
|
match arg {
|
||||||
None => {
|
None => {
|
||||||
tasks.get_current_task().map_or_else(
|
match tasks.get_current_task() {
|
||||||
|| info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes"),
|
None => {
|
||||||
|task| println!("{}", task.description_events().map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content)).join("\n")),
|
info!("With a task selected, use ,NOTE to attach NOTE and , to list all its notes");
|
||||||
);
|
tasks.recurse_activities = !tasks.recurse_activities;
|
||||||
continue 'repl;
|
info!("Toggled activities recursion to {}", tasks.recurse_activities);
|
||||||
|
}
|
||||||
|
Some(task) => {
|
||||||
|
println!("{}",
|
||||||
|
task.description_events()
|
||||||
|
.map(|e| format!("{} {}", format_timestamp_local(&e.created_at), e.content))
|
||||||
|
.join("\n"));
|
||||||
|
continue 'repl;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(arg) => {
|
Some(arg) => {
|
||||||
if arg.len() < CHARACTER_THRESHOLD {
|
if arg.len() < CHARACTER_THRESHOLD {
|
||||||
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
|
warn!("Note needs at least {CHARACTER_THRESHOLD} characters!");
|
||||||
continue 'repl;
|
continue 'repl;
|
||||||
}
|
}
|
||||||
tasks.make_note(arg)
|
tasks.make_note(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -492,9 +500,12 @@ async fn main() -> Result<()> {
|
||||||
info!("Filtering for own tasks");
|
info!("Filtering for own tasks");
|
||||||
tasks.set_filter_author(keys.public_key())
|
tasks.set_filter_author(keys.public_key())
|
||||||
} else if let Ok(key) = PublicKey::from_str(arg) {
|
} else if let Ok(key) = PublicKey::from_str(arg) {
|
||||||
let author = tasks.get_author(&key);
|
let author = tasks.get_username(&key);
|
||||||
info!("Filtering for tasks by {author}");
|
info!("Filtering for tasks by {author}");
|
||||||
tasks.set_filter_author(key)
|
tasks.set_filter_author(key)
|
||||||
|
} else if let Some((key, meta)) = tasks.find_user(arg) {
|
||||||
|
info!("Filtering for tasks by {}", meta.display_name.as_ref().unwrap_or(meta.name.as_ref().unwrap_or(&key.to_string())));
|
||||||
|
tasks.set_filter_author(key.clone())
|
||||||
} else {
|
} else {
|
||||||
parse_hour(arg, 1)
|
parse_hour(arg, 1)
|
||||||
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
|
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
|
||||||
|
@ -675,13 +686,18 @@ async fn main() -> Result<()> {
|
||||||
} else {
|
} else {
|
||||||
tasks.clear_filters();
|
tasks.clear_filters();
|
||||||
}
|
}
|
||||||
} else if let Ok(depth) = remaining.parse::<usize>() {
|
|
||||||
if pos != tasks.get_position_ref() {
|
|
||||||
tasks.move_to(pos.cloned());
|
|
||||||
}
|
|
||||||
tasks.set_depth(depth);
|
|
||||||
} else {
|
} else {
|
||||||
tasks.filter_or_create(pos.cloned().as_ref(), &remaining).map(|id| tasks.move_to(Some(id)));
|
match remaining.parse::<usize>() {
|
||||||
|
Ok(depth) if depth < 10 => {
|
||||||
|
if pos != tasks.get_position_ref() {
|
||||||
|
tasks.move_to(pos.cloned());
|
||||||
|
}
|
||||||
|
tasks.set_view_depth(depth);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tasks.filter_or_create(pos.cloned().as_ref(), &remaining).map(|id| tasks.move_to(Some(id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -696,6 +712,11 @@ async fn main() -> Result<()> {
|
||||||
if dots > 1 {
|
if dots > 1 {
|
||||||
info!("Moving up {} tasks", dots - 1)
|
info!("Moving up {} tasks", dots - 1)
|
||||||
}
|
}
|
||||||
|
} else if let Ok(depth) = remaining.parse::<usize>() {
|
||||||
|
if pos != tasks.get_position_ref() {
|
||||||
|
tasks.move_to(pos.cloned());
|
||||||
|
}
|
||||||
|
tasks.set_search_depth(depth);
|
||||||
} else {
|
} else {
|
||||||
// TODO regex match
|
// TODO regex match
|
||||||
let mut transform: Box<dyn Fn(&str) -> String> = Box::new(|s: &str| s.to_string());
|
let mut transform: Box<dyn Fn(&str) -> String> = Box::new(|s: &str| s.to_string());
|
||||||
|
@ -714,7 +735,9 @@ async fn main() -> Result<()> {
|
||||||
tasks.move_to(filtered.into_iter().next());
|
tasks.move_to(filtered.into_iter().next());
|
||||||
} else {
|
} else {
|
||||||
tasks.move_to(pos.cloned());
|
tasks.move_to(pos.cloned());
|
||||||
tasks.set_view(filtered);
|
if !tasks.set_view(filtered) {
|
||||||
|
continue 'repl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -724,7 +747,7 @@ async fn main() -> Result<()> {
|
||||||
tasks.move_to(None);
|
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);
|
||||||
or_warn!(tasks.print_tasks());
|
println!("{}", tasks);
|
||||||
continue 'repl;
|
continue 'repl;
|
||||||
}
|
}
|
||||||
or_warn!(Url::parse(&command), "Failed to parse url {}", command).map(|url| {
|
or_warn!(Url::parse(&command), "Failed to parse url {}", command).map(|url| {
|
||||||
|
@ -749,7 +772,7 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tasks.custom_time = None;
|
tasks.custom_time = None;
|
||||||
or_warn!(tasks.print_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 if prompt was empty, or clear
|
||||||
|
|
12
src/task.rs
12
src/task.rs
|
@ -2,6 +2,7 @@ use fmt::Display;
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::collections::{BTreeSet, HashSet};
|
use std::collections::{BTreeSet, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::string::ToString;
|
use std::string::ToString;
|
||||||
|
|
||||||
use colored::{ColoredString, Colorize};
|
use colored::{ColoredString, Colorize};
|
||||||
|
@ -40,6 +41,12 @@ impl Ord for Task {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Hash for Task {
|
||||||
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||||
|
self.event.id.hash(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Task {
|
impl Task {
|
||||||
pub(crate) fn new(event: Event) -> Task {
|
pub(crate) fn new(event: Event) -> Task {
|
||||||
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
|
let (refs, tags) = event.tags.iter().partition_map(|tag| match tag.as_standardized() {
|
||||||
|
@ -94,9 +101,10 @@ impl Task {
|
||||||
self.event.kind == TASK_KIND
|
self.event.kind == TASK_KIND
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this is an actionable task - false if stateless
|
||||||
pub(crate) fn is_task(&self) -> bool {
|
pub(crate) fn is_task(&self) -> bool {
|
||||||
self.is_task_kind() ||
|
self.is_task_kind() ||
|
||||||
self.states().next().is_some()
|
self.props.iter().any(|event| State::try_from(event.kind).is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
|
fn states(&self) -> impl DoubleEndedIterator<Item=TaskState> + '_ {
|
||||||
|
@ -134,7 +142,7 @@ impl Task {
|
||||||
self.state().unwrap_or_else(|| self.default_state())
|
self.state().unwrap_or_else(|| self.default_state())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns None for a stateless task.
|
/// Returns None for activities.
|
||||||
pub(crate) fn state_label(&self) -> Option<ColoredString> {
|
pub(crate) fn state_label(&self) -> Option<ColoredString> {
|
||||||
self.state()
|
self.state()
|
||||||
.or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
|
.or_else(|| Some(self.default_state()).filter(|_| self.is_task()))
|
||||||
|
|
312
src/tasks.rs
312
src/tasks.rs
|
@ -60,13 +60,15 @@ pub(crate) struct TasksRelay {
|
||||||
/// The task properties currently visible
|
/// The task properties currently visible
|
||||||
properties: Vec<String>,
|
properties: Vec<String>,
|
||||||
/// The task properties sorted by
|
/// The task properties sorted by
|
||||||
sorting: VecDeque<String>,
|
sorting: VecDeque<String>, // TODO track boolean for reversal?
|
||||||
|
|
||||||
/// A filtered view of the current tasks.
|
/// A filtered view of the current tasks.
|
||||||
/// Would like this to be Task references
|
/// Would like this to be Task references
|
||||||
/// but that doesn't work unless I start meddling with Rc everywhere.
|
/// but that doesn't work unless I start meddling with Rc everywhere.
|
||||||
view: Vec<EventId>,
|
view: Vec<EventId>,
|
||||||
depth: usize,
|
search_depth: usize,
|
||||||
|
view_depth: usize,
|
||||||
|
pub(crate) recurse_activities: bool,
|
||||||
|
|
||||||
/// Currently active tags
|
/// Currently active tags
|
||||||
tags: BTreeSet<Tag>,
|
tags: BTreeSet<Tag>,
|
||||||
|
@ -165,7 +167,9 @@ impl TasksRelay {
|
||||||
tags: Default::default(),
|
tags: Default::default(),
|
||||||
tags_excluded: Default::default(),
|
tags_excluded: Default::default(),
|
||||||
state: Default::default(),
|
state: Default::default(),
|
||||||
depth: 1,
|
search_depth: 4,
|
||||||
|
view_depth: 0,
|
||||||
|
recurse_activities: true,
|
||||||
|
|
||||||
sender,
|
sender,
|
||||||
overflow: Default::default(),
|
overflow: Default::default(),
|
||||||
|
@ -206,6 +210,13 @@ impl TasksRelay {
|
||||||
self.get_position_at(now()).1
|
self.get_position_at(now()).1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sorting_key(&self, task: &Task) -> impl Ord {
|
||||||
|
self.sorting
|
||||||
|
.iter()
|
||||||
|
.map(|p| self.get_property(task, p.as_str()))
|
||||||
|
.collect_vec()
|
||||||
|
}
|
||||||
|
|
||||||
// TODO binary search
|
// TODO binary search
|
||||||
/// Gets last position change before the given timestamp
|
/// Gets last position change before the given timestamp
|
||||||
fn get_position_at(&self, timestamp: Timestamp) -> (Timestamp, Option<&EventId>) {
|
fn get_position_at(&self, timestamp: Timestamp) -> (Timestamp, Option<&EventId>) {
|
||||||
|
@ -266,11 +277,11 @@ impl TasksRelay {
|
||||||
vec.push(format!("{} - {} by {}",
|
vec.push(format!("{} - {} by {}",
|
||||||
format_timestamp_local(start),
|
format_timestamp_local(start),
|
||||||
format_timestamp_relative_to(end, start),
|
format_timestamp_relative_to(end, start),
|
||||||
self.get_author(key)))
|
self.get_username(key)))
|
||||||
}
|
}
|
||||||
iter.into_buffer()
|
iter.into_buffer()
|
||||||
.for_each(|(stamp, _)|
|
.for_each(|(stamp, _)|
|
||||||
vec.push(format!("{} started by {}", format_timestamp_local(stamp), self.get_author(key))));
|
vec.push(format!("{} started by {}", format_timestamp_local(stamp), self.get_username(key))));
|
||||||
vec
|
vec
|
||||||
}).sorted_unstable(); // TODO sorting depends on timestamp format - needed to interleave different people
|
}).sorted_unstable(); // TODO sorting depends on timestamp format - needed to interleave different people
|
||||||
(format!("Times Tracked on {:?}", self.get_task_title(id)), Box::from(history))
|
(format!("Times Tracked on {:?}", self.get_task_title(id)), Box::from(history))
|
||||||
|
@ -366,25 +377,24 @@ impl TasksRelay {
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
fn resolve_tasks<'a>(
|
|
||||||
&'a self,
|
|
||||||
iter: impl Iterator<Item=&'a Task>,
|
|
||||||
sparse: bool,
|
|
||||||
) -> Vec<&'a Task> {
|
|
||||||
self.resolve_tasks_rec(iter, sparse, self.depth)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_tasks_rec<'a>(
|
fn resolve_tasks_rec<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
iter: impl Iterator<Item=&'a Task>,
|
iter: impl Iterator<Item=&'a Task>,
|
||||||
sparse: bool,
|
sparse: bool,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
) -> Vec<&'a Task> {
|
) -> Vec<&'a Task> {
|
||||||
iter.flat_map(move |task| {
|
iter.sorted_by_cached_key(|task| self.sorting_key(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 new_depth = depth - 1;
|
|
||||||
if new_depth > 0 {
|
if new_depth > 0 {
|
||||||
let mut children = self.resolve_tasks_rec(self.tasks.children_of(&task), sparse, new_depth);
|
let mut children = self.resolve_tasks_rec(self.tasks.children_of(&task), sparse, new_depth);
|
||||||
if !children.is_empty() {
|
if !children.is_empty() {
|
||||||
|
@ -433,7 +443,8 @@ impl TasksRelay {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn filtered_tasks<'a>(&'a self, position: Option<&'a EventId>, sparse: bool) -> Vec<&'a Task> {
|
pub(crate) fn filtered_tasks<'a>(&'a self, position: Option<&'a EventId>, sparse: bool) -> Vec<&'a Task> {
|
||||||
let mut current = self.resolve_tasks(self.tasks.children_for(position), sparse);
|
let roots = self.tasks.children_for(position);
|
||||||
|
let mut current = self.resolve_tasks_rec(roots, sparse, self.search_depth + self.view_depth);
|
||||||
if current.is_empty() {
|
if current.is_empty() {
|
||||||
if !self.tags.is_empty() {
|
if !self.tags.is_empty() {
|
||||||
let mut children = self.tasks.children_for(self.get_position_ref()).peekable();
|
let mut children = self.tasks.children_for(self.get_position_ref()).peekable();
|
||||||
|
@ -443,7 +454,7 @@ impl TasksRelay {
|
||||||
if current.is_empty() {
|
if current.is_empty() {
|
||||||
println!("No tasks here matching{}", self.get_prompt_suffix());
|
println!("No tasks here matching{}", self.get_prompt_suffix());
|
||||||
} else {
|
} else {
|
||||||
println!("Found some matching tasks beyond specified view depth:");
|
println!("Found matching tasks beyond specified search depth:");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -468,7 +479,7 @@ impl TasksRelay {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn visible_tasks(&self) -> Vec<&Task> {
|
pub(crate) fn visible_tasks(&self) -> Vec<&Task> {
|
||||||
if self.depth == 0 {
|
if self.search_depth == 0 {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
if !self.view.is_empty() {
|
if !self.view.is_empty() {
|
||||||
|
@ -477,75 +488,13 @@ impl TasksRelay {
|
||||||
self.filtered_tasks(self.get_position_ref(), true)
|
self.filtered_tasks(self.get_position_ref(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn print_tasks(&self) -> Result<(), Error> {
|
|
||||||
let mut lock = stdout().lock();
|
|
||||||
if let Some(t) = self.get_current_task() {
|
|
||||||
let state = t.state_or_default();
|
|
||||||
let now = &now();
|
|
||||||
let mut tracking_stamp: Option<Timestamp> = None;
|
|
||||||
for elem in
|
|
||||||
timestamps(self.get_own_events_history(), &[t.get_id()])
|
|
||||||
.map(|(e, _)| e) {
|
|
||||||
if tracking_stamp.is_some() && elem > now {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
tracking_stamp = Some(*elem)
|
|
||||||
}
|
|
||||||
writeln!(
|
|
||||||
lock,
|
|
||||||
"Tracking since {} (total tracked time {}m) - {} since {}",
|
|
||||||
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
|
|
||||||
self.time_tracked(*t.get_id()) / 60,
|
|
||||||
state.get_label(),
|
|
||||||
format_timestamp_relative(&state.time)
|
|
||||||
)?;
|
|
||||||
writeln!(lock, "{}", t.descriptions().join("\n"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tasks = self.visible_tasks();
|
|
||||||
if tasks.is_empty() {
|
|
||||||
let (label, times) = self.times_tracked();
|
|
||||||
let mut times_recent = times.rev().take(6).collect_vec();
|
|
||||||
times_recent.reverse();
|
|
||||||
// TODO Add recent prefix
|
|
||||||
writeln!(lock, "{}\n{}", label.italic(), times_recent.join("\n"))?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO proper column alignment
|
|
||||||
// TODO hide empty columns
|
|
||||||
writeln!(lock, "{}", self.properties.join("\t").bold())?;
|
|
||||||
let mut total_time = 0;
|
|
||||||
let count = tasks.len();
|
|
||||||
tasks.sort_by_cached_key(|task| {
|
|
||||||
self.sorting
|
|
||||||
.iter()
|
|
||||||
.map(|p| self.get_property(task, p.as_str()))
|
|
||||||
.collect_vec()
|
|
||||||
});
|
|
||||||
for task in tasks {
|
|
||||||
writeln!(
|
|
||||||
lock,
|
|
||||||
"{}",
|
|
||||||
self.properties.iter()
|
|
||||||
.map(|p| self.get_property(task, p.as_str()))
|
|
||||||
.join(" \t")
|
|
||||||
)?;
|
|
||||||
if self.depth < 2 || task.parent_id() == self.get_position_ref() {
|
|
||||||
total_time += self.total_time_tracked(task.event.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total_time > 0 {
|
|
||||||
writeln!(lock, "{} visible tasks{}", count, display_time(" tracked a total of HHhMMm", total_time))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_property(&self, task: &Task, str: &str) -> String {
|
fn get_property(&self, task: &Task, str: &str) -> String {
|
||||||
let mut children = self.tasks.children_of(task).peekable();
|
let mut children = self.tasks.children_of(task).peekable();
|
||||||
|
// Only show progress for non-activities with children
|
||||||
let progress =
|
let progress =
|
||||||
self.total_progress(task.get_id())
|
children.peek()
|
||||||
.filter(|_| children.peek().is_some());
|
.filter(|_| task.is_task())
|
||||||
|
.and_then(|_| self.total_progress(task.get_id()));
|
||||||
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
|
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
|
||||||
match str {
|
match str {
|
||||||
"subtasks" => {
|
"subtasks" => {
|
||||||
|
@ -575,7 +524,7 @@ impl TasksRelay {
|
||||||
}
|
}
|
||||||
"progress" => prog_string.clone(),
|
"progress" => prog_string.clone(),
|
||||||
|
|
||||||
"author" => format!("{:.6}", self.get_author(&task.event.pubkey)), // FIXME temporary until proper column alignment
|
"author" => format!("{:.6}", self.get_username(&task.event.pubkey)), // FIXME temporary until proper column alignment
|
||||||
"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
|
||||||
|
@ -585,7 +534,14 @@ impl TasksRelay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_author(&self, pubkey: &PublicKey) -> String {
|
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
|
||||||
|
self.users.iter().find(|(_, v)|
|
||||||
|
// TODO regex word boundary
|
||||||
|
v.name.as_ref().is_some_and(|n| n.starts_with(term)) ||
|
||||||
|
v.display_name.as_ref().is_some_and(|n| n.starts_with(term)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
|
||||||
self.users.get(pubkey)
|
self.users.get(pubkey)
|
||||||
.and_then(|m| m.name.clone())
|
.and_then(|m| m.name.clone())
|
||||||
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
|
.unwrap_or_else(|| format!("{:.6}", pubkey.to_string()))
|
||||||
|
@ -738,7 +694,7 @@ impl TasksRelay {
|
||||||
for task in self.tasks.values() {
|
for task in self.tasks.values() {
|
||||||
if task.get_filter_title().to_ascii_lowercase() == lowercase_arg &&
|
if task.get_filter_title().to_ascii_lowercase() == lowercase_arg &&
|
||||||
// exclude closed tasks and their subtasks
|
// exclude closed tasks and their subtasks
|
||||||
!self.traverse_up_from(Some(*task.get_id())).any(|t| t.pure_state() == State::Closed) {
|
!self.traverse_up_from(Some(*task.get_id())).any(|t| !t.pure_state().is_open()) {
|
||||||
return vec![task.event.id];
|
return vec![task.event.id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -746,9 +702,8 @@ impl TasksRelay {
|
||||||
if filtered.is_empty() {
|
if filtered.is_empty() {
|
||||||
filtered = filtered_fuzzy;
|
filtered = filtered_fuzzy;
|
||||||
}
|
}
|
||||||
let pos = self.get_position_ref();
|
|
||||||
let immediate = filtered.iter().filter(
|
let immediate = filtered.iter().filter(
|
||||||
|t| self.get_by_id(t).is_some_and(|t| t.parent_id() == pos)).collect_vec();
|
|t| self.get_by_id(t).is_some_and(|t| t.parent_id() == position)).collect_vec();
|
||||||
if immediate.len() == 1 {
|
if immediate.len() == 1 {
|
||||||
return immediate.into_iter().cloned().collect_vec();
|
return immediate.into_iter().cloned().collect_vec();
|
||||||
}
|
}
|
||||||
|
@ -1075,32 +1030,36 @@ impl TasksRelay {
|
||||||
Some(self.set_state_for(*id, comment, state))
|
Some(self.set_state_for(*id, comment, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn make_note(&mut self, note: &str) {
|
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 = build_prop(Kind::TextNote, note.trim(), *id);
|
||||||
self.submit(prop);
|
return self.submit(prop)
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (input, tags) = extract_tags(note.trim());
|
let (input, tags) = extract_tags(note.trim());
|
||||||
self.submit(
|
self.submit(
|
||||||
build_task(input, tags, Some(("stateless ", 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())
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Properties
|
// Properties
|
||||||
|
|
||||||
pub(crate) fn set_depth(&mut self, depth: usize) {
|
pub(crate) fn set_view_depth(&mut self, depth: usize) {
|
||||||
|
info!("Showing {depth} subtask levels");
|
||||||
|
self.view_depth = depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_search_depth(&mut self, depth: usize) {
|
||||||
if !self.view.is_empty() {
|
if !self.view.is_empty() {
|
||||||
self.view.clear();
|
self.view.clear();
|
||||||
info!("Cleared search and changed view depth to {depth}");
|
info!("Cleared search and changed search depth to {depth}");
|
||||||
} else {
|
} else {
|
||||||
info!("Changed view depth to {depth}");
|
info!("Changed search depth to {depth}");
|
||||||
}
|
}
|
||||||
self.depth = depth;
|
self.search_depth = depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn get_columns(&mut self) -> &mut Vec<String> {
|
pub(crate) fn get_columns(&mut self) -> &mut Vec<String> {
|
||||||
|
@ -1120,6 +1079,109 @@ impl TasksRelay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for TasksRelay {
|
||||||
|
fn fmt(&self, lock: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if let Some(t) = self.get_current_task() {
|
||||||
|
let state = t.state_or_default();
|
||||||
|
let now = &now();
|
||||||
|
let mut tracking_stamp: Option<Timestamp> = None;
|
||||||
|
for elem in
|
||||||
|
timestamps(self.get_own_events_history(), &[t.get_id()])
|
||||||
|
.map(|(e, _)| e) {
|
||||||
|
if tracking_stamp.is_some() && elem > now {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tracking_stamp = Some(*elem)
|
||||||
|
}
|
||||||
|
writeln!(
|
||||||
|
lock,
|
||||||
|
"Active from {} (total tracked time {}m) - {} since {}",
|
||||||
|
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
|
||||||
|
self.time_tracked(*t.get_id()) / 60,
|
||||||
|
state.get_label(),
|
||||||
|
format_timestamp_relative(&state.time)
|
||||||
|
)?;
|
||||||
|
writeln!(lock, "{}", t.descriptions().join("\n"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
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() {
|
||||||
|
if !self.tags.is_empty() {
|
||||||
|
let mut children = self.tasks.children_for(position).peekable();
|
||||||
|
if children.peek().is_some() {
|
||||||
|
current = self.resolve_tasks_rec(children, true, 9);
|
||||||
|
if current.is_empty() {
|
||||||
|
writeln!(lock, "No tasks here matching{}", self.get_prompt_suffix())?;
|
||||||
|
} else {
|
||||||
|
writeln!(lock, "Found matching tasks beyond specified search depth:")?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = self.resolve_tasks_rec(roots.iter().cloned(), true, self.view_depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if current.is_empty() {
|
||||||
|
let (label, times) = self.times_tracked();
|
||||||
|
let mut times_recent = times.rev().take(6).collect_vec();
|
||||||
|
times_recent.reverse();
|
||||||
|
// TODO Add recent prefix
|
||||||
|
writeln!(lock, "{}\n{}", label.italic(), times_recent.join("\n"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO proper column alignment
|
||||||
|
// TODO hide empty columns
|
||||||
|
writeln!(lock, "{}", self.properties.join(" \t").bold())?;
|
||||||
|
|
||||||
|
let count = current.len();
|
||||||
|
let mut total_time = 0;
|
||||||
|
for task in current {
|
||||||
|
writeln!(
|
||||||
|
lock,
|
||||||
|
"{}",
|
||||||
|
self.properties.iter()
|
||||||
|
.map(|p| self.get_property(task, p.as_str()))
|
||||||
|
.join(" \t")
|
||||||
|
)?;
|
||||||
|
total_time += self.total_time_tracked(task.event.id) // TODO include parent if it matches
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(lock, "{} visible tasks{}", count, display_time(" tracked a total of HHhMMm", total_time))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub trait PropertyCollection<T> {
|
pub trait PropertyCollection<T> {
|
||||||
fn remove_at(&mut self, index: usize);
|
fn remove_at(&mut self, index: usize);
|
||||||
fn add_or_remove(&mut self, value: T);
|
fn add_or_remove(&mut self, value: T);
|
||||||
|
@ -1488,6 +1550,7 @@ mod tasks_test {
|
||||||
tasks.move_to(Some(parent));
|
tasks.move_to(Some(parent));
|
||||||
let pin = tasks.make_task("pin");
|
let pin = tasks.make_task("pin");
|
||||||
|
|
||||||
|
tasks.search_depth = 1;
|
||||||
assert_eq!(tasks.filtered_tasks(None, true).len(), 2);
|
assert_eq!(tasks.filtered_tasks(None, true).len(), 2);
|
||||||
assert_eq!(tasks.filtered_tasks(None, false).len(), 2);
|
assert_eq!(tasks.filtered_tasks(None, false).len(), 2);
|
||||||
assert_eq!(tasks.filtered_tasks(Some(&zero), false).len(), 0);
|
assert_eq!(tasks.filtered_tasks(Some(&zero), false).len(), 0);
|
||||||
|
@ -1503,17 +1566,18 @@ mod tasks_test {
|
||||||
assert_eq!(tasks.filtered_tasks(Some(&zero), false), vec![tasks.get_by_id(&pin).unwrap()]);
|
assert_eq!(tasks.filtered_tasks(Some(&zero), false), vec![tasks.get_by_id(&pin).unwrap()]);
|
||||||
|
|
||||||
tasks.move_to(None);
|
tasks.move_to(None);
|
||||||
assert_eq!(tasks.depth, 1);
|
assert_eq!(tasks.view_depth, 0);
|
||||||
assert_tasks!(tasks, [pin, test, parent]);
|
assert_tasks!(tasks, [pin, test, parent]);
|
||||||
tasks.set_depth(2);
|
tasks.set_view_depth(1);
|
||||||
assert_tasks!(tasks, [pin, test]);
|
assert_tasks!(tasks, [pin, test]);
|
||||||
tasks.add_tag("tag".to_string());
|
tasks.add_tag("tag".to_string());
|
||||||
assert_tasks!(tasks, [test]);
|
assert_tasks!(tasks, [test]);
|
||||||
assert_eq!(tasks.filtered_tasks(None, true), vec![tasks.get_by_id(&test).unwrap()]);
|
assert_eq!(tasks.filtered_tasks(None, true), vec![tasks.get_by_id(&test).unwrap()]);
|
||||||
|
|
||||||
tasks.submit(EventBuilder::new(Kind::Bookmarks, "", []));
|
tasks.submit(EventBuilder::new(Kind::Bookmarks, "", []));
|
||||||
tasks.clear_filters();
|
tasks.clear_filters();
|
||||||
assert_tasks!(tasks, [pin, test]);
|
assert_tasks!(tasks, [pin, test]);
|
||||||
tasks.set_depth(1);
|
tasks.set_view_depth(0);
|
||||||
assert_tasks!(tasks, [test, parent]);
|
assert_tasks!(tasks, [test, parent]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1522,7 +1586,8 @@ mod tasks_test {
|
||||||
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(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
|
let side = tasks.submit(
|
||||||
|
build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)], None));
|
||||||
assert_eq!(tasks.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");
|
||||||
|
@ -1620,18 +1685,21 @@ mod tasks_test {
|
||||||
fn test_depth() {
|
fn test_depth() {
|
||||||
let mut tasks = stub_tasks();
|
let mut tasks = stub_tasks();
|
||||||
|
|
||||||
let t1 = tasks.make_task("t1");
|
let t1 = tasks.make_note("t1");
|
||||||
let task1 = tasks.get_by_id(&t1).unwrap();
|
let activity_t1 = tasks.get_by_id(&t1).unwrap();
|
||||||
assert_eq!(tasks.depth, 1);
|
assert!(!activity_t1.is_task());
|
||||||
assert_eq!(task1.pure_state(), State::Open);
|
assert_eq!(tasks.view_depth, 0);
|
||||||
|
assert_eq!(activity_t1.pure_state(), State::Open);
|
||||||
debug!("{:?}", tasks);
|
debug!("{:?}", tasks);
|
||||||
assert_eq!(tasks.visible_tasks().len(), 1);
|
assert_eq!(tasks.visible_tasks().len(), 1);
|
||||||
tasks.depth = 0;
|
tasks.search_depth = 0;
|
||||||
assert_eq!(tasks.visible_tasks().len(), 0);
|
assert_eq!(tasks.visible_tasks().len(), 0);
|
||||||
|
tasks.recurse_activities = false;
|
||||||
|
assert_eq!(tasks.filtered_tasks(None, false).len(), 1);
|
||||||
|
|
||||||
tasks.move_to(Some(t1));
|
tasks.move_to(Some(t1));
|
||||||
assert_position!(tasks, t1);
|
assert_position!(tasks, t1);
|
||||||
tasks.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);
|
||||||
|
@ -1647,7 +1715,7 @@ mod tasks_test {
|
||||||
assert_tasks!(tasks, [t111]);
|
assert_tasks!(tasks, [t111]);
|
||||||
assert_eq!(tasks.get_task_path(Some(t111)), "t1>t11>t111");
|
assert_eq!(tasks.get_task_path(Some(t111)), "t1>t11>t111");
|
||||||
assert_eq!(tasks.relative_path(t111), "t111");
|
assert_eq!(tasks.relative_path(t111), "t111");
|
||||||
tasks.depth = 2;
|
tasks.view_depth = 2;
|
||||||
assert_tasks!(tasks, [t111]);
|
assert_tasks!(tasks, [t111]);
|
||||||
|
|
||||||
assert_eq!(ChildIterator::from(&tasks, &EventId::all_zeros()).get_all().len(), 1);
|
assert_eq!(ChildIterator::from(&tasks, &EventId::all_zeros()).get_all().len(), 1);
|
||||||
|
@ -1662,21 +1730,33 @@ mod tasks_test {
|
||||||
assert_position!(tasks, t1);
|
assert_position!(tasks, t1);
|
||||||
assert_eq!(tasks.get_own_events_history().count(), 3);
|
assert_eq!(tasks.get_own_events_history().count(), 3);
|
||||||
assert_eq!(tasks.relative_path(t111), "t11>t111");
|
assert_eq!(tasks.relative_path(t111), "t11>t111");
|
||||||
assert_eq!(tasks.depth, 2);
|
assert_eq!(tasks.view_depth, 2);
|
||||||
assert_tasks!(tasks, [t111, t12]);
|
assert_tasks!(tasks, [t111, t12]);
|
||||||
tasks.set_view(vec![t11]);
|
tasks.set_view(vec![t11]);
|
||||||
assert_tasks!(tasks, [t11]); // No more depth applied to view
|
assert_tasks!(tasks, [t11]); // No more depth applied to view
|
||||||
tasks.set_depth(1);
|
tasks.set_search_depth(1); // resets view
|
||||||
|
assert_tasks!(tasks, [t111, t12]);
|
||||||
|
tasks.set_view_depth(0);
|
||||||
assert_tasks!(tasks, [t11, t12]);
|
assert_tasks!(tasks, [t11, t12]);
|
||||||
|
|
||||||
tasks.move_to(None);
|
tasks.move_to(None);
|
||||||
assert_tasks!(tasks, [t1]);
|
tasks.recurse_activities = true;
|
||||||
tasks.depth = 2;
|
|
||||||
assert_tasks!(tasks, [t11, t12]);
|
assert_tasks!(tasks, [t11, t12]);
|
||||||
tasks.depth = 3;
|
tasks.recurse_activities = false;
|
||||||
|
assert_tasks!(tasks, [t1]);
|
||||||
|
tasks.view_depth = 1;
|
||||||
|
assert_tasks!(tasks, [t11, t12]);
|
||||||
|
tasks.view_depth = 2;
|
||||||
assert_tasks!(tasks, [t111, t12]);
|
assert_tasks!(tasks, [t111, t12]);
|
||||||
tasks.depth = 9;
|
tasks.view_depth = 9;
|
||||||
assert_tasks!(tasks, [t111, t12]);
|
assert_tasks!(tasks, [t111, t12]);
|
||||||
|
|
||||||
|
tasks.add_tag("tag".to_string());
|
||||||
|
tasks.view_depth = 0;
|
||||||
|
assert_tasks!(tasks, [t11]);
|
||||||
|
tasks.search_depth = 0;
|
||||||
|
assert_eq!(tasks.view, []);
|
||||||
|
assert_tasks!(tasks, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
Loading…
Reference in New Issue