Compare commits

...

7 commits

Author SHA1 Message Date
xeruf
bd32e61212 refactor: revamp visible task algorithm 2024-11-22 00:04:19 +01:00
xeruf
5cd82e8581 enhance: display more accurate time tracking prefixes 2024-11-21 23:59:30 +01:00
xeruf
eea8511a6e feat: enable finding user by partial key and name 2024-11-21 21:18:26 +01:00
xeruf
5032b4db93 refactor(tasks): omit empty descriptions
preparation for state update notes
2024-11-21 10:56:52 +01:00
xeruf
fc97b513c4 release: 0.6.2 - case-insensitive hashtags and pubkey filtering 2024-11-21 09:48:04 +01:00
xeruf
9c92a19cde fix: make hashtag behaviour more consistent 2024-11-21 09:47:14 +01:00
xeruf
0a7685d907 feat: make hashtags case-insensitive 2024-11-21 09:17:56 +01:00
7 changed files with 317 additions and 173 deletions

2
Cargo.lock generated
View file

@ -1488,7 +1488,7 @@ dependencies = [
[[package]]
name = "mostr"
version = "0.6.1"
version = "0.6.2"
dependencies = [
"chrono",
"chrono-english",

View file

@ -5,7 +5,7 @@ repository = "https://forge.ftt.gmbh/janek/mostr"
readme = "README.md"
license = "GPL 3.0"
authors = ["melonion"]
version = "0.6.1"
version = "0.6.2"
rust-version = "1.82"
edition = "2021"
default-run = "mostr"

96
src/hashtag.rs Normal file
View file

@ -0,0 +1,96 @@
use nostr_sdk::{Alphabet, Tag};
use std::cmp::Ordering;
use std::fmt::{Display, Formatter};
use std::hash::{Hash, Hasher};
pub fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
}
/// This exists so that Hashtags can easily be matched without caring about case
/// but displayed in their original case
#[derive(Clone, Debug)]
pub struct Hashtag {
value: String,
lowercased: String,
}
impl Hashtag {
pub fn matches(&self, token: &str) -> bool {
self.lowercased.contains(&token.to_ascii_lowercase())
}
}
impl Display for Hashtag {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value)
}
}
impl Hash for Hashtag {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write(self.lowercased.as_bytes());
}
}
impl Eq for Hashtag {}
impl PartialEq<Self> for Hashtag {
fn eq(&self, other: &Self) -> bool {
self.lowercased == other.lowercased
}
}
impl TryFrom<&Tag> for Hashtag {
type Error = String;
fn try_from(value: &Tag) -> Result<Self, Self::Error> {
value.content().take_if(|_| is_hashtag(value))
.map(|s| Hashtag::from(s))
.ok_or_else(|| "Tag is not a Hashtag".to_string())
}
}
impl From<&str> for Hashtag {
fn from(value: &str) -> Self {
let val = value.trim().to_string();
Hashtag {
lowercased: val.to_ascii_lowercase(),
value: val,
}
}
}
impl From<&Hashtag> for Tag {
fn from(value: &Hashtag) -> Self {
Tag::hashtag(&value.lowercased)
}
}
impl Ord for Hashtag {
fn cmp(&self, other: &Self) -> Ordering {
self.lowercased.cmp(&other.lowercased)
// Wanted to do this so lowercase tags are preferred,
// but is technically undefined behaviour
// because it deviates from Eq implementation
//match {
// Ordering::Equal => self.0.cmp(&other.0),
// other => other,
//}
}
}
impl PartialOrd for Hashtag {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.lowercased.cmp(&other.lowercased))
}
}
#[test]
fn test_hashtag() {
assert_eq!("yeah", "YeaH".to_ascii_lowercase());
assert_eq!("yeah".to_ascii_lowercase().cmp(&"YeaH".to_ascii_lowercase()), Ordering::Equal);
use itertools::Itertools;
let strings = vec!["yeah", "YeaH"];
let mut tags = strings.iter().cloned().map(Hashtag::from).sorted_unstable().collect_vec();
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
tags.sort_unstable();
assert_eq!(strings, tags.iter().map(ToString::to_string).collect_vec());
}

View file

@ -92,7 +92,7 @@ pub(crate) fn extract_hashtags(input: &str) -> impl Iterator<Item=Tag> + '_ {
input.split_ascii_whitespace()
.filter(|s| s.starts_with('#'))
.map(|s| s.trim_start_matches('#'))
.map(to_hashtag)
.map(to_hashtag_tag)
}
/// Extracts everything after a " # " as a list of tags
@ -121,7 +121,7 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
let mut split = result.split(|e| { e == &"#" });
let main = split.next().unwrap().join(" ");
let mut tags = extract_hashtags(&main)
.chain(split.flatten().map(|s| to_hashtag(&s)))
.chain(split.flatten().map(|s| to_hashtag_tag(&s)))
.chain(prio.map(|p| to_prio_tag(p)))
.collect_vec();
tags.sort();
@ -129,7 +129,7 @@ pub(crate) fn extract_tags(input: &str) -> (String, Vec<Tag>) {
(main, tags)
}
pub fn to_hashtag(tag: &str) -> Tag {
pub fn to_hashtag_tag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into()
}
@ -155,11 +155,6 @@ pub fn format_tag_basic(tag: &Tag) -> String {
}
}
pub fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T)
}
pub fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
}
@ -169,7 +164,7 @@ fn test_extract_tags() {
assert_eq!(extract_tags("Hello from #mars with #greetings #yeah *4 # # yeah done-it"),
("Hello from #mars with #greetings #yeah".to_string(),
std::iter::once(Tag::custom(TagKind::Custom(Cow::from(PRIO)), [40.to_string()]))
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag)).collect()));
.chain(["done-it", "greetings", "mars", "yeah"].into_iter().map(to_hashtag_tag)).collect()));
assert_eq!(extract_tags("So tagless #"),
("So tagless".to_string(), vec![]));
}

View file

@ -21,7 +21,6 @@ use itertools::Itertools;
use keyring::Entry;
use log::{debug, error, info, trace, warn, LevelFilter};
use nostr_sdk::prelude::*;
use nostr_sdk::TagStandard::Hashtag;
use regex::Regex;
use rustyline::config::Configurer;
use rustyline::error::ReadlineError;
@ -29,12 +28,14 @@ use rustyline::DefaultEditor;
use tokio::sync::mpsc;
use tokio::time::error::Elapsed;
use tokio::time::timeout;
use crate::hashtag::Hashtag;
mod helpers;
mod task;
mod tasks;
mod kinds;
mod event_sender;
mod hashtag;
const INACTVITY_DELAY: u64 = 200;
const LOCAL_RELAY_NAME: &str = "TEMP";
@ -385,7 +386,7 @@ async fn main() -> Result<()> {
match arg {
None => {
if let Some(task) = tasks.get_current_task() {
println!("Change History:");
println!("Change History for {}:", task.get_id());
for e in once(&task.event).chain(task.props.iter().rev()) {
println!("{} {} [{}]",
format_timestamp_full(&e.created_at),
@ -464,12 +465,9 @@ async fn main() -> Result<()> {
Some(arg) => {
if arg == "@" {
tasks.reset_key_filter()
} else if let Ok(key) = PublicKey::from_str(arg) {
info!("Showing {}'s tasks", tasks.get_username(&key));
} else if let Some((key, name)) = tasks.find_user_with_displayname(arg) {
info!("Showing {}'s tasks", name);
tasks.set_key_filter(key)
} else if let Some((key, meta)) = tasks.find_user(arg) {
info!("Showing {}'s tasks", meta.display_name.as_ref().unwrap_or(meta.name.as_ref().unwrap_or(&key.to_string())));
tasks.set_key_filter(key.clone())
} else {
if parse_hour(arg, 1)
.or_else(|| parse_date(arg).map(|utc| utc.with_timezone(&Local)))
@ -576,14 +574,14 @@ async fn main() -> Result<()> {
}
Some('#') => {
if !tasks.update_tags(arg_default.split_whitespace().map(|s| Hashtag(s.to_string()).into())) {
if !tasks.update_tags(arg_default.split_whitespace().map(Hashtag::from)) {
continue;
}
}
Some('+') =>
match arg {
Some(arg) => tasks.add_tag(arg.to_string()),
Some(arg) => tasks.add_tag(arg),
None => {
tasks.print_hashtags();
if tasks.has_tag_filter() {
@ -621,7 +619,7 @@ async fn main() -> Result<()> {
label)
},
vec.iter().rev().join("\n"));
} else if let Ok(key) = PublicKey::parse(arg) { // TODO also match name
} else if let Some((key, _)) = tasks.find_user(arg) {
let (label, mut times) = tasks.times_tracked_for(&key);
println!("{}\n{}", label.italic(),
times.join("\n"));
@ -708,8 +706,8 @@ async fn main() -> Result<()> {
let filtered =
tasks.get_filtered(pos, |t| {
transform(&t.event.content).contains(&remaining) ||
t.get_hashtags().any(
|tag| tag.content().is_some_and(|s| transform(s).contains(&remaining)))
t.list_hashtags().any(
|tag| tag.matches(&remaining))
});
if filtered.len() == 1 {
tasks.move_to(filtered.into_iter().next());

View file

@ -11,9 +11,9 @@ use itertools::Either::{Left, Right};
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
use nostr_sdk::{Alphabet, Event, EventId, Kind, Tag, Timestamp};
use crate::hashtag::{is_hashtag, Hashtag};
use crate::helpers::{format_timestamp_local, some_non_empty};
use crate::kinds::{is_hashtag, match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::kinds::{match_event_tag, Prio, PRIO, PROCEDURE_KIND, PROCEDURE_KIND_ID, TASK_KIND};
use crate::tasks::now;
pub static MARKER_PARENT: &str = "parent";
@ -98,7 +98,8 @@ impl Task {
/// Description items, ordered newest to oldest
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content)
self.description_events()
.filter_map(|e| Some(&e.content).take_if(|s| !s.trim().is_empty()))
}
pub(crate) fn is_task_kind(&self) -> bool {
@ -174,8 +175,8 @@ impl Task {
}
}
pub(crate) fn get_hashtags(&self) -> impl Iterator<Item=&Tag> {
self.tags().filter(|t| is_hashtag(t))
pub(crate) fn list_hashtags(&self) -> impl Iterator<Item=Hashtag> + use<'_> {
self.tags().filter_map(|t| Hashtag::try_from(t).ok())
}
fn tags(&self) -> impl Iterator<Item=&Tag> {
@ -222,10 +223,7 @@ impl Task {
.map(|e| format!("{} kind {} \"{}\"", e.created_at, e.kind, e.content))
.collect_vec()
)),
"descriptions" => Some(format!(
"{:?}",
self.descriptions().collect_vec()
)),
"descriptions" => Some(format!("{:?}", self.descriptions().collect_vec())),
_ => {
warn!("Unknown task property {}", property);
None
@ -357,7 +355,7 @@ mod tasks_test {
EventBuilder::new(TASK_KIND, "task").tags([Tag::hashtag("tag1")])
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Open);
assert_eq!(task.get_hashtags().count(), 1);
assert_eq!(task.list_hashtags().count(), 1);
task.props.insert(
EventBuilder::new(State::Done.into(), "")
.sign_with_keys(&keys).unwrap());
@ -367,7 +365,7 @@ mod tasks_test {
.custom_created_at(Timestamp::from(Timestamp::now() - 2))
.sign_with_keys(&keys).unwrap());
assert_eq!(task.pure_state(), State::Done);
assert_eq!(task.get_hashtags().count(), 2);
assert_eq!(task.list_hashtags().count(), 2);
task.props.insert(
EventBuilder::new(State::Closed.into(), "")
.custom_created_at(Timestamp::from(Timestamp::now() + 1))

View file

@ -1,11 +1,11 @@
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
use std::fmt::{Display, Formatter};
use std::io::Write;
use std::iter::{empty, once, FusedIterator};
use std::ops::{Div, Rem};
use std::str::FromStr;
use std::time::Duration;
use crate::hashtag::Hashtag;
use crate::event_sender::{EventSender, MostrMessage};
use crate::helpers::{format_timestamp_local, format_timestamp_relative, format_timestamp_relative_to, parse_tracking_stamp, some_non_empty, to_string_or_default, CHARACTER_THRESHOLD};
use crate::kinds::*;
@ -16,7 +16,6 @@ use log::{debug, error, info, trace, warn};
use nostr_sdk::{Alphabet, Event, EventBuilder, EventId, JsonUtil, Keys, Kind, Metadata, PublicKey, SingleLetterTag, Tag, TagKind, TagStandard, Timestamp, Url};
use regex::bytes::Regex;
use tokio::sync::mpsc::Sender;
use TagStandard::Hashtag;
const DEFAULT_PRIO: Prio = 25;
pub const HIGH_PRIO: Prio = 85;
@ -72,9 +71,9 @@ pub(crate) struct TasksRelay {
pub(crate) recurse_activities: bool,
/// Currently active tags
tags: BTreeSet<Tag>,
tags: BTreeSet<Hashtag>,
/// Tags filtered out from view
tags_excluded: BTreeSet<Tag>,
tags_excluded: BTreeSet<Hashtag>,
/// Current active state
state: StateFilter,
/// Current priority for filtering and new tasks
@ -244,12 +243,8 @@ impl TasksRelay {
.filter(|t| t.pure_state() != State::Closed)
}
pub(crate) fn all_hashtags(&self) -> impl Iterator<Item=&str> {
self.nonclosed_tasks()
.flat_map(|t| t.get_hashtags())
.filter_map(|tag| tag.content().map(|s| s.trim()))
.sorted_unstable()
.dedup()
pub(crate) fn all_hashtags(&self) -> BTreeSet<Hashtag> {
self.nonclosed_tasks().flat_map(|t| t.list_hashtags()).collect()
}
/// Dynamic time tracking overview for current task or current user.
@ -284,12 +279,12 @@ impl TasksRelay {
}
// TODO show history for active tags
(
"Your Time-Tracking History:".to_string(),
format!("Time-Tracking History for {}:", self.get_displayname(&key)),
Box::from(full.into_iter()),
)
} else {
(
"You have nothing time-tracked yet".to_string(),
"Nothing time-tracked yet".to_string(),
Box::from(empty()),
)
}
@ -397,10 +392,10 @@ impl TasksRelay {
},
}
for tag in self.tags.iter() {
prompt.push_str(&format!(" #{}", tag.content().unwrap()));
prompt.push_str(&format!(" #{}", tag));
}
for tag in self.tags_excluded.iter() {
prompt.push_str(&format!(" -#{}", tag.content().unwrap()));
prompt.push_str(&format!(" -#{}", tag));
}
prompt.push_str(&self.state.indicator());
self.priority.map(|p|
@ -498,10 +493,10 @@ impl TasksRelay {
self.priority.is_none_or(|prio| {
task.priority().unwrap_or(DEFAULT_PRIO) >= prio
}) &&
!task.get_hashtags().any(|tag| self.tags_excluded.contains(tag)) &&
!task.list_hashtags().any(|tag| self.tags_excluded.contains(&tag)) &&
(self.tags.is_empty() || {
let mut iter = task.get_hashtags().sorted_unstable();
self.tags.iter().all(|tag| iter.any(|t| t == tag))
let mut iter = task.list_hashtags().sorted_unstable();
self.tags.iter().all(|tag| iter.any(|t| &t == tag))
})
}
@ -547,16 +542,55 @@ impl TasksRelay {
current
}
// TODO this is a relict for tests
#[deprecated]
fn visible_tasks(&self) -> Vec<&Task> {
if self.search_depth == 0 {
return vec![];
let tasks = self.viewed_tasks();
if self.view.is_empty() && !tasks.is_empty() {
let bookmarks = self.bookmarked_tasks_deduped(&tasks);
return bookmarks.chain(tasks.into_iter()).collect_vec();
}
if !self.view.is_empty() {
return self.view.iter().flat_map(|id| self.get_by_id(id)).collect();
tasks
}
fn viewed_tasks(&self) -> Vec<&Task> {
let view = self.view.iter()
.flat_map(|id| self.get_by_id(id))
.collect_vec();
if self.search_depth > 0 && view.is_empty() {
self.resolve_tasks_rec(
self.tasks.children_for(self.get_position()),
true,
self.search_depth + self.view_depth,
)
} else {
self.resolve_tasks_rec(view.into_iter(), true, self.view_depth)
}
self.filtered_tasks(self.get_position(), true)
}
fn bookmarked_tasks_deduped(&self, visible: &[&Task]) -> impl Iterator<Item=&Task> {
let tree = visible.iter()
.flat_map(|task| self.traverse_up_from(Some(task.event.id)))
.unique();
let pos = self.get_position();
let ids: HashSet<&EventId> = tree.map(|t| t.get_id()).chain(pos.as_ref()).collect();
// TODO add recent tasks (most time tracked + recently created)
self.bookmarks.iter()
.chain(
// Latest
self.tasks.values()
.sorted_unstable().rev()
.take(3).map(|t| t.get_id()))
.chain(
// Highest Prio
self.tasks.values()
.filter_map(|t| t.priority().filter(|p| *p > 35).map(|p| (p, t)))
.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()
}
fn get_property(&self, task: &Task, str: &str) -> String {
@ -623,11 +657,29 @@ impl TasksRelay {
}
}
pub(crate) fn find_user_with_displayname(&self, term: &str) -> Option<(PublicKey, String)> {
match PublicKey::from_str(term) {
Ok(key) => Some((key, self.get_displayname(&key))),
Err(_) => self.find_user(term).map(|(k, _)| (*k, self.get_displayname(k))),
}
}
// Find username or key starting with the given term.
pub(crate) fn find_user(&self, term: &str) -> Option<(&PublicKey, &Metadata)> {
self.users.iter().find(|(_, v)|
if let Ok(key) = PublicKey::from_str(term) {
return self.users.get_key_value(&key);
}
self.users.iter().find(|(k, 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)))
v.display_name.as_ref().is_some_and(|n| n.starts_with(term)) ||
(term.len() > 4 && k.to_string().starts_with(term)))
}
pub(crate) fn get_displayname(&self, pubkey: &PublicKey) -> String {
self.users.get(pubkey)
.and_then(|m| m.display_name.clone().or(m.name.clone()))
.unwrap_or_else(|| pubkey.to_string())
}
pub(crate) fn get_username(&self, pubkey: &PublicKey) -> String {
@ -728,12 +780,12 @@ impl TasksRelay {
pub(crate) fn print_hashtags(&self) {
println!(
"Hashtags of all known tasks:\n{}",
self.all_hashtags().join(" ").italic()
self.all_hashtags().into_iter().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 {
pub(crate) fn update_tags(&mut self, tags: impl IntoIterator<Item=Hashtag>) -> bool {
let mut peekable = tags.into_iter().peekable();
if self.tags.is_empty() && peekable.peek().is_none() {
if !self.tags_excluded.is_empty() {
@ -747,15 +799,15 @@ impl TasksRelay {
}
}
fn set_tags(&mut self, tags: impl IntoIterator<Item=Tag>) {
fn set_tags(&mut self, tags: impl IntoIterator<Item=Hashtag>) {
self.tags.clear();
self.tags.extend(tags);
}
pub(crate) fn add_tag(&mut self, tag: String) {
pub(crate) fn add_tag(&mut self, tag: &str) {
self.view.clear();
info!("Added tag filter for #{tag}");
let tag: Tag = Hashtag(tag).into();
let tag = Hashtag::from(tag);
self.tags_excluded.remove(&tag);
self.tags.insert(tag);
}
@ -763,13 +815,11 @@ impl TasksRelay {
pub(crate) fn remove_tag(&mut self, tag: &str) {
self.view.clear();
let len = self.tags.len();
self.tags.retain(|t| {
!t.content().is_some_and(|value| value.to_string().starts_with(tag))
});
self.tags.retain(|t| !t.matches(tag));
if self.tags.len() < len {
info!("Removed tag filters starting with {tag}");
info!("Removed tag filters containing {tag}");
} else {
self.tags_excluded.insert(Hashtag(tag.to_string()).into());
self.tags_excluded.insert(Hashtag::from(tag).into());
info!("Excluding #{tag} from view");
}
}
@ -964,6 +1014,10 @@ impl TasksRelay {
})
}
fn context_hashtags(&self) -> impl Iterator<Item=Tag> + use<'_> {
self.tags.iter().map(Tag::from)
}
/// Creates a task following the current state
///
/// Sanitizes input
@ -1007,7 +1061,7 @@ impl TasksRelay {
let id = self.submit(
EventBuilder::new(TASK_KIND, &input)
.tags(input_tags)
.tags(self.tags.iter().cloned())
.tags(self.context_hashtags())
.tags(tags)
.tags(prio)
);
@ -1090,6 +1144,10 @@ impl TasksRelay {
}
pub(crate) fn add(&mut self, event: Event) {
let author = event.pubkey;
if !self.users.contains_key(&author) {
self.users.insert(author, Metadata::new());
}
match event.kind {
Kind::GitIssue => self.add_task(event),
Kind::Metadata => match Metadata::from_json(event.content.as_str()) {
@ -1255,12 +1313,12 @@ impl TasksRelay {
MARKER_PROPERTY
} else {
// Activity if parent is not a task
prop = prop.add_tags(self.tags.iter().cloned());
prop = prop.tags(self.context_hashtags());
MARKER_PARENT
};
info!("Created {} {format}", if marker == MARKER_PROPERTY { "note" } else { "activity" } );
self.submit(
prop.add_tags(
prop.tags(
self.get_position().map(|pos| self.make_event_tag_from_id(pos, marker))))
}
@ -1324,35 +1382,10 @@ impl Display for TasksRelay {
writeln!(lock)?;
}
let position = self.get_position();
let mut current: Vec<&Task>;
let view = self.view.iter()
.flat_map(|id| self.get_by_id(id))
.collect_vec();
if self.search_depth > 0 && view.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(view.into_iter(), true, self.view_depth);
}
let visible = self.viewed_tasks();
if current.is_empty() {
if visible.is_empty() {
writeln!(lock, "No tasks here matching{}", self.get_prompt_suffix())?;
let (label, times) = self.times_tracked();
let mut times_recent = times.rev().take(6).collect_vec();
times_recent.reverse();
@ -1361,32 +1394,8 @@ impl Display for TasksRelay {
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.as_ref()).collect();
if self.view.is_empty() {
let mut bookmarks =
// TODO add recent tasks (most time tracked + recently created)
self.bookmarks.iter()
.chain(
// Latest
self.tasks.values()
.sorted_unstable().rev()
.take(3).map(|t| t.get_id()))
.chain(
// Highest Prio
self.tasks.values()
.filter_map(|t| t.priority().filter(|p| *p > 35).map(|p| (p, t)))
.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();
let mut bookmarks = self.bookmarked_tasks_deduped(&visible).peekable();
if bookmarks.peek().is_some() {
writeln!(lock, "{}", Colorize::bold("Quick Access"))?;
for task in bookmarks {
@ -1405,9 +1414,9 @@ impl Display for TasksRelay {
// TODO hide empty columns
writeln!(lock, "{}", self.properties.join(" \t").bold())?;
let count = current.len();
let count = visible.len();
let mut total_time = 0;
for task in current {
for task in visible {
writeln!(
lock,
"{}",
@ -1762,7 +1771,7 @@ mod tasks_test {
};
}
macro_rules! assert_tasks {
macro_rules! assert_tasks_visible {
($left:expr, $right:expr $(,)?) => {
assert_eq!(
$left
@ -1775,6 +1784,22 @@ mod tasks_test {
};
}
macro_rules! assert_tasks_view {
($left:expr, $right:expr $(,)?) => {
let tasks = $left.viewed_tasks();
assert_eq!(
tasks
.iter()
.map(|t| t.event.id)
.collect::<HashSet<EventId>>(),
HashSet::from($right),
"Tasks Visible: {:?}\nExpected: {:?}",
tasks,
$right.map(|id| $left.get_relative_path(id))
)
};
}
#[test]
fn test_recursive_closing() {
let mut tasks = stub_tasks();
@ -1783,21 +1808,48 @@ mod tasks_test {
let parent = tasks.make_task("parent #tag1");
tasks.move_to(Some(parent));
let sub = tasks.make_task("sub #oi # tag2");
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2"]);
assert_eq!(tasks.all_hashtags(), ["oi", "tag1", "tag2"].into_iter().map(Hashtag::from).collect());
tasks.make_note("note with #tag3 # yeah");
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "yeah"]);
let all_tags = ["oi", "tag1", "tag2", "tag3", "yeah"].into_iter().map(Hashtag::from).collect();
assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(Timestamp::now());
tasks.update_state("Finished #yeah # oi", State::Done);
assert_eq!(tasks.get_by_id(&parent).unwrap().get_hashtags().cloned().collect_vec(), ["tag1", "oi", "yeah", "tag3", "yeah"].map(to_hashtag));
assert_eq!(tasks.all_hashtags().collect_vec(), vec!["oi", "tag1", "tag2", "tag3", "yeah"]);
tasks.update_state("Finished #YeaH # oi", State::Done);
assert_eq!(tasks.get_by_id(&parent).unwrap().list_hashtags().collect_vec(), ["tag1", "YeaH", "oi", "tag3", "yeah"].map(Hashtag::from));
assert_eq!(tasks.all_hashtags(), all_tags);
tasks.custom_time = Some(now());
tasks.update_state("Closing Down", State::Closed);
assert_eq!(tasks.get_by_id(&sub).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.get_by_id(&parent).unwrap().pure_state(), State::Closed);
assert_eq!(tasks.nonclosed_tasks().next(), None);
assert_eq!(tasks.all_hashtags().next(), None);
assert_eq!(tasks.all_hashtags(), Default::default());
}
#[test]
fn test_context() {
let mut tasks = stub_tasks();
tasks.update_tags(["dp", "yeah"].into_iter().map(Hashtag::from));
assert_eq!(tasks.get_prompt_suffix(), " #dp #yeah");
tasks.remove_tag("Y");
assert_eq!(tasks.tags, ["dp"].into_iter().map(Hashtag::from).collect());
tasks.set_priority(Some(HIGH_PRIO));
assert_eq!(tasks.get_prompt_suffix(), " #dp *85");
let id = tasks.make_task("test # tag");
let task1 = tasks.get_by_id(&id).unwrap();
assert_eq!(task1.priority(), Some(HIGH_PRIO));
assert_eq!(
task1.list_hashtags().collect_vec(),
vec!["DP", "tag"].into_iter().map(Hashtag::from).collect_vec()
);
tasks.make_task_and_enter("another *4", State::Pending);
assert_eq!(tasks.get_current_task().unwrap().priority(), Some(40));
tasks.make_note("*3");
let task2 = tasks.get_current_task().unwrap();
assert_eq!(task2.descriptions().next(), None);
assert_eq!(task2.priority(), Some(30));
tasks.pubkey = Some(Keys::generate().public_key);
}
#[test]
@ -1808,13 +1860,13 @@ mod tasks_test {
EventBuilder::new(TASK_KIND, "sub")
.tags([tasks.make_event_tag_from_id(parent, MARKER_PARENT)])
);
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.viewed_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);
assert_eq!(tasks.viewed_tasks().len(), 2);
}
#[test]
@ -1823,7 +1875,7 @@ mod tasks_test {
let zero = EventId::all_zeros();
let test = tasks.make_task("test # tag");
let parent = tasks.make_task("parent");
assert_eq!(tasks.visible_tasks().len(), 2);
assert_eq!(tasks.viewed_tasks().len(), 2);
tasks.move_to(Some(parent));
let pin = tasks.make_task("pin");
@ -1839,7 +1891,7 @@ mod tasks_test {
EventBuilder::new(Kind::Bookmarks, "")
.tags([Tag::event(pin), Tag::event(zero)])
);
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.viewed_tasks().len(), 1);
assert_eq!(tasks.filtered_tasks(Some(pin), true).len(), 0);
assert_eq!(tasks.filtered_tasks(Some(pin), false).len(), 0);
assert_eq!(tasks.filtered_tasks(Some(zero), true).len(), 0);
@ -1850,11 +1902,11 @@ mod tasks_test {
tasks.move_to(None);
assert_eq!(tasks.view_depth, 0);
assert_tasks!(tasks, [pin, test, parent]);
assert_tasks_visible!(tasks, [pin, test, parent]);
tasks.set_view_depth(1);
assert_tasks!(tasks, [pin, test]);
tasks.add_tag("tag".to_string());
assert_tasks!(tasks, [test]);
assert_tasks_visible!(tasks, [pin, test]);
tasks.add_tag("tag");
assert_tasks_visible!(tasks, [test]);
assert_eq!(
tasks.filtered_tasks(None, true),
vec![tasks.get_by_id(&test).unwrap()]
@ -1862,9 +1914,9 @@ mod tasks_test {
tasks.submit(EventBuilder::new(Kind::Bookmarks, ""));
tasks.clear_filters();
assert_tasks!(tasks, [pin, test]);
assert_tasks_visible!(tasks, [pin, test]);
tasks.set_view_depth(0);
assert_tasks!(tasks, [test, parent]);
assert_tasks_visible!(tasks, [test, parent]);
}
#[test]
@ -1876,9 +1928,9 @@ mod tasks_test {
EventBuilder::new(TASK_KIND, "side")
.tags([tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)])
);
assert_eq!(tasks.visible_tasks(), Vec::<&Task>::new());
assert_eq!(tasks.viewed_tasks(), Vec::<&Task>::new());
let sub_id = tasks.make_task("sub");
assert_tasks!(tasks, [sub_id]);
assert_tasks_view!(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());
@ -1892,20 +1944,20 @@ mod tasks_test {
let id1 = tasks.filter_or_create(zero, "newer");
assert_eq!(tasks.len(), 1);
assert_eq!(tasks.visible_tasks().len(), 0);
assert_eq!(tasks.viewed_tasks().len(), 0);
assert_eq!(tasks.get_by_id(&id1.unwrap()).unwrap().parent_id(), zero.as_ref());
tasks.move_to(zero);
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.viewed_tasks().len(), 1);
let sub = tasks.make_task("test");
assert_eq!(tasks.len(), 2);
assert_eq!(tasks.visible_tasks().len(), 2);
assert_eq!(tasks.viewed_tasks().len(), 2);
assert_eq!(tasks.get_by_id(&sub).unwrap().parent_id(), zero.as_ref());
// Do not substring match invisible subtask
let id2 = tasks.filter_or_create(None, "#new-is gold wrapped").unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks.visible_tasks().len(), 2);
assert_eq!(tasks.viewed_tasks().len(), 2);
let new2 = tasks.get_by_id(&id2).unwrap();
assert_eq!(new2.props, Default::default());
@ -1981,32 +2033,32 @@ mod tasks_test {
assert_eq!(tasks.view_depth, 0);
assert_eq!(activity_t1.pure_state(), State::Open);
debug!("{:?}", tasks);
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.viewed_tasks().len(), 1);
tasks.search_depth = 0;
assert_eq!(tasks.visible_tasks().len(), 0);
assert_eq!(tasks.viewed_tasks().len(), 0);
tasks.recurse_activities = false;
assert_eq!(tasks.filtered_tasks(None, false).len(), 1);
tasks.move_to(Some(t1));
assert_position!(tasks, t1);
tasks.search_depth = 2;
assert_eq!(tasks.visible_tasks().len(), 0);
assert_eq!(tasks.viewed_tasks().len(), 0);
let t11 = tasks.make_task("t11 # tag");
assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.viewed_tasks().len(), 1);
assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11");
assert_eq!(tasks.get_relative_path(t11), "t11");
let t12 = tasks.make_task("t12");
assert_eq!(tasks.visible_tasks().len(), 2);
assert_eq!(tasks.viewed_tasks().len(), 2);
tasks.move_to(Some(t11));
assert_position!(tasks, t11);
assert_eq!(tasks.visible_tasks().len(), 0);
assert_eq!(tasks.viewed_tasks().len(), 0);
let t111 = tasks.make_task("t111");
assert_tasks!(tasks, [t111]);
assert_tasks_view!(tasks, [t111]);
assert_eq!(tasks.get_task_path(Some(t111)), "t1>t11>t111");
assert_eq!(tasks.get_relative_path(t111), "t111");
tasks.view_depth = 2;
assert_tasks!(tasks, [t111]);
assert_tasks_view!(tasks, [t111]);
assert_eq!(ChildIterator::from(&tasks, EventId::all_zeros()).get_all().len(), 1);
assert_eq!(ChildIterator::from(&tasks, EventId::all_zeros()).get_depth(0).len(), 1);
@ -2021,32 +2073,37 @@ mod tasks_test {
assert_eq!(tasks.get_own_events_history().count(), 3);
assert_eq!(tasks.get_relative_path(t111), "t11>t111");
assert_eq!(tasks.view_depth, 2);
assert_tasks!(tasks, [t111, t12]);
tasks.set_view(vec![t11]);
assert_tasks!(tasks, [t11]); // No more depth applied to view
tasks.set_search_depth(1); // resets view
assert_tasks!(tasks, [t111, t12]);
tasks.set_search_depth(1);
assert_tasks_view!(tasks, [t111, t12]);
tasks.set_view_depth(0);
assert_tasks!(tasks, [t11, t12]);
assert_tasks_view!(tasks, [t11, t12]);
tasks.set_view(vec![t11]);
assert_tasks_view!(tasks, [t11]);
tasks.set_view_depth(1);
assert_tasks_view!(tasks, [t111]);
tasks.set_search_depth(1); // resets view
assert_tasks_view!(tasks, [t111, t12]);
tasks.set_view_depth(0);
assert_tasks_view!(tasks, [t11, t12]);
tasks.move_to(None);
tasks.recurse_activities = true;
assert_tasks!(tasks, [t11, t12]);
assert_tasks_view!(tasks, [t11, t12]);
tasks.recurse_activities = false;
assert_tasks!(tasks, [t1]);
assert_tasks_view!(tasks, [t1]);
tasks.view_depth = 1;
assert_tasks!(tasks, [t11, t12]);
assert_tasks_view!(tasks, [t11, t12]);
tasks.view_depth = 2;
assert_tasks!(tasks, [t111, t12]);
assert_tasks_view!(tasks, [t111, t12]);
tasks.view_depth = 9;
assert_tasks!(tasks, [t111, t12]);
assert_tasks_view!(tasks, [t111, t12]);
tasks.add_tag("tag".to_string());
tasks.add_tag("tag");
tasks.view_depth = 0;
assert_tasks!(tasks, [t11]);
assert_tasks_view!(tasks, [t11]);
tasks.search_depth = 0;
assert_eq!(tasks.view, []);
assert_tasks!(tasks, []);
assert_tasks_view!(tasks, []);
}
#[test]