diff --git a/docs/src/tasks.md b/docs/src/tasks.md index f21dd475d..eb6756e22 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -38,3 +38,14 @@ The following keys, and key formats, are defined: The following are not yet implemented: * `dep_` - indicates this task depends on `` (value is an empty string) + +### UDAs + +Any unrecognized keys are treated as "user-defined attributes" (UDAs). +These attributes can be used to store additional data associated with a task. +For example, applications that synchronize tasks with other systems such as calendars or team planning services might store unique identifiers for those systems as UDAs. +The application defining a UDA defines the format of the value. + +UDAs _should_ have a namespaced structure of the form `.`, where `` identifies the application defining the UDA. +For example, a service named "DevSync" synchronizing tasks from GitHub might use UDAs like `devsync.github.issue-id`. +Note that many existing UDAs for Taskwarrior integrations do not follow this pattern; these are referred to as legacy UDAs. diff --git a/taskchampion/src/task/task.rs b/taskchampion/src/task/task.rs index 2e3ba8169..40a85ba87 100644 --- a/taskchampion/src/task/task.rs +++ b/taskchampion/src/task/task.rs @@ -6,6 +6,7 @@ use chrono::prelude::*; use log::trace; use std::convert::AsRef; use std::convert::TryInto; +use std::str::FromStr; use uuid::Uuid; /* The Task and TaskMut classes wrap the underlying [`TaskMap`], which is a simple key/value map. @@ -46,6 +47,40 @@ pub struct TaskMut<'r> { updated_modified: bool, } +/// An enum containing all of the key names defined in the data model, with the exception +/// of the properties containing data (`tag_..`, etc.) +#[derive(strum_macros::AsRefStr, strum_macros::EnumString)] +#[strum(serialize_all = "kebab-case")] +enum Prop { + Description, + Modified, + Start, + Status, + Wait, +} + +#[allow(clippy::ptr_arg)] +fn uda_string_to_tuple(key: &str) -> (&str, &str) { + let mut iter = key.splitn(2, '.'); + let first = iter.next().unwrap(); + let second = iter.next(); + if let Some(second) = second { + (first, second) + } else { + ("", first) + } +} + +fn uda_tuple_to_string(namespace: impl AsRef, key: impl AsRef) -> String { + let namespace = namespace.as_ref(); + let key = key.as_ref(); + if namespace.is_empty() { + key.into() + } else { + format!("{}.{}", namespace, key) + } +} + impl Task { pub(crate) fn new(uuid: Uuid, taskmap: TaskMap) -> Task { Task { uuid, taskmap } @@ -71,14 +106,14 @@ impl Task { pub fn get_status(&self) -> Status { self.taskmap - .get("status") + .get(Prop::Status.as_ref()) .map(|s| Status::from_taskmap(s)) .unwrap_or(Status::Pending) } pub fn get_description(&self) -> &str { self.taskmap - .get("description") + .get(Prop::Description.as_ref()) .map(|s| s.as_ref()) .unwrap_or("") } @@ -86,7 +121,7 @@ impl Task { /// Get the wait time. If this value is set, it will be returned, even /// if it is in the past. pub fn get_wait(&self) -> Option> { - self.get_timestamp("wait") + self.get_timestamp(Prop::Wait.as_ref()) } /// Determine whether this task is waiting now. @@ -100,7 +135,7 @@ impl Task { /// Determine whether this task is active -- that is, that it has been started /// and not stopped. pub fn is_active(&self) -> bool { - self.taskmap.contains_key("start") + self.taskmap.contains_key(Prop::Start.as_ref()) } /// Determine whether a given synthetic tag is present on this task. All other @@ -161,12 +196,53 @@ impl Task { }) } + /// Get the named user defined attributes (UDA). This will return None + /// for any key defined in the Task data model, regardless of whether + /// it is set or not. + pub fn get_uda(&self, namespace: &str, key: &str) -> Option<&str> { + self.get_legacy_uda(uda_tuple_to_string(namespace, key).as_ref()) + } + + /// Get the user defined attributes (UDAs) of this task, in arbitrary order. Each key is split + /// on the first `.` character. Legacy keys that do not contain `.` are represented as `("", + /// key)`. + pub fn get_udas(&self) -> impl Iterator + '_ { + self.taskmap + .iter() + .filter(|(k, _)| !Task::is_known_key(k)) + .map(|(k, v)| (uda_string_to_tuple(k), v.as_ref())) + } + + /// Get the named user defined attribute (UDA) in a legacy format. This will return None for + /// any key defined in the Task data model, regardless of whether it is set or not. + pub fn get_legacy_uda(&self, key: &str) -> Option<&str> { + if Task::is_known_key(key) { + return None; + } + self.taskmap.get(key).map(|s| s.as_ref()) + } + + /// Like `get_udas`, but returning each UDA key as a single string. + pub fn get_legacy_udas(&self) -> impl Iterator + '_ { + self.taskmap + .iter() + .filter(|(p, _)| !Task::is_known_key(p)) + .map(|(p, v)| (p.as_ref(), v.as_ref())) + } + pub fn get_modified(&self) -> Option> { - self.get_timestamp("modified") + self.get_timestamp(Prop::Modified.as_ref()) } // -- utility functions + fn is_known_key(key: &str) -> bool { + Prop::from_str(key).is_ok() + || key.starts_with("tag_") + || key.starts_with("annotation_") + || key.starts_with("dep_") + } + fn get_timestamp(&self, property: &str) -> Option> { if let Some(ts) = self.taskmap.get(property) { if let Ok(ts) = ts.parse() { @@ -191,19 +267,22 @@ impl<'r> TaskMut<'r> { let uuid = self.uuid; self.replica.add_to_working_set(uuid)?; } - self.set_string("status", Some(String::from(status.to_taskmap()))) + self.set_string( + Prop::Status.as_ref(), + Some(String::from(status.to_taskmap())), + ) } pub fn set_description(&mut self, description: String) -> anyhow::Result<()> { - self.set_string("description", Some(description)) + self.set_string(Prop::Description.as_ref(), Some(description)) } pub fn set_wait(&mut self, wait: Option>) -> anyhow::Result<()> { - self.set_timestamp("wait", wait) + self.set_timestamp(Prop::Wait.as_ref(), wait) } pub fn set_modified(&mut self, modified: DateTime) -> anyhow::Result<()> { - self.set_timestamp("modified", Some(modified)) + self.set_timestamp(Prop::Modified.as_ref(), Some(modified)) } /// Start the task by creating "start": "", if the task is not already @@ -212,12 +291,12 @@ impl<'r> TaskMut<'r> { if self.is_active() { return Ok(()); } - self.set_timestamp("start", Some(Utc::now())) + self.set_timestamp(Prop::Start.as_ref(), Some(Utc::now())) } /// Stop the task by removing the `start` key pub fn stop(&mut self) -> anyhow::Result<()> { - self.set_timestamp("start", None) + self.set_timestamp(Prop::Start.as_ref(), None) } /// Mark this task as complete @@ -255,15 +334,66 @@ impl<'r> TaskMut<'r> { self.set_string(format!("annotation_{}", entry.timestamp()), None) } + /// Set a user-defined attribute (UDA). This will fail if the key is defined by the data + /// model. + pub fn set_uda( + &mut self, + namespace: impl AsRef, + key: impl AsRef, + value: impl Into, + ) -> anyhow::Result<()> { + let key = uda_tuple_to_string(namespace, key); + self.set_legacy_uda(key, value) + } + + /// Remove a user-defined attribute (UDA). This will fail if the key is defined by the data + /// model. + pub fn remove_uda( + &mut self, + namespace: impl AsRef, + key: impl AsRef, + ) -> anyhow::Result<()> { + let key = uda_tuple_to_string(namespace, key); + self.remove_legacy_uda(key) + } + + /// Set a user-defined attribute (UDA), where the key is a legacy key. + pub fn set_legacy_uda( + &mut self, + key: impl Into, + value: impl Into, + ) -> anyhow::Result<()> { + let key = key.into(); + if Task::is_known_key(&key) { + anyhow::bail!( + "Property name {} as special meaning in a task and cannot be used as a UDA", + key + ); + } + self.set_string(key, Some(value.into())) + } + + /// Remove a user-defined attribute (UDA), where the key is a legacy key. + pub fn remove_legacy_uda(&mut self, key: impl Into) -> anyhow::Result<()> { + let key = key.into(); + if Task::is_known_key(&key) { + anyhow::bail!( + "Property name {} as special meaning in a task and cannot be used as a UDA", + key + ); + } + self.set_string(key, None) + } + // -- utility functions fn lastmod(&mut self) -> anyhow::Result<()> { if !self.updated_modified { let now = format!("{}", Utc::now().timestamp()); trace!("task {}: set property modified={:?}", self.task.uuid, now); - self.task.taskmap = self - .replica - .update_task(self.task.uuid, "modified", Some(now))?; + self.task.taskmap = + self.replica + .update_task(self.task.uuid, Prop::Modified.as_ref(), Some(now))?; self.updated_modified = true; } Ok(()) @@ -624,4 +754,151 @@ mod test { assert!(!task.taskmap.contains_key("tag_abc")); }); } + + #[test] + fn test_get_udas() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ("description".into(), "not a uda".into()), + ("modified".into(), "not a uda".into()), + ("start".into(), "not a uda".into()), + ("status".into(), "not a uda".into()), + ("wait".into(), "not a uda".into()), + ("start".into(), "not a uda".into()), + ("tag_abc".into(), "not a uda".into()), + ("dep_1234".into(), "not a uda".into()), + ("annotation_1234".into(), "not a uda".into()), + ("githubid".into(), "123".into()), + ("jira.url".into(), "h://x".into()), + ] + .drain(..) + .collect(), + ); + + let mut udas: Vec<_> = task.get_udas().collect(); + udas.sort_unstable(); + assert_eq!( + udas, + vec![(("", "githubid"), "123"), (("jira", "url"), "h://x")] + ); + } + + #[test] + fn test_get_uda() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ("description".into(), "not a uda".into()), + ("githubid".into(), "123".into()), + ("jira.url".into(), "h://x".into()), + ] + .drain(..) + .collect(), + ); + + assert_eq!(task.get_uda("", "description"), None); // invalid UDA + assert_eq!(task.get_uda("", "githubid"), Some("123")); + assert_eq!(task.get_uda("jira", "url"), Some("h://x")); + assert_eq!(task.get_uda("bugzilla", "url"), None); + } + + #[test] + fn test_get_legacy_uda() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ("description".into(), "not a uda".into()), + ("dep_1234".into(), "not a uda".into()), + ("githubid".into(), "123".into()), + ("jira.url".into(), "h://x".into()), + ] + .drain(..) + .collect(), + ); + + assert_eq!(task.get_legacy_uda("description"), None); // invalid UDA + assert_eq!(task.get_legacy_uda("dep_1234"), None); // invalid UDA + assert_eq!(task.get_legacy_uda("githubid"), Some("123")); + assert_eq!(task.get_legacy_uda("jira.url"), Some("h://x")); + assert_eq!(task.get_legacy_uda("bugzilla.url"), None); + } + + #[test] + fn test_set_uda() { + with_mut_task(|mut task| { + task.set_uda("jira", "url", "h://y").unwrap(); + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![(("jira", "url"), "h://y")]); + + task.set_uda("", "jiraid", "TW-1234").unwrap(); + + let mut udas: Vec<_> = task.get_udas().collect(); + udas.sort_unstable(); + assert_eq!( + udas, + vec![(("", "jiraid"), "TW-1234"), (("jira", "url"), "h://y")] + ); + }) + } + + #[test] + fn test_set_legacy_uda() { + with_mut_task(|mut task| { + task.set_legacy_uda("jira.url", "h://y").unwrap(); + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![(("jira", "url"), "h://y")]); + + task.set_legacy_uda("jiraid", "TW-1234").unwrap(); + + let mut udas: Vec<_> = task.get_udas().collect(); + udas.sort_unstable(); + assert_eq!( + udas, + vec![(("", "jiraid"), "TW-1234"), (("jira", "url"), "h://y")] + ); + }) + } + + #[test] + fn test_set_uda_invalid() { + with_mut_task(|mut task| { + assert!(task.set_uda("", "modified", "123").is_err()); + assert!(task.set_uda("", "tag_abc", "123").is_err()); + assert!(task.set_legacy_uda("modified", "123").is_err()); + assert!(task.set_legacy_uda("tag_abc", "123").is_err()); + }) + } + + #[test] + fn test_remove_uda() { + with_mut_task(|mut task| { + task.set_string("github.id", Some("123".into())).unwrap(); + task.remove_uda("github", "id").unwrap(); + + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![]); + }) + } + + #[test] + fn test_remove_legacy_uda() { + with_mut_task(|mut task| { + task.set_string("githubid", Some("123".into())).unwrap(); + task.remove_legacy_uda("githubid").unwrap(); + + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![]); + }) + } + + #[test] + fn test_remove_uda_invalid() { + with_mut_task(|mut task| { + assert!(task.remove_uda("", "modified").is_err()); + assert!(task.remove_uda("", "tag_abc").is_err()); + assert!(task.remove_legacy_uda("modified").is_err()); + assert!(task.remove_legacy_uda("tag_abc").is_err()); + }) + } }