diff --git a/Cargo.lock b/Cargo.lock index 61a97dabe..8f78fcdfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "failure" version = "0.1.6" @@ -176,6 +182,15 @@ dependencies = [ "wasi", ] +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.4" @@ -607,6 +622,7 @@ dependencies = [ "chrono", "clap", "failure", + "itertools", "kv", "lmdb-rkv", "proptest", diff --git a/Cargo.toml b/Cargo.toml index 8e5c07492..e1f5ba545 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ failure = {version = "0.1.5", features = ["derive"] } clap = "~2.33.0" kv = {version = "0.10.0", features = ["msgpack-value"]} lmdb-rkv = {version = "0.12.3"} +itertools = "0.9.0" [dev-dependencies] proptest = "0.9.4" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 1bfe24c75..106400d47 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -2,5 +2,7 @@ - [Installation](./installation.md) - [Usage](./usage.md) +- [Internal Details](./internals.md) + - [Data Model](./data-model.md) --- - [Development Notes](./development-notes.md) diff --git a/docs/src/data-model.md b/docs/src/data-model.md new file mode 100644 index 000000000..908ba5fe1 --- /dev/null +++ b/docs/src/data-model.md @@ -0,0 +1,42 @@ +# Data Model + +A client manages a single offline instance of a single user's task list. +The data model is only seen from the clients' perspective. + +## Task Database + +The task database is composed of an un-ordered collection of tasks, each keyed by a UUID. +Each task in the database has an arbitrary-sized set of key/value properties, with string values. + +Tasks are only created and modified; "deleted" tasks continue to stick around and can be modified and even un-deleted. +Tasks have an expiration time, after which they may be purged from the database. + +## Task Fields + +Each task can have any of the following fields. +Timestamps are stored as UNIX epoch timestamps, in the form of an integer expressed in decimal notation. +Note that it is possible for any field to be omitted. + +NOTE: This structure is based on https://taskwarrior.org/docs/design/task.html, but will diverge from that +model over time. + +* `status` - one of `Pending`, `Completed`, `Deleted`, `Recurring`, or `Waiting` +* `entry` (timestamp) - time that the task was created +* `description` - the one-line summary of the task +* `start` (timestamp) - if set, the task is active and this field gives the time the task was started +* `end` (timestamp) - the time at which the task was deleted or completed +* `due` (timestamp) - the time at which the task is due +* `until` (timestamp) - the time after which recurrent child tasks should not be created +* `wait` (timestamp) - the time before which this task is considered waiting and should not be shown +* `modified` (timestamp) - time that the task was last modified +* `scheduled` (timestamp) - time that the task is available to start +* `recur` - recurrence frequency +* `mask` - recurrence history +* `imask` - for children of recurring tasks, the index into the `mask` property on the parent +* `parent` - for children of recurring tasks, the uuid of the parent task +* `project` - the task's project (usually a short identifier) +* `priority` - the task's priority, one of `L`, `M`, or `H`. +* `depends` - a comma (`,`) separated list of uuids of tasks on which this task depends +* `tags` - a comma (`,`) separated list of tags for this task +* `annotation_` - an annotation for this task, with the timestamp as part of the key +* `udas` - user-defined attributes diff --git a/docs/src/development-notes.md b/docs/src/development-notes.md index 24f5d87b6..ffdbb6314 100644 --- a/docs/src/development-notes.md +++ b/docs/src/development-notes.md @@ -4,20 +4,7 @@ Goals: * Reliable concurrency - clients do not diverge * Storage O(n) with n number of tasks -# Data Model - -A client manages a single offline instance of a single user's task list. -The data model is only seen from the clients' perspective. - -## Task Database - -The task database is composed of an un-ordered collection of tasks, each keyed by a UUID. -Each task has an arbitrary-sized set of key/value properties, with string values. - -Tasks are only created, never deleted. -See below for details on how tasks can "expire" from the task database. - -## Operations +# Operations Every change to the task database is captured as an operation. Each operation has one of the forms diff --git a/docs/src/internals.md b/docs/src/internals.md new file mode 100644 index 000000000..d845d59a9 --- /dev/null +++ b/docs/src/internals.md @@ -0,0 +1,4 @@ +# Internal Details + +This section describes some of the internal details of TaskChampion. +While this section is not required to use TaskChampion, understanding some of these details may help to understand how TaskChampion behaves. diff --git a/src/errors.rs b/src/errors.rs index 314a7a0a5..8a180936f 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -5,6 +5,6 @@ pub enum Error { #[fail(display = "Task Database Error: {}", _0)] DBError(String), - #[fail(display = "TDB2 Error: {}", _0)] - TDB2Error(String), + #[fail(display = "Replica Error: {}", _0)] + ReplicaError(String), } diff --git a/src/replica.rs b/src/replica.rs index abfe2ab7e..4d4a0e96e 100644 --- a/src/replica.rs +++ b/src/replica.rs @@ -1,9 +1,11 @@ +use crate::errors::Error; use crate::operation::Operation; use crate::task::{Priority, Status, Task, TaskBuilder}; use crate::taskdb::DB; use crate::taskstorage::TaskMap; -use chrono::Utc; +use chrono::{DateTime, Utc}; use failure::Fallible; +use itertools::join; use std::collections::HashMap; use uuid::Uuid; @@ -77,7 +79,12 @@ impl Replica { status: Status, description: String, ) -> Fallible { - // TODO: check that it doesn't exist + // check that it doesn't exist; this is a convenience check, as the task + // may already exist when this Create operation is finally sync'd with + // operations from other replicas + if self.taskdb.get_task(&uuid)?.is_some() { + return Err(Error::DBError(format!("Task {} already exists", uuid)).into()); + } self.taskdb .apply(Operation::Create { uuid: uuid.clone() })?; self.update_task(uuid.clone(), "status", Some(String::from(status.as_ref())))?; @@ -90,7 +97,11 @@ impl Replica { /// Delete a task. The task must exist. pub fn delete_task(&mut self, uuid: Uuid) -> Fallible<()> { - // TODO: must verify task does exist + // check that it already exists; this is a convenience check, as the task may already exist + // when this Create operation is finally sync'd with operations from other replicas + if self.taskdb.get_task(&uuid)?.is_none() { + return Err(Error::DBError(format!("Task {} does not exist", uuid)).into()); + } self.taskdb.apply(Operation::Delete { uuid }) } @@ -121,6 +132,8 @@ impl From<&TaskMap> for Task { } } +// TODO: move this struct to crate::task, with a trait for update_task, since it is the reverse +// of TaskBuilder::set /// TaskMut allows changes to a task. It is intended for short-term use, such as changing a few /// properties, and should not be held for long periods of wall-clock time. pub struct TaskMut<'a> { @@ -150,47 +163,108 @@ impl<'a> TaskMut<'a> { Ok(()) } - /// Set the task's status - pub fn status(&mut self, status: Status) -> Fallible<()> { + fn set_string(&mut self, property: &str, value: Option) -> Fallible<()> { + self.lastmod()?; + self.replica.update_task(self.uuid.clone(), property, value) + } + + fn set_timestamp(&mut self, property: &str, value: Option>) -> Fallible<()> { self.lastmod()?; self.replica.update_task( self.uuid.clone(), - "status", - Some(String::from(status.as_ref())), + property, + value.map(|v| format!("{}", v.timestamp())), ) } - // TODO: description - // TODO: start - // TODO: end - // TODO: due - // TODO: until - // TODO: wait - // TODO: scheduled - // TODO: recur - // TODO: mask - // TODO: imask - // TODO: parent + /// Set the task's status + pub fn status(&mut self, status: Status) -> Fallible<()> { + self.set_string("status", Some(String::from(status.as_ref()))) + } + + /// Set the task's description + pub fn description(&mut self, description: String) -> Fallible<()> { + self.set_string("description", Some(description)) + } + + /// Set the task's start time + pub fn start(&mut self, time: Option>) -> Fallible<()> { + self.set_timestamp("start", time) + } + + /// Set the task's end time + pub fn end(&mut self, time: Option>) -> Fallible<()> { + self.set_timestamp("end", time) + } + + /// Set the task's due time + pub fn due(&mut self, time: Option>) -> Fallible<()> { + self.set_timestamp("due", time) + } + + /// Set the task's until time + pub fn until(&mut self, time: Option>) -> Fallible<()> { + self.set_timestamp("until", time) + } + + /// Set the task's wait time + pub fn wait(&mut self, time: Option>) -> Fallible<()> { + self.set_timestamp("wait", time) + } + + /// Set the task's scheduled time + pub fn scheduled(&mut self, time: Option>) -> Fallible<()> { + self.set_timestamp("scheduled", time) + } + + /// Set the task's recur value + pub fn recur(&mut self, recur: Option) -> Fallible<()> { + self.set_string("recur", recur) + } + + /// Set the task's mask value + pub fn mask(&mut self, mask: Option) -> Fallible<()> { + self.set_string("mask", mask) + } + + /// Set the task's imask value + pub fn imask(&mut self, imask: Option) -> Fallible<()> { + self.set_string("imask", imask.map(|v| format!("{}", v))) + } + + /// Set the task's parent task + pub fn parent(&mut self, parent: Option) -> Fallible<()> { + self.set_string("parent", parent.map(|v| format!("{}", v))) + } /// Set the task's project - pub fn project(&mut self, project: String) -> Fallible<()> { - self.lastmod()?; - self.replica - .update_task(self.uuid.clone(), "project", Some(project)) + pub fn project(&mut self, project: Option) -> Fallible<()> { + self.set_string("project", project) } /// Set the task's priority - pub fn priority(&mut self, priority: Priority) -> Fallible<()> { - self.lastmod()?; - self.replica.update_task( - self.uuid.clone(), - "priority", - Some(String::from(priority.as_ref())), + pub fn priority(&mut self, priority: Option) -> Fallible<()> { + self.set_string("priority", priority.map(|v| String::from(v.as_ref()))) + } + + /// Set the task's depends; note that this completely replaces the list of tasks on which this + /// one depends. + pub fn depends(&mut self, depends: Vec) -> Fallible<()> { + self.set_string( + "depends", + if depends.len() > 0 { + Some(join(depends.iter(), ",")) + } else { + None + }, ) } - // TODO: depends - // TODO: tags + /// Set the task's tags; note that this completely replaces the list of tags + pub fn tags(&mut self, tags: Vec) -> Fallible<()> { + self.set_string("tags", Some(join(tags.iter(), ","))) + } + // TODO: annotations // TODO: udas } @@ -209,7 +283,7 @@ mod tests { let mut tm = rep .new_task(uuid.clone(), Status::Pending, "a task".into()) .unwrap(); - tm.priority(Priority::L).unwrap(); + tm.priority(Some(Priority::L)).unwrap(); let t = rep.get_task(&uuid).unwrap().unwrap(); assert_eq!(t.description, String::from("a task")); @@ -237,12 +311,18 @@ mod tests { rep.new_task(uuid.clone(), Status::Pending, "another task".into()) .unwrap(); - let mut tm = rep.get_task_mut(&uuid).unwrap().unwrap(); - tm.priority(Priority::L).unwrap(); - tm.project("work".into()).unwrap(); - let t = rep.get_task(&uuid).unwrap().unwrap(); assert_eq!(t.description, String::from("another task")); + + let mut tm = rep.get_task_mut(&uuid).unwrap().unwrap(); + tm.status(Status::Completed).unwrap(); + tm.description("another task, updated".into()).unwrap(); + tm.priority(Some(Priority::L)).unwrap(); + tm.project(Some("work".into())).unwrap(); + + let t = rep.get_task(&uuid).unwrap().unwrap(); + assert_eq!(t.status, Status::Completed); + assert_eq!(t.description, String::from("another task, updated")); assert_eq!(t.project, Some("work".into())); } diff --git a/src/task/taskbuilder.rs b/src/task/taskbuilder.rs index 53d805cf0..6d5bc0d92 100644 --- a/src/task/taskbuilder.rs +++ b/src/task/taskbuilder.rs @@ -131,8 +131,6 @@ impl TaskBuilder { annotations: self.annotations, udas: self.udas, } - - // TODO: check validity per https://taskwarrior.org/docs/design/task.html } }