From 4314b8bc2dc1d177fef872537d026d7bd0c17cf6 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Tue, 26 Oct 2021 22:33:14 -0400 Subject: [PATCH] Add support for annotations This matches the taskwarrior task model for annotations. --- cli/src/argparse/modification.rs | 13 ++-- cli/src/argparse/subcommand.rs | 32 ++++++++ cli/src/invocation/cmd/info.rs | 5 ++ cli/src/invocation/modify.rs | 10 ++- docs/src/tasks.md | 4 +- taskchampion/src/lib.rs | 2 +- taskchampion/src/task/annotation.rs | 2 +- taskchampion/src/task/task.rs | 116 +++++++++++++++++++++++++++- 8 files changed, 173 insertions(+), 11 deletions(-) diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 2005bd2c3..08b04ffd2 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -48,6 +48,9 @@ pub struct Modification { /// Remove tags pub remove_tags: HashSet, + + /// Add annotation + pub annotate: Option, } /// A single argument that is part of a modification, used internally to this module @@ -128,12 +131,12 @@ impl Modification { pub(super) fn get_usage(u: &mut usage::Usage) { u.modifications.push(usage::Modification { syntax: "DESCRIPTION", - summary: "Set description", + summary: "Set description/annotation", description: " - Set the task description. Multiple arguments are combined into a single - space-separated description. To avoid surprises from shell quoting, prefer - to use a single quoted argument, for example `ta 19 modify \"return library - books\"`", + Set the task description (or the task annotation for `ta annotate`). Multiple + arguments are combined into a single space-separated description. To avoid + surprises from shell quoting, prefer to use a single quoted argument, for example + `ta 19 modify \"return library books\"`", }); u.modifications.push(usage::Modification { syntax: "+TAG", diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 2efc52ea4..d06244bec 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -207,6 +207,13 @@ impl Modify { "start" => modification.active = Some(true), "stop" => modification.active = Some(false), "done" => modification.status = Some(Status::Completed), + "annotate" => { + // what would be parsed as a description is, here, used as the annotation + if let DescriptionMod::Set(s) = modification.description { + modification.description = DescriptionMod::None; + modification.annotate = Some(s); + } + } _ => {} } @@ -225,6 +232,7 @@ impl Modify { arg_matching(literal("start")), arg_matching(literal("stop")), arg_matching(literal("done")), + arg_matching(literal("annotate")), )), Modification::parse, )), @@ -278,6 +286,13 @@ impl Modify { Mark all tasks matching the required filter as completed, additionally applying any given modifications.", }); + u.subcommands.push(usage::Subcommand { + name: "annotate", + syntax: " annotate [modification]", + summary: "Mark tasks as completed", + description: " + Add an annotation to all tasks matching the required filter.", + }); } } @@ -652,6 +667,23 @@ mod test { ); } + #[test] + fn test_annotate() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + annotate: Some("sent invoice".into()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "annotate", "sent", "invoice"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + #[test] fn test_report() { let subcommand = Subcommand::Report { diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs index 58bd46fba..bd7e6e269 100644 --- a/cli/src/invocation/cmd/info.rs +++ b/cli/src/invocation/cmd/info.rs @@ -39,6 +39,11 @@ pub(crate) fn execute( if let Some(wait) = task.get_wait() { t.add_row(row![b->"Wait", wait]); } + let mut annotations: Vec<_> = task.get_annotations().collect(); + annotations.sort(); + for ann in annotations { + t.add_row(row![b->"Annotation", format!("{}: {}", ann.entry, ann.description)]); + } } t.print(w)?; } diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index 2e27fba74..e3731e5e1 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -1,5 +1,6 @@ use crate::argparse::{DescriptionMod, Modification}; -use taskchampion::TaskMut; +use chrono::Utc; +use taskchampion::{Annotation, TaskMut}; /// Apply the given modification pub(super) fn apply_modification( @@ -41,5 +42,12 @@ pub(super) fn apply_modification( task.set_wait(wait)?; } + if let Some(ref ann) = modification.annotate { + task.add_annotation(Annotation { + entry: Utc::now(), + description: ann.into(), + })?; + } + Ok(()) } diff --git a/docs/src/tasks.md b/docs/src/tasks.md index cfd6e1ef3..e19136d88 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -33,8 +33,8 @@ The following keys, and key formats, are defined: * `start` - the most recent time at which this task was started (a task with no `start` key is not active) * `tag.` - indicates this task has tag `` (value is an empty string) * `wait` - indicates the time before which this task should be hidden, as it is not actionable +* `annotation_` - value is an annotation created at the given time The following are not yet implemented: -* `dep.` - indicates this task depends on `` (value is an empty string) -* `annotation.` - value is an annotation created at the given time +* `dep_` - indicates this task depends on `` (value is an empty string) diff --git a/taskchampion/src/lib.rs b/taskchampion/src/lib.rs index df72bdb22..dea259d4c 100644 --- a/taskchampion/src/lib.rs +++ b/taskchampion/src/lib.rs @@ -58,7 +58,7 @@ pub use errors::Error; pub use replica::Replica; pub use server::{Server, ServerConfig}; pub use storage::StorageConfig; -pub use task::{Priority, Status, Tag, Task, TaskMut}; +pub use task::{Annotation, Priority, Status, Tag, Task, TaskMut}; pub use workingset::WorkingSet; /// Re-exported type from the `uuid` crate, for ease of compatibility for consumers of this crate. diff --git a/taskchampion/src/task/annotation.rs b/taskchampion/src/task/annotation.rs index dadb72b0f..951dc3f11 100644 --- a/taskchampion/src/task/annotation.rs +++ b/taskchampion/src/task/annotation.rs @@ -1,7 +1,7 @@ use super::Timestamp; /// An annotation for a task -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Annotation { /// Time the annotation was made pub entry: Timestamp, diff --git a/taskchampion/src/task/task.rs b/taskchampion/src/task/task.rs index 102d27aa8..6b8f2b3c3 100644 --- a/taskchampion/src/task/task.rs +++ b/taskchampion/src/task/task.rs @@ -1,5 +1,5 @@ use super::tag::{SyntheticTag, TagInner}; -use super::{Status, Tag}; +use super::{Annotation, Status, Tag, Timestamp}; use crate::replica::Replica; use crate::storage::TaskMap; use chrono::prelude::*; @@ -145,6 +145,22 @@ impl Task { ) } + /// Iterate over the task's annotations, in arbitrary order. + pub fn get_annotations(&self) -> impl Iterator + '_ { + self.taskmap.iter().filter_map(|(k, v)| { + if let Some(ts) = k.strip_prefix("annotation_") { + if let Ok(ts) = ts.parse::() { + return Some(Annotation { + entry: Utc.timestamp(ts, 0), + description: v.to_owned(), + }); + } + // note that invalid "annotation_*" are ignored + } + None + }) + } + pub fn get_modified(&self) -> Option> { self.get_timestamp("modified") } @@ -225,6 +241,20 @@ impl<'r> TaskMut<'r> { self.set_string(format!("tag.{}", tag), None) } + /// Add a new annotation. Note that annotations with the same entry time + /// will overwrite one another. + pub fn add_annotation(&mut self, ann: Annotation) -> anyhow::Result<()> { + self.set_string( + format!("annotation_{}", ann.entry.timestamp()), + Some(ann.description), + ) + } + + /// Remove an annotation, based on its entry time. + pub fn remove_annotation(&mut self, entry: Timestamp) -> anyhow::Result<()> { + self.set_string(format!("annotation_{}", entry.timestamp()), None) + } + // -- utility functions fn lastmod(&mut self) -> anyhow::Result<()> { @@ -442,6 +472,90 @@ mod test { assert_eq!(tags, vec![utag("ok"), stag(SyntheticTag::Pending)]); } + #[test] + fn test_get_annotations() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ( + String::from("annotation_1635301873"), + String::from("left message"), + ), + ( + String::from("annotation_1635301883"), + String::from("left another message"), + ), + (String::from("annotation_"), String::from("invalid")), + (String::from("annotation_abcde"), String::from("invalid")), + ] + .drain(..) + .collect(), + ); + + let mut anns: Vec<_> = task.get_annotations().collect(); + anns.sort(); + assert_eq!( + anns, + vec![ + Annotation { + entry: Utc.timestamp(1635301873, 0), + description: "left message".into() + }, + Annotation { + entry: Utc.timestamp(1635301883, 0), + description: "left another message".into() + } + ] + ); + } + + #[test] + fn test_add_annotation() { + with_mut_task(|mut task| { + task.add_annotation(Annotation { + entry: Utc.timestamp(1635301900, 0), + description: "right message".into(), + }) + .unwrap(); + let k = "annotation_1635301900"; + assert_eq!(task.taskmap[k], "right message".to_owned()); + task.reload().unwrap(); + assert_eq!(task.taskmap[k], "right message".to_owned()); + // adding with same time overwrites.. + task.add_annotation(Annotation { + entry: Utc.timestamp(1635301900, 0), + description: "right message 2".into(), + }) + .unwrap(); + assert_eq!(task.taskmap[k], "right message 2".to_owned()); + }); + } + + #[test] + fn test_remove_annotation() { + with_mut_task(|mut task| { + task.set_string("annotation_1635301873", Some("left message".into())) + .unwrap(); + task.set_string("annotation_1635301883", Some("left another message".into())) + .unwrap(); + + task.remove_annotation(Utc.timestamp(1635301873, 0)) + .unwrap(); + + task.reload().unwrap(); + + let mut anns: Vec<_> = task.get_annotations().collect(); + anns.sort(); + assert_eq!( + anns, + vec![Annotation { + entry: Utc.timestamp(1635301883, 0), + description: "left another message".into() + }] + ); + }); + } + #[test] fn test_start() { with_mut_task(|mut task| {