Merge pull request #316 from djmitche/issue90

Define UDAs
This commit is contained in:
Dustin J. Mitchell 2021-12-27 17:06:47 -05:00 committed by GitHub
commit 1bc7b5f019
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 302 additions and 14 deletions

View file

@ -38,3 +38,14 @@ The following keys, and key formats, are defined:
The following are not yet implemented:
* `dep_<uuid>` - indicates this task depends on `<uuid>` (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 `<namespace>.<key>`, where `<namespace>` 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.

View file

@ -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<str>, key: impl AsRef<str>) -> 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<DateTime<Utc>> {
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<Item = ((&str, &str), &str)> + '_ {
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<Item = (&str, &str)> + '_ {
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<DateTime<Utc>> {
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<DateTime<Utc>> {
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<DateTime<Utc>>) -> anyhow::Result<()> {
self.set_timestamp("wait", wait)
self.set_timestamp(Prop::Wait.as_ref(), wait)
}
pub fn set_modified(&mut self, modified: DateTime<Utc>) -> anyhow::Result<()> {
self.set_timestamp("modified", Some(modified))
self.set_timestamp(Prop::Modified.as_ref(), Some(modified))
}
/// Start the task by creating "start": "<timestamp>", 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<str>,
key: impl AsRef<str>,
value: impl Into<String>,
) -> 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<str>,
key: impl AsRef<str>,
) -> 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<String>,
value: impl Into<String>,
) -> 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<String>) -> 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());
})
}
}