Compare commits

..

No commits in common. "20fc8f9a3ad574a8316ca6fa81eba9b0b21df096" and "00bd7a997a6afd5c2bf9267fa8de802d9d3471a6" have entirely different histories.

8 changed files with 74 additions and 125 deletions

2
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -146,11 +146,6 @@ pub fn format_timestamp_local(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M") format_timestamp(stamp, "%y-%m-%d %a %H:%M")
} }
/// Format nostr timestamp with seconds precision.
pub fn format_timestamp_full(stamp: &Timestamp) -> String {
format_timestamp(stamp, "%y-%m-%d %a %H:%M:%S")
}
pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String { pub fn format_timestamp_relative_to(stamp: &Timestamp, reference: &Timestamp) -> String {
// Rough difference in days // Rough difference in days
match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 { match (stamp.as_u64() as i64 - reference.as_u64() as i64) / 80_000 {

View file

@ -41,11 +41,10 @@ Task:
- `hashtags` - list of hashtags set for the task - `hashtags` - list of hashtags set for the task
- `tags` - values of all nostr tags associated with the event, except event tags - `tags` - values of all nostr tags associated with the event, except event tags
- `desc` - last note on the task - `desc` - last note on the task
- `description` - all notes on the task - `description` - accumulated notes on the task
- `time` - time tracked on this task by you - `time` - time tracked on this task by you
Utilities: Utilities:
- `state` - indicator of current progress - `state` - indicator of current progress
- `owner` - author or task assignee
- `rtime` - time tracked on this tasks and its subtree by everyone - `rtime` - time tracked on this tasks and its subtree by everyone
- `progress` - recursive subtask completion in percent - `progress` - recursive subtask completion in percent
- `subtasks` - how many direct subtasks are complete - `subtasks` - how many direct subtasks are complete
@ -79,8 +78,7 @@ where
.tags(id.into_iter().map(Tag::event)) .tags(id.into_iter().map(Tag::event))
} }
/// Formats and joins the tags with commata pub fn join<'a, T>(tags: T) -> String
pub fn join_tags<'a, T>(tags: T) -> String
where where
T: IntoIterator<Item=&'a Tag>, T: IntoIterator<Item=&'a Tag>,
{ {
@ -133,16 +131,12 @@ pub fn to_hashtag(tag: &str) -> Tag {
TagStandard::Hashtag(tag.to_string()).into() TagStandard::Hashtag(tag.to_string()).into()
} }
pub fn format_tag(tag: &Tag) -> String { fn format_tag(tag: &Tag) -> String {
if let Some(et) = match_event_tag(tag) { if let Some(et) = match_event_tag(tag) {
return format!("{}: {:.8}", return format!("{}: {:.8}",
et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()), et.marker.as_ref().map(|m| m.to_string()).unwrap_or(MARKER_PARENT.to_string()),
et.id); et.id);
} }
format_tag_basic(tag)
}
pub fn format_tag_basic(tag: &Tag) -> String {
match tag.as_standardized() { match tag.as_standardized() {
Some(TagStandard::PublicKey { Some(TagStandard::PublicKey {
public_key, public_key,
@ -155,12 +149,12 @@ pub fn format_tag_basic(tag: &Tag) -> String {
} }
} }
pub fn is_hashtag(tag: &Tag) -> bool { pub(crate) fn is_hashtag(tag: &Tag) -> bool {
tag.single_letter_tag() tag.single_letter_tag()
.is_some_and(|letter| letter.character == Alphabet::T) .is_some_and(|letter| letter.character == Alphabet::T)
} }
pub fn to_prio_tag(value: Prio) -> Tag { pub(crate) fn to_prio_tag(value: Prio) -> Tag {
Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()]) Tag::custom(TagKind::Custom(Cow::from(PRIO)), [value.to_string()])
} }

View file

@ -10,7 +10,7 @@ use std::time::Duration;
use crate::event_sender::MostrMessage; use crate::event_sender::MostrMessage;
use crate::helpers::*; use crate::helpers::*;
use crate::kinds::{format_tag_basic, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS}; use crate::kinds::{join, match_event_tag, Prio, BASIC_KINDS, PROPERTY_COLUMNS, PROP_KINDS};
use crate::task::{State, Task, TaskState, MARKER_PROPERTY}; use crate::task::{State, Task, TaskState, MARKER_PROPERTY};
use crate::tasks::{PropertyCollection, StateFilter, TasksRelay}; use crate::tasks::{PropertyCollection, StateFilter, TasksRelay};
use chrono::Local; use chrono::Local;
@ -385,29 +385,20 @@ async fn main() -> Result<()> {
match arg { match arg {
None => { None => {
if let Some(task) = tasks.get_current_task() { if let Some(task) = tasks.get_current_task() {
println!("Change History:");
for e in once(&task.event).chain(task.props.iter().rev()) { for e in once(&task.event).chain(task.props.iter().rev()) {
let content = match State::try_from(e.kind) {
Ok(state) => {
format!("State: {state}{}",
if e.content.is_empty() { String::new() } else { format!(" - {}", e.content) })
}
Err(_) => {
e.content.to_string()
}
};
println!("{} {} [{}]", println!("{} {} [{}]",
format_timestamp_full(&e.created_at), format_timestamp_local(&e.created_at),
match State::try_from(e.kind) { content,
Ok(state) => { join(e.tags.iter().filter(|t| match_event_tag(t).unwrap().marker.is_none_or(|m| m != MARKER_PROPERTY))));
format!("State: {state}{}",
if e.content.is_empty() { String::new() } else { format!(" - {}", e.content) })
}
Err(_) => {
e.content.to_string()
}
},
e.tags.iter().filter_map(|t| {
match match_event_tag(t) {
Some(et) =>
Some(et).take_if(|et| et.marker.as_ref().is_some_and(|m| m != MARKER_PROPERTY))
.map(|et| format!("{}: {}", et.marker.as_ref().unwrap(), tasks.get_relative_path(et.id))),
None =>
Some(format_tag_basic(t)),
}
}).join(", ")
)
} }
continue 'repl; continue 'repl;
} else { } else {
@ -453,36 +444,37 @@ async fn main() -> Result<()> {
} }
Some('@') => { Some('@') => {
match arg { let success = match arg {
None => { None => {
let today = Timestamp::now() - 80_000; let today = Timestamp::now() - 80_000;
info!("Filtering for tasks from the last 22 hours"); info!("Filtering for tasks from the last 22 hours");
if !tasks.set_filter_since(today) { tasks.set_filter_from(today)
continue 'repl;
}
} }
Some(arg) => { Some(arg) => {
if arg == "@" { if arg == "@" {
tasks.reset_key_filter() info!("Filtering for own tasks");
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) {
info!("Showing {}'s tasks", tasks.get_username(&key)); let author = tasks.get_username(&key);
tasks.set_key_filter(key) info!("Filtering for tasks by {author}");
tasks.set_filter_author(key)
} else if let Some((key, meta)) = tasks.find_user(arg) { } 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()))); info!("Filtering for tasks by {}", meta.display_name.as_ref().unwrap_or(meta.name.as_ref().unwrap_or(&key.to_string())));
tasks.set_key_filter(key.clone()) tasks.set_filter_author(key.clone())
} else { } else {
if 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)))
.map(|time| { .map(|time| {
info!("Filtering for tasks from {}", format_datetime_relative(time)); info!("Filtering for tasks from {}", format_datetime_relative(time));
tasks.set_filter_since(time.to_timestamp()) tasks.set_filter_from(time.to_timestamp())
}) })
.is_none_or(|b| !b) { .unwrap_or(false)
continue 'repl;
}
} }
} }
}; };
if !success {
continue 'repl;
}
} }
Some('*') => { Some('*') => {

View file

@ -92,12 +92,11 @@ impl Task {
self.event.content.trim().trim_start_matches('#').to_string() self.event.content.trim().trim_start_matches('#').to_string()
} }
fn description_events(&self) -> impl DoubleEndedIterator<Item=&Event> + '_ { pub(crate) fn description_events(&self) -> impl Iterator<Item=&Event> + '_ {
self.props.iter().filter(|event| event.kind == Kind::TextNote) self.props.iter().filter(|event| event.kind == Kind::TextNote)
} }
/// Description items, ordered newest to oldest pub(crate) fn descriptions(&self) -> impl Iterator<Item=&String> + '_ {
pub(crate) fn descriptions(&self) -> impl DoubleEndedIterator<Item=&String> + '_ {
self.description_events().map(|e| &e.content) self.description_events().map(|e| &e.content)
} }
@ -209,8 +208,8 @@ impl Task {
// Dynamic // Dynamic
"priority" => self.priority_raw().map(|c| c.to_string()), "priority" => self.priority_raw().map(|c| c.to_string()),
"status" => self.state_label().map(|c| c.to_string()), "status" => self.state_label().map(|c| c.to_string()),
"desc" => self.descriptions().next().cloned(), "desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().rev().join(" ")), "description" => Some(self.descriptions().join(" ")),
"hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })), "hashtags" => Some(self.join_tags(|tag| { is_hashtag(tag) })),
"tags" => Some(self.join_tags(|_| true)), // TODO test these! "tags" => Some(self.join_tags(|_| true)), // TODO test these!
"alltags" => Some(format!("{:?}", self.tags)), "alltags" => Some(format!("{:?}", self.tags)),

View file

@ -79,7 +79,6 @@ pub(crate) struct TasksRelay {
state: StateFilter, state: StateFilter,
/// Current priority for filtering and new tasks /// Current priority for filtering and new tasks
priority: Option<Prio>, priority: Option<Prio>,
pubkey: Option<PublicKey>,
sender: EventSender, sender: EventSender,
overflow: VecDeque<Event>, overflow: VecDeque<Event>,
@ -174,7 +173,6 @@ impl TasksRelay {
tags_excluded: Default::default(), tags_excluded: Default::default(),
state: Default::default(), state: Default::default(),
priority: None, priority: None,
pubkey: Some(sender.pubkey()),
search_depth: 4, search_depth: 4,
view_depth: 0, view_depth: 0,
@ -384,28 +382,14 @@ impl TasksRelay {
.and_then(|t| t.parent_id()) .and_then(|t| t.parent_id())
} }
// TODO test with context elements
/// Visual representation of current context
pub(crate) fn get_prompt_suffix(&self) -> String { pub(crate) fn get_prompt_suffix(&self) -> String {
let mut prompt = String::with_capacity(128); self.tags.iter()
match self.pubkey { .map(|t| format!(" #{}", t.content().unwrap()))
None => { prompt.push_str(" @ALL"); } .chain(self.tags_excluded.iter()
Some(key) => .map(|t| format!(" -#{}", t.content().unwrap())))
if key != self.sender.pubkey() { .chain(once(self.state.indicator()))
prompt.push_str(" "); .chain(self.priority.map(|p| format!(" *{:02}", p)))
prompt.push_str(&self.get_username(&key)) .join("")
},
}
for tag in self.tags.iter() {
prompt.push_str(&format!(" #{}", tag.content().unwrap()));
}
for tag in self.tags_excluded.iter() {
prompt.push_str(&format!(" -#{}", tag.content().unwrap()));
}
prompt.push_str(&self.state.indicator());
self.priority.map(|p|
prompt.push_str(&format!(" *{:02}", p)));
prompt
} }
pub(crate) fn get_task_path(&self, id: Option<EventId>) -> String { pub(crate) fn get_task_path(&self, id: Option<EventId>) -> String {
@ -415,14 +399,6 @@ impl TasksRelay {
.unwrap_or_default() .unwrap_or_default()
} }
pub(crate) fn get_relative_path(&self, id: EventId) -> String {
join_tasks(
self.traverse_up_from(Some(id))
.take_while(|t| Some(t.event.id) != self.get_position()),
false,
).unwrap_or(id.to_string())
}
/// Iterate over the task referenced by the given id and all its available parents. /// Iterate over the task referenced by the given id and all its available parents.
fn traverse_up_from(&self, id: Option<EventId>) -> ParentIterator { fn traverse_up_from(&self, id: Option<EventId>) -> ParentIterator {
ParentIterator { ParentIterator {
@ -431,6 +407,13 @@ impl TasksRelay {
} }
} }
fn relative_path(&self, id: EventId) -> String {
join_tasks(
self.traverse_up_from(Some(id))
.take_while(|t| Some(t.event.id) != self.get_position()),
false,
).unwrap_or(id.to_string())
}
// Helpers // Helpers
@ -494,7 +477,6 @@ impl TasksRelay {
fn filter(&self, task: &Task) -> bool { fn filter(&self, task: &Task) -> bool {
self.state.matches(task) && self.state.matches(task) &&
self.pubkey.is_none_or(|p| p == task.event.pubkey) &&
self.priority.is_none_or(|prio| { self.priority.is_none_or(|prio| {
task.priority().unwrap_or(DEFAULT_PRIO) >= prio task.priority().unwrap_or(DEFAULT_PRIO) >= prio
}) && }) &&
@ -615,7 +597,7 @@ impl TasksRelay {
} }
}), }),
"path" => self.get_task_path(Some(task.event.id)), "path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.get_relative_path(task.event.id), "rpath" => self.relative_path(task.event.id),
// TODO format strings configurable // TODO format strings configurable
"time" => display_time("MMMm", self.time_tracked(*task.get_id())), "time" => display_time("MMMm", self.time_tracked(*task.get_id())),
"rtime" => display_time("HH:MM", self.total_time_tracked(*task.get_id())), "rtime" => display_time("HH:MM", self.total_time_tracked(*task.get_id())),
@ -658,22 +640,11 @@ impl TasksRelay {
Ok(added) Ok(added)
} }
pub(crate) fn reset_key_filter(&mut self) { pub(crate) fn set_filter_author(&mut self, key: PublicKey) -> bool {
let own = self.sender.pubkey(); self.set_filter(|t| t.event.pubkey == key)
if self.pubkey.is_some_and(|k| k == own) {
info!("Showing everybody's tasks");
self.pubkey = None
} else {
info!("Showing own tasks");
self.pubkey = Some(own)
}
} }
pub(crate) fn set_key_filter(&mut self, key: PublicKey) { pub(crate) fn set_filter_from(&mut self, time: Timestamp) -> bool {
self.pubkey = Some(key)
}
pub(crate) fn set_filter_since(&mut self, time: Timestamp) -> bool {
// TODO filter at both ends // TODO filter at both ends
self.set_filter(|t| t.last_state_update() > time) self.set_filter(|t| t.last_state_update() > time)
} }
@ -714,11 +685,10 @@ impl TasksRelay {
pub(crate) fn clear_filters(&mut self) { pub(crate) fn clear_filters(&mut self) {
self.state = StateFilter::Default; self.state = StateFilter::Default;
self.pubkey = Some(self.sender.pubkey());
self.view.clear(); self.view.clear();
self.tags.clear(); self.tags.clear();
self.tags_excluded.clear(); self.tags_excluded.clear();
info!("Reset all filters"); info!("Removed all filters");
} }
pub(crate) fn has_tag_filter(&self) -> bool { pub(crate) fn has_tag_filter(&self) -> bool {
@ -1003,7 +973,7 @@ impl TasksRelay {
let (input, input_tags) = extract_tags(input.trim()); let (input, input_tags) = extract_tags(input.trim());
let prio = let prio =
if input_tags.iter().any(|t| t.kind().to_string() == PRIO) { None } else { self.priority.map(|p| to_prio_tag(p)) }; if input_tags.iter().any(|t| t.kind().to_string() == PRIO) { None } else { self.priority.map(|p| to_prio_tag(p)) };
info!("Created task \"{input}\" with tags [{}]", join_tags(&input_tags)); info!("Created task \"{input}\" with tags [{}]", join(&input_tags));
let id = self.submit( let id = self.submit(
EventBuilder::new(TASK_KIND, &input) EventBuilder::new(TASK_KIND, &input)
.tags(input_tags) .tags(input_tags)
@ -1245,7 +1215,7 @@ impl TasksRelay {
/// Sanitizes Input. /// Sanitizes Input.
pub(crate) fn make_note(&mut self, note: &str) -> EventId { pub(crate) fn make_note(&mut self, note: &str) -> EventId {
let (name, tags) = extract_tags(note.trim()); let (name, tags) = extract_tags(note.trim());
let format = format!("\"{name}\" with tags [{}]", join_tags(&tags)); let format = format!("\"{name}\" with tags [{}]", join(&tags));
let mut prop = let mut prop =
EventBuilder::new(Kind::TextNote, name).tags(tags); EventBuilder::new(Kind::TextNote, name).tags(tags);
//.filter(|id| self.get_by_id(id).is_some_and(|t| t.is_task())) //.filter(|id| self.get_by_id(id).is_some_and(|t| t.is_task()))
@ -1314,14 +1284,13 @@ impl Display for TasksRelay {
} }
writeln!( writeln!(
lock, lock,
"Active from {} (total tracked time {}m) - State {} since {}", "Active from {} (total tracked time {}m) - {} since {}",
tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)), tracking_stamp.map_or("?".to_string(), |t| format_timestamp_relative(&t)),
self.time_tracked(*t.get_id()) / 60, self.time_tracked(*t.get_id()) / 60,
state.get_label(), state.get_label(),
format_timestamp_relative(&state.time) format_timestamp_relative(&state.time)
)?; )?;
for d in t.descriptions().rev() { writeln!(lock, "{}", d)?; } writeln!(lock, "{}", t.descriptions().join("\n"))?;
writeln!(lock)?;
} }
let position = self.get_position(); let position = self.get_position();
@ -1478,8 +1447,6 @@ fn display_time(format: &str, secs: u64) -> String {
) )
} }
/// Joins the tasks of this upwards iterator.
/// * `include_last_id` whether to add the id of an unknown parent at the top
pub(crate) fn join_tasks<'a>( pub(crate) fn join_tasks<'a>(
iter: impl Iterator<Item=&'a Task>, iter: impl Iterator<Item=&'a Task>,
include_last_id: bool, include_last_id: bool,
@ -1488,12 +1455,14 @@ pub(crate) fn join_tasks<'a>(
tasks tasks
.iter() .iter()
.map(|t| t.get_title()) .map(|t| t.get_title())
.chain( .chain(if include_last_id {
tasks.last() tasks.last()
.take_if(|_| include_last_id)
.and_then(|t| t.parent_id()) .and_then(|t| t.parent_id())
.map(|id| id.to_string()) .map(|id| id.to_string())
.into_iter()) .into_iter()
} else {
None.into_iter()
})
.fold(None, |acc, val| { .fold(None, |acc, val| {
Some(acc.map_or_else( Some(acc.map_or_else(
|| val.clone(), || val.clone(),
@ -1994,7 +1963,7 @@ mod tasks_test {
let t11 = tasks.make_task("t11 # tag"); let t11 = tasks.make_task("t11 # tag");
assert_eq!(tasks.visible_tasks().len(), 1); assert_eq!(tasks.visible_tasks().len(), 1);
assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11"); assert_eq!(tasks.get_task_path(Some(t11)), "t1>t11");
assert_eq!(tasks.get_relative_path(t11), "t11"); assert_eq!(tasks.relative_path(t11), "t11");
let t12 = tasks.make_task("t12"); let t12 = tasks.make_task("t12");
assert_eq!(tasks.visible_tasks().len(), 2); assert_eq!(tasks.visible_tasks().len(), 2);
@ -2004,7 +1973,7 @@ mod tasks_test {
let t111 = tasks.make_task("t111"); let t111 = tasks.make_task("t111");
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.get_relative_path(t111), "t111"); assert_eq!(tasks.relative_path(t111), "t111");
tasks.view_depth = 2; tasks.view_depth = 2;
assert_tasks!(tasks, [t111]); assert_tasks!(tasks, [t111]);
@ -2019,7 +1988,7 @@ mod tasks_test {
tasks.move_to(Some(t1)); tasks.move_to(Some(t1));
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.get_relative_path(t111), "t11>t111"); assert_eq!(tasks.relative_path(t111), "t11>t111");
assert_eq!(tasks.view_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]);
@ -2072,7 +2041,7 @@ mod tasks_test {
tasks.get_task_path(Some(dangling)), tasks.get_task_path(Some(dangling)),
"0000000000000000000000000000000000000000000000000000000000000000>test" "0000000000000000000000000000000000000000000000000000000000000000>test"
); );
assert_eq!(tasks.get_relative_path(dangling), "test"); assert_eq!(tasks.relative_path(dangling), "test");
} }
#[allow(dead_code)] // #[test] #[allow(dead_code)] // #[test]