mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-06-26 10:54:26 +02:00
Reorganize handling of task data
This abandons field-by-field compatibility with the TaskWarrior TDB2 format, which wasn't a sustainable strategy anyway. Instead, tasks are represented as a TaskMap with custom key formats. In this commit, there are only a few allowed keys, with room to grow. Replica returns convenience wrappers Task (read-only) and TaskMut (read-write) with getters and setters to make modifying tasks easier.
This commit is contained in:
parent
c2c2a00ed5
commit
634aaadb73
10 changed files with 276 additions and 490 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -131,12 +131,6 @@ 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"
|
||||
|
@ -182,15 +176,6 @@ 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"
|
||||
|
@ -622,7 +607,6 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"failure",
|
||||
"itertools",
|
||||
"kv",
|
||||
"lmdb-rkv",
|
||||
"proptest",
|
||||
|
|
|
@ -13,7 +13,6 @@ 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"
|
||||
|
|
|
@ -6,5 +6,6 @@
|
|||
- [Data Model](./data-model.md)
|
||||
- [Replica Storage](./storage.md)
|
||||
- [Task Database](./taskdb.md)
|
||||
- [Tasks](./tasks.md)
|
||||
- [Synchronization](./sync.md)
|
||||
- [Planned Functionality](./plans.md)
|
||||
|
|
|
@ -7,47 +7,15 @@ The storage is transaction-protected, with the expectation of a serializable iso
|
|||
The storage contains the following information:
|
||||
|
||||
- `tasks`: a set of tasks, indexed by UUID
|
||||
- `base_version`: the number of the last version sync'd from the server
|
||||
- `base_version`: the number of the last version sync'd from the server (a single integer)
|
||||
- `operations`: all operations performed since base_version
|
||||
- `working_set`: a mapping from integer -> UUID, used to keep stable small-integer indexes into the tasks for users' convenience. This data is not synchronized with the server and does not affect any consistency guarantees.
|
||||
|
||||
## Tasks
|
||||
|
||||
The tasks are stored as an un-ordered collection, keyed by task 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, in task storage, 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_<timestamp>` - an annotation for this task, with the timestamp as part of the key
|
||||
* `udas` - user-defined attributes
|
||||
Each task in the database has represented by a key-value map.
|
||||
See [Tasks](./tasks.md) for details on the content of that map.
|
||||
|
||||
## Operations
|
||||
|
||||
|
|
38
docs/src/tasks.md
Normal file
38
docs/src/tasks.md
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Tasks
|
||||
|
||||
Tasks are stored internally as a key/value map with string keys and values.
|
||||
All fields are optional: the `Create` operation creates an empty task.
|
||||
Display layers should apply appropriate defaults where necessary.
|
||||
|
||||
## Atomicity
|
||||
|
||||
The synchronization process does not support read-modify-write operations.
|
||||
For example, suppose tags are updated by reading a list of tags, adding a tag, and writing the result back.
|
||||
This would be captured as an `Update` operation containing the amended list of tags.
|
||||
Suppose two such `Update` operations are made in different replicas and must be reconciled:
|
||||
* `Update("d394be59-60e6-499e-b7e7-ca0142648409", "tags", "oldtag,newtag1", "2020-11-23T14:21:22Z")`
|
||||
* `Update("d394be59-60e6-499e-b7e7-ca0142648409", "tags", "oldtag,newtag2", "2020-11-23T15:08:57Z")`
|
||||
|
||||
The result of this reconciliation will be `oldtag,newtag2`, while the user almost certainly intended `oldtag,newtag1,newtag2`.
|
||||
|
||||
The key names given below avoid this issue, allowing user updates such as adding a tag or deleting a dependency to be represented in a single `Update` operation.
|
||||
|
||||
## Representations
|
||||
|
||||
Integers are stored in decimal notation.
|
||||
|
||||
Timestamps are stored as UNIX epoch timestamps, in the form of an integer.
|
||||
|
||||
## Keys
|
||||
|
||||
The following keys, and key formats, are defined:
|
||||
|
||||
* `status` - one of `P` for a pending task (the default), `C` for completed or `D` for deleted
|
||||
* `description` - the one-line summary of the task
|
||||
* `modified` - the time of the last modification of this task
|
||||
|
||||
The following are not yet implemented:
|
||||
|
||||
* `dep.<uuid>` - indicates this task depends on `<uuid>` (value is an empty string)
|
||||
* `tag.<tag>` - indicates this task has tag `<tag>` (value is an empty string)
|
||||
* `annotation.<timestamp>` - value is an annotation created at the given time
|
|
@ -47,8 +47,8 @@ fn main() {
|
|||
("pending", _) => {
|
||||
let working_set = replica.working_set().unwrap();
|
||||
for i in 1..working_set.len() {
|
||||
if let Some((ref uuid, ref task)) = working_set[i] {
|
||||
println!("{}: {} - {:?}", i, uuid, task);
|
||||
if let Some(ref task) = working_set[i] {
|
||||
println!("{}: {} - {:?}", i, task.get_uuid(), task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
299
src/replica.rs
299
src/replica.rs
|
@ -1,11 +1,10 @@
|
|||
use crate::errors::Error;
|
||||
use crate::operation::Operation;
|
||||
use crate::task::{Priority, Status, Task, TaskBuilder};
|
||||
use crate::task::{Status, Task};
|
||||
use crate::taskdb::DB;
|
||||
use crate::taskstorage::TaskMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono::Utc;
|
||||
use failure::Fallible;
|
||||
use itertools::join;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
@ -22,7 +21,12 @@ impl Replica {
|
|||
/// Update an existing task. If the value is Some, the property is added or updated. If the
|
||||
/// value is None, the property is deleted. It is not an error to delete a nonexistent
|
||||
/// property.
|
||||
fn update_task<S1, S2>(&mut self, uuid: Uuid, property: S1, value: Option<S2>) -> Fallible<()>
|
||||
pub(crate) fn update_task<S1, S2>(
|
||||
&mut self,
|
||||
uuid: Uuid,
|
||||
property: S1,
|
||||
value: Option<S2>,
|
||||
) -> Fallible<()>
|
||||
where
|
||||
S1: Into<String>,
|
||||
S2: Into<String>,
|
||||
|
@ -35,29 +39,18 @@ impl Replica {
|
|||
})
|
||||
}
|
||||
|
||||
/// Return true if this status string is such that the task should be included in
|
||||
/// the working set.
|
||||
fn is_working_set_status(status: Option<&String>) -> bool {
|
||||
if let Some(status) = status {
|
||||
status == "pending"
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the given uuid to the working set, returning its index.
|
||||
fn add_to_working_set(&mut self, uuid: &Uuid) -> Fallible<u64> {
|
||||
pub(crate) fn add_to_working_set(&mut self, uuid: &Uuid) -> Fallible<u64> {
|
||||
self.taskdb.add_to_working_set(uuid)
|
||||
}
|
||||
|
||||
/// Get all tasks represented as a map keyed by UUID
|
||||
pub fn all_tasks<'a>(&'a mut self) -> Fallible<HashMap<Uuid, Task>> {
|
||||
Ok(self
|
||||
.taskdb
|
||||
.all_tasks()?
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.into()))
|
||||
.collect())
|
||||
let mut res = HashMap::new();
|
||||
for (uuid, tm) in self.taskdb.all_tasks()?.drain(..) {
|
||||
res.insert(uuid.clone(), Task::new(uuid.clone(), tm));
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Get the UUIDs of all tasks
|
||||
|
@ -67,13 +60,13 @@ impl Replica {
|
|||
|
||||
/// Get the "working set" for this replica -- the set of pending tasks, as indexed by small
|
||||
/// integers
|
||||
pub fn working_set(&mut self) -> Fallible<Vec<Option<(Uuid, Task)>>> {
|
||||
pub fn working_set(&mut self) -> Fallible<Vec<Option<Task>>> {
|
||||
let working_set = self.taskdb.working_set()?;
|
||||
let mut res = Vec::with_capacity(working_set.len());
|
||||
for i in 0..working_set.len() {
|
||||
res.push(match working_set[i] {
|
||||
Some(u) => match self.taskdb.get_task(&u)? {
|
||||
Some(task) => Some((u, (&task).into())),
|
||||
Some(tm) => Some(Task::new(u, tm)),
|
||||
None => None,
|
||||
},
|
||||
None => None,
|
||||
|
@ -84,7 +77,10 @@ impl Replica {
|
|||
|
||||
/// Get an existing task by its UUID
|
||||
pub fn get_task(&mut self, uuid: &Uuid) -> Fallible<Option<Task>> {
|
||||
Ok(self.taskdb.get_task(&uuid)?.map(|t| (&t).into()))
|
||||
Ok(self
|
||||
.taskdb
|
||||
.get_task(uuid)?
|
||||
.map(move |tm| Task::new(uuid.clone(), tm)))
|
||||
}
|
||||
|
||||
/// Get an existing task by its working set index
|
||||
|
@ -92,19 +88,17 @@ impl Replica {
|
|||
let working_set = self.taskdb.working_set()?;
|
||||
if (i as usize) < working_set.len() {
|
||||
if let Some(uuid) = working_set[i as usize] {
|
||||
return Ok(self.taskdb.get_task(&uuid)?.map(|t| (&t).into()));
|
||||
return Ok(self
|
||||
.taskdb
|
||||
.get_task(&uuid)?
|
||||
.map(move |tm| Task::new(uuid, tm)));
|
||||
}
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
/// Create a new task. The task must not already exist.
|
||||
pub fn new_task(
|
||||
&mut self,
|
||||
uuid: Uuid,
|
||||
status: Status,
|
||||
description: String,
|
||||
) -> Fallible<TaskMut> {
|
||||
pub fn new_task(&mut self, uuid: Uuid, status: Status, description: String) -> Fallible<Task> {
|
||||
// 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
|
||||
|
@ -113,15 +107,14 @@ impl Replica {
|
|||
}
|
||||
self.taskdb
|
||||
.apply(Operation::Create { uuid: uuid.clone() })?;
|
||||
self.update_task(uuid.clone(), "status", Some(String::from(status.as_ref())))?;
|
||||
self.update_task(uuid.clone(), "description", Some(description))?;
|
||||
let now = format!("{}", Utc::now().timestamp());
|
||||
self.update_task(uuid.clone(), "entry", Some(now.clone()))?;
|
||||
self.update_task(uuid.clone(), "modified", Some(now))?;
|
||||
Ok(TaskMut::new(self, uuid))
|
||||
let mut task = Task::new(uuid, TaskMap::new()).into_mut(self);
|
||||
task.set_description(description)?;
|
||||
task.set_status(status)?;
|
||||
Ok(task.into_immut())
|
||||
}
|
||||
|
||||
/// Delete a task. The task must exist.
|
||||
/// Delete a task. The task must exist. Note that this is different from setting status to
|
||||
/// Deleted; this is the final purge of the task.
|
||||
pub fn delete_task(&mut self, uuid: Uuid) -> Fallible<()> {
|
||||
// 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
|
||||
|
@ -131,196 +124,61 @@ impl Replica {
|
|||
self.taskdb.apply(Operation::Delete { uuid })
|
||||
}
|
||||
|
||||
/// Get an existing task by its UUID, suitable for modification
|
||||
pub fn get_task_mut<'a>(&'a mut self, uuid: &Uuid) -> Fallible<Option<TaskMut<'a>>> {
|
||||
// the call to get_task is to ensure the task exists locally
|
||||
Ok(self
|
||||
.taskdb
|
||||
.get_task(&uuid)?
|
||||
.map(move |_| TaskMut::new(self, uuid.clone())))
|
||||
}
|
||||
|
||||
/// Perform "garbage collection" on this replica. In particular, this renumbers the working
|
||||
/// set to contain only pending tasks.
|
||||
pub fn gc(&mut self) -> Fallible<()> {
|
||||
let pending = String::from(Status::Pending.to_taskmap());
|
||||
self.taskdb
|
||||
.rebuild_working_set(|t| Replica::is_working_set_status(t.get("status")))?;
|
||||
.rebuild_working_set(|t| t.get("status") == Some(&pending))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&TaskMap> for Task {
|
||||
fn from(taskmap: &TaskMap) -> Task {
|
||||
let mut bldr = TaskBuilder::new();
|
||||
for (k, v) in taskmap.iter() {
|
||||
bldr = bldr.set(k, v.into());
|
||||
}
|
||||
bldr.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// 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> {
|
||||
replica: &'a mut Replica,
|
||||
uuid: Uuid,
|
||||
// if true, then this TaskMut has already updated the `modified` property and need not do so
|
||||
// again.
|
||||
updated_modified: bool,
|
||||
}
|
||||
|
||||
impl<'a> TaskMut<'a> {
|
||||
fn new(replica: &'a mut Replica, uuid: Uuid) -> TaskMut {
|
||||
TaskMut {
|
||||
replica,
|
||||
uuid,
|
||||
updated_modified: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn lastmod(&mut self) -> Fallible<()> {
|
||||
if !self.updated_modified {
|
||||
let now = format!("{}", Utc::now().timestamp());
|
||||
self.replica
|
||||
.update_task(self.uuid.clone(), "modified", Some(now))?;
|
||||
self.updated_modified = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_string(&mut self, property: &str, value: Option<String>) -> Fallible<()> {
|
||||
self.lastmod()?;
|
||||
self.replica.update_task(self.uuid.clone(), property, value)
|
||||
}
|
||||
|
||||
fn set_timestamp(&mut self, property: &str, value: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.lastmod()?;
|
||||
self.replica.update_task(
|
||||
self.uuid.clone(),
|
||||
property,
|
||||
value.map(|v| format!("{}", v.timestamp())),
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the task's status. This also adds the task to the working set if the
|
||||
/// new status puts it in that set.
|
||||
pub fn status(&mut self, status: Status) -> Fallible<()> {
|
||||
let status = String::from(status.as_ref());
|
||||
if Replica::is_working_set_status(Some(&status)) {
|
||||
self.replica.add_to_working_set(&self.uuid)?;
|
||||
}
|
||||
self.set_string("status", Some(status))
|
||||
}
|
||||
|
||||
/// 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<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.set_timestamp("start", time)
|
||||
}
|
||||
|
||||
/// Set the task's end time
|
||||
pub fn end(&mut self, time: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.set_timestamp("end", time)
|
||||
}
|
||||
|
||||
/// Set the task's due time
|
||||
pub fn due(&mut self, time: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.set_timestamp("due", time)
|
||||
}
|
||||
|
||||
/// Set the task's until time
|
||||
pub fn until(&mut self, time: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.set_timestamp("until", time)
|
||||
}
|
||||
|
||||
/// Set the task's wait time
|
||||
pub fn wait(&mut self, time: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.set_timestamp("wait", time)
|
||||
}
|
||||
|
||||
/// Set the task's scheduled time
|
||||
pub fn scheduled(&mut self, time: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.set_timestamp("scheduled", time)
|
||||
}
|
||||
|
||||
/// Set the task's recur value
|
||||
pub fn recur(&mut self, recur: Option<String>) -> Fallible<()> {
|
||||
self.set_string("recur", recur)
|
||||
}
|
||||
|
||||
/// Set the task's mask value
|
||||
pub fn mask(&mut self, mask: Option<String>) -> Fallible<()> {
|
||||
self.set_string("mask", mask)
|
||||
}
|
||||
|
||||
/// Set the task's imask value
|
||||
pub fn imask(&mut self, imask: Option<u64>) -> Fallible<()> {
|
||||
self.set_string("imask", imask.map(|v| format!("{}", v)))
|
||||
}
|
||||
|
||||
/// Set the task's parent task
|
||||
pub fn parent(&mut self, parent: Option<Uuid>) -> Fallible<()> {
|
||||
self.set_string("parent", parent.map(|v| format!("{}", v)))
|
||||
}
|
||||
|
||||
/// Set the task's project
|
||||
pub fn project(&mut self, project: Option<String>) -> Fallible<()> {
|
||||
self.set_string("project", project)
|
||||
}
|
||||
|
||||
/// Set the task's priority
|
||||
pub fn priority(&mut self, priority: Option<Priority>) -> 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<Uuid>) -> Fallible<()> {
|
||||
self.set_string(
|
||||
"depends",
|
||||
if depends.len() > 0 {
|
||||
Some(join(depends.iter(), ","))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Set the task's tags; note that this completely replaces the list of tags
|
||||
pub fn tags(&mut self, tags: Vec<Uuid>) -> Fallible<()> {
|
||||
self.set_string("tags", Some(join(tags.iter(), ",")))
|
||||
}
|
||||
|
||||
// TODO: annotations
|
||||
// TODO: udas
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::task::Status;
|
||||
use crate::taskdb::DB;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn new_task_and_modify() {
|
||||
fn new_task() {
|
||||
let mut rep = Replica::new(DB::new_inmemory().into());
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
let mut tm = rep
|
||||
let t = rep
|
||||
.new_task(uuid.clone(), Status::Pending, "a task".into())
|
||||
.unwrap();
|
||||
tm.priority(Some(Priority::L)).unwrap();
|
||||
assert_eq!(t.get_description(), String::from("a task"));
|
||||
assert_eq!(t.get_status(), Status::Pending);
|
||||
assert!(t.get_modified().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_task() {
|
||||
let mut rep = Replica::new(DB::new_inmemory().into());
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
let t = rep
|
||||
.new_task(uuid.clone(), Status::Pending, "a task".into())
|
||||
.unwrap();
|
||||
|
||||
let mut t = t.into_mut(&mut rep);
|
||||
t.set_description(String::from("past tense")).unwrap();
|
||||
t.set_status(Status::Completed).unwrap();
|
||||
// check that values have changed on the TaskMut
|
||||
assert_eq!(t.get_description(), "past tense");
|
||||
assert_eq!(t.get_status(), Status::Completed);
|
||||
|
||||
// check that values have changed after into_immut
|
||||
let t = t.into_immut();
|
||||
assert_eq!(t.get_description(), "past tense");
|
||||
assert_eq!(t.get_status(), Status::Completed);
|
||||
|
||||
// check tha values have changed in storage, too
|
||||
let t = rep.get_task(&uuid).unwrap().unwrap();
|
||||
assert_eq!(t.description, String::from("a task"));
|
||||
assert_eq!(t.status, Status::Pending);
|
||||
assert_eq!(t.priority, Some(Priority::L));
|
||||
assert_eq!(t.get_description(), "past tense");
|
||||
assert_eq!(t.get_status(), Status::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -344,34 +202,33 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let t = rep.get_task(&uuid).unwrap().unwrap();
|
||||
assert_eq!(t.description, String::from("another task"));
|
||||
assert_eq!(t.get_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 mut t = t.into_mut(&mut rep);
|
||||
t.set_status(Status::Deleted).unwrap();
|
||||
t.set_description("gone".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()));
|
||||
assert_eq!(t.get_status(), Status::Deleted);
|
||||
assert_eq!(t.get_description(), "gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_pending_adds_to_working_set() {
|
||||
fn new_pending_adds_to_working_set() {
|
||||
let mut rep = Replica::new(DB::new_inmemory().into());
|
||||
let uuid = Uuid::new_v4();
|
||||
|
||||
rep.new_task(uuid.clone(), Status::Pending, "to-be-pending".into())
|
||||
.unwrap();
|
||||
|
||||
let mut tm = rep.get_task_mut(&uuid).unwrap().unwrap();
|
||||
tm.status(Status::Pending).unwrap();
|
||||
|
||||
let t = rep.get_working_set_task(1).unwrap().unwrap();
|
||||
assert_eq!(t.status, Status::Pending);
|
||||
assert_eq!(t.description, String::from("to-be-pending"));
|
||||
assert_eq!(t.get_status(), Status::Pending);
|
||||
assert_eq!(t.get_description(), "to-be-pending");
|
||||
|
||||
let ws = rep.working_set().unwrap();
|
||||
assert_eq!(ws.len(), 2);
|
||||
assert!(ws[0].is_none());
|
||||
assert_eq!(ws[1].as_ref().unwrap().get_uuid(), &uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
mod task;
|
||||
mod taskbuilder;
|
||||
|
||||
pub use self::task::Priority::*;
|
||||
pub use self::task::Status::*;
|
||||
pub use self::task::{Annotation, Priority, Status, Task, Timestamp};
|
||||
pub use self::taskbuilder::TaskBuilder;
|
||||
|
|
198
src/task/task.rs
198
src/task/task.rs
|
@ -1,5 +1,7 @@
|
|||
use crate::replica::Replica;
|
||||
use crate::taskstorage::TaskMap;
|
||||
use chrono::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use failure::Fallible;
|
||||
use std::convert::TryFrom;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
@ -39,33 +41,26 @@ pub enum Status {
|
|||
Pending,
|
||||
Completed,
|
||||
Deleted,
|
||||
Recurring,
|
||||
Waiting,
|
||||
}
|
||||
|
||||
impl TryFrom<&str> for Status {
|
||||
type Error = failure::Error;
|
||||
|
||||
fn try_from(s: &str) -> Result<Self, Self::Error> {
|
||||
impl Status {
|
||||
/// Get a Status from the 1-character value in a TaskMap,
|
||||
/// defaulting to Pending
|
||||
pub(crate) fn from_taskmap(s: &str) -> Status {
|
||||
match s {
|
||||
"pending" => Ok(Status::Pending),
|
||||
"completed" => Ok(Status::Completed),
|
||||
"deleted" => Ok(Status::Deleted),
|
||||
"recurring" => Ok(Status::Recurring),
|
||||
"waiting" => Ok(Status::Waiting),
|
||||
_ => Err(format_err!("invalid status {}", s)),
|
||||
}
|
||||
"P" => Status::Pending,
|
||||
"C" => Status::Completed,
|
||||
"D" => Status::Deleted,
|
||||
_ => Status::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Status {
|
||||
fn as_ref(&self) -> &str {
|
||||
/// Get the 1-character value for this status to use in the TaskMap.
|
||||
pub(crate) fn to_taskmap(&self) -> &str {
|
||||
match self {
|
||||
Status::Pending => "pending",
|
||||
Status::Completed => "completed",
|
||||
Status::Deleted => "deleted",
|
||||
Status::Recurring => "recurring",
|
||||
Status::Waiting => "waiting",
|
||||
Status::Pending => "P",
|
||||
Status::Completed => "C",
|
||||
Status::Deleted => "D",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,30 +71,145 @@ pub struct Annotation {
|
|||
pub description: String,
|
||||
}
|
||||
|
||||
/// A task, the fundamental business object of this tool.
|
||||
/// A task, as publicly exposed by this crate.
|
||||
///
|
||||
/// This structure is based on https://taskwarrior.org/docs/design/task.html with the
|
||||
/// exception that the uuid property is omitted.
|
||||
/// Note that Task objects represent a snapshot of the task at a moment in time, and are not
|
||||
/// protected by the atomicity of the backend storage. Concurrent modifications are safe,
|
||||
/// but a Task that is cached for more than a few seconds may cause the user to see stale
|
||||
/// data. Fetch, use, and drop Tasks quickly.
|
||||
///
|
||||
/// This struct contains only getters for various values on the task. The `into_mut` method returns
|
||||
/// a TaskMut which can be used to modify the task.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Task {
|
||||
pub status: Status,
|
||||
pub entry: Timestamp,
|
||||
pub description: String,
|
||||
pub start: Option<Timestamp>,
|
||||
pub end: Option<Timestamp>,
|
||||
pub due: Option<Timestamp>,
|
||||
pub until: Option<Timestamp>,
|
||||
pub wait: Option<Timestamp>,
|
||||
pub modified: Timestamp,
|
||||
pub scheduled: Option<Timestamp>,
|
||||
pub recur: Option<String>,
|
||||
pub mask: Option<String>,
|
||||
pub imask: Option<u64>,
|
||||
pub parent: Option<Uuid>,
|
||||
pub project: Option<String>,
|
||||
pub priority: Option<Priority>,
|
||||
pub depends: Vec<Uuid>,
|
||||
pub tags: Vec<String>,
|
||||
pub annotations: Vec<Annotation>,
|
||||
pub udas: HashMap<String, String>,
|
||||
uuid: Uuid,
|
||||
taskmap: TaskMap,
|
||||
}
|
||||
|
||||
/// A mutable task, with setter methods. Calling a setter will update the Replica, as well as the
|
||||
/// included Task.
|
||||
pub struct TaskMut<'r> {
|
||||
task: Task,
|
||||
replica: &'r mut Replica,
|
||||
updated_modified: bool,
|
||||
}
|
||||
|
||||
impl Task {
|
||||
pub(crate) fn new(uuid: Uuid, taskmap: TaskMap) -> Task {
|
||||
Task { uuid, taskmap }
|
||||
}
|
||||
|
||||
pub fn get_uuid(&self) -> &Uuid {
|
||||
&self.uuid
|
||||
}
|
||||
|
||||
/// Prepare to mutate this task, requiring a mutable Replica
|
||||
/// in order to update the data it contains.
|
||||
pub fn into_mut(self, replica: &mut Replica) -> TaskMut {
|
||||
TaskMut {
|
||||
task: self,
|
||||
replica: replica,
|
||||
updated_modified: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_status(&self) -> Status {
|
||||
self.taskmap
|
||||
.get("status")
|
||||
.map(|s| Status::from_taskmap(s))
|
||||
.unwrap_or(Status::Pending)
|
||||
}
|
||||
|
||||
pub fn get_description(&self) -> &str {
|
||||
self.taskmap
|
||||
.get("description")
|
||||
.map(|s| s.as_ref())
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn get_modified(&self) -> Option<DateTime<Utc>> {
|
||||
self.get_timestamp("modified")
|
||||
}
|
||||
|
||||
// -- utility functions
|
||||
|
||||
pub fn get_timestamp(&self, property: &str) -> Option<DateTime<Utc>> {
|
||||
if let Some(ts) = self.taskmap.get(property) {
|
||||
if let Ok(ts) = ts.parse() {
|
||||
return Some(Utc.timestamp(ts, 0));
|
||||
}
|
||||
// if the value does not parse as an integer, default to None
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> TaskMut<'r> {
|
||||
/// Get the immutable task
|
||||
pub fn into_immut(self) -> Task {
|
||||
self.task
|
||||
}
|
||||
|
||||
/// Set the task's status. This also adds the task to the working set if the
|
||||
/// new status puts it in that set.
|
||||
pub fn set_status(&mut self, status: Status) -> Fallible<()> {
|
||||
if status == Status::Pending {
|
||||
let uuid = self.uuid.clone();
|
||||
self.replica.add_to_working_set(&uuid)?;
|
||||
}
|
||||
self.set_string("status", Some(String::from(status.to_taskmap())))
|
||||
}
|
||||
|
||||
/// Set the task's description
|
||||
pub fn set_description(&mut self, description: String) -> Fallible<()> {
|
||||
self.set_string("description", Some(description))
|
||||
}
|
||||
|
||||
/// Set the task's description
|
||||
pub fn set_modified(&mut self, modified: DateTime<Utc>) -> Fallible<()> {
|
||||
self.set_timestamp("modified", Some(modified))
|
||||
}
|
||||
|
||||
// -- utility functions
|
||||
|
||||
fn lastmod(&mut self) -> Fallible<()> {
|
||||
if !self.updated_modified {
|
||||
let now = format!("{}", Utc::now().timestamp());
|
||||
self.replica
|
||||
.update_task(self.task.uuid.clone(), "modified", Some(now.clone()))?;
|
||||
self.task.taskmap.insert(String::from("modified"), now);
|
||||
self.updated_modified = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_string(&mut self, property: &str, value: Option<String>) -> Fallible<()> {
|
||||
self.lastmod()?;
|
||||
self.replica
|
||||
.update_task(self.task.uuid.clone(), property, value.as_ref())?;
|
||||
|
||||
if let Some(v) = value {
|
||||
self.task.taskmap.insert(property.to_string(), v);
|
||||
} else {
|
||||
self.task.taskmap.remove(property);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_timestamp(&mut self, property: &str, value: Option<DateTime<Utc>>) -> Fallible<()> {
|
||||
self.lastmod()?;
|
||||
self.replica.update_task(
|
||||
self.task.uuid.clone(),
|
||||
property,
|
||||
value.map(|v| format!("{}", v.timestamp())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> std::ops::Deref for TaskMut<'r> {
|
||||
type Target = Task;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.task
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,169 +0,0 @@
|
|||
use crate::task::{Annotation, Priority, Status, Task, Timestamp};
|
||||
use chrono::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::str;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TaskBuilder {
|
||||
status: Option<Status>,
|
||||
entry: Option<Timestamp>,
|
||||
description: Option<String>,
|
||||
start: Option<Timestamp>,
|
||||
end: Option<Timestamp>,
|
||||
due: Option<Timestamp>,
|
||||
until: Option<Timestamp>,
|
||||
wait: Option<Timestamp>,
|
||||
modified: Option<Timestamp>,
|
||||
scheduled: Option<Timestamp>,
|
||||
recur: Option<String>,
|
||||
mask: Option<String>,
|
||||
imask: Option<u64>,
|
||||
parent: Option<Uuid>,
|
||||
project: Option<String>,
|
||||
priority: Option<Priority>,
|
||||
depends: Vec<Uuid>,
|
||||
tags: Vec<String>,
|
||||
annotations: Vec<Annotation>,
|
||||
udas: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Parse an "integer", allowing for occasional integers with trailing decimal zeroes
|
||||
fn parse_int<T>(value: &str) -> Result<T, <T as str::FromStr>::Err>
|
||||
where
|
||||
T: str::FromStr,
|
||||
{
|
||||
// some integers are rendered with following decimal zeroes
|
||||
if let Some(i) = value.find('.') {
|
||||
let mut nonzero = false;
|
||||
for c in value[i + 1..].chars() {
|
||||
if c != '0' {
|
||||
nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !nonzero {
|
||||
return value[..i].parse();
|
||||
}
|
||||
}
|
||||
value.parse()
|
||||
}
|
||||
|
||||
/// Parse a UNIX timestamp into a UTC DateTime
|
||||
fn parse_timestamp(value: &str) -> Result<Timestamp, <i64 as str::FromStr>::Err> {
|
||||
Ok(Utc.timestamp(parse_int::<i64>(value)?, 0))
|
||||
}
|
||||
|
||||
/// Parse depends, as a list of ,-separated UUIDs
|
||||
fn parse_depends(value: &str) -> Result<Vec<Uuid>, uuid::Error> {
|
||||
value.split(',').map(|s| Uuid::parse_str(s)).collect()
|
||||
}
|
||||
|
||||
/// Parse tags, as a list of ,-separated strings
|
||||
fn parse_tags(value: &str) -> Vec<String> {
|
||||
value.split(',').map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
impl TaskBuilder {
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
// TODO: fallible
|
||||
pub fn set(mut self, name: &str, value: String) -> Self {
|
||||
const ANNOTATION_PREFIX: &str = "annotation_";
|
||||
if name.starts_with(ANNOTATION_PREFIX) {
|
||||
let entry = parse_timestamp(&name[ANNOTATION_PREFIX.len()..]).unwrap();
|
||||
// TODO: sort by entry time
|
||||
self.annotations.push(Annotation {
|
||||
entry,
|
||||
description: value.to_string(),
|
||||
});
|
||||
return self;
|
||||
}
|
||||
match name {
|
||||
"status" => self.status = Some(Status::try_from(value.as_ref()).unwrap()),
|
||||
"entry" => self.entry = Some(parse_timestamp(&value).unwrap()),
|
||||
"description" => self.description = Some(value),
|
||||
"start" => self.start = Some(parse_timestamp(&value).unwrap()),
|
||||
"end" => self.end = Some(parse_timestamp(&value).unwrap()),
|
||||
"due" => self.due = Some(parse_timestamp(&value).unwrap()),
|
||||
"until" => self.until = Some(parse_timestamp(&value).unwrap()),
|
||||
"wait" => self.wait = Some(parse_timestamp(&value).unwrap()),
|
||||
"modified" => self.modified = Some(parse_timestamp(&value).unwrap()),
|
||||
"scheduled" => self.scheduled = Some(parse_timestamp(&value).unwrap()),
|
||||
"recur" => self.recur = Some(value),
|
||||
"mask" => self.mask = Some(value),
|
||||
"imask" => self.imask = Some(parse_int::<u64>(&value).unwrap()),
|
||||
"parent" => self.parent = Some(Uuid::parse_str(&value).unwrap()),
|
||||
"project" => self.project = Some(value),
|
||||
"priority" => self.priority = Some(Priority::try_from(value.as_ref()).unwrap()),
|
||||
"depends" => self.depends = parse_depends(&value).unwrap(),
|
||||
"tags" => self.tags = parse_tags(&value),
|
||||
_ => {
|
||||
self.udas.insert(name.to_string(), value);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Task {
|
||||
Task {
|
||||
status: self.status.unwrap(),
|
||||
description: self.description.unwrap(),
|
||||
entry: self.entry.unwrap(),
|
||||
start: self.start,
|
||||
end: self.end,
|
||||
due: self.due,
|
||||
until: self.until,
|
||||
wait: self.wait,
|
||||
modified: self.modified.unwrap(),
|
||||
scheduled: self.scheduled,
|
||||
recur: self.recur,
|
||||
mask: self.mask,
|
||||
imask: self.imask,
|
||||
parent: self.parent,
|
||||
project: self.project,
|
||||
priority: self.priority,
|
||||
depends: self.depends,
|
||||
tags: self.tags,
|
||||
annotations: self.annotations,
|
||||
udas: self.udas,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::{parse_depends, parse_int};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn test_parse_int() {
|
||||
assert_eq!(parse_int::<u8>("123").unwrap(), 123u8);
|
||||
assert_eq!(parse_int::<u32>("123000000").unwrap(), 123000000u32);
|
||||
assert_eq!(parse_int::<i32>("-123000000").unwrap(), -123000000i32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_int_decimals() {
|
||||
assert_eq!(parse_int::<u8>("123.00").unwrap(), 123u8);
|
||||
assert_eq!(parse_int::<u32>("123.0000").unwrap(), 123u32);
|
||||
assert_eq!(parse_int::<i32>("-123.").unwrap(), -123i32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_depends() {
|
||||
let u1 = "123e4567-e89b-12d3-a456-426655440000";
|
||||
let u2 = "123e4567-e89b-12d3-a456-999999990000";
|
||||
assert_eq!(
|
||||
parse_depends(u1).unwrap(),
|
||||
vec![Uuid::parse_str(u1).unwrap()]
|
||||
);
|
||||
assert_eq!(
|
||||
parse_depends(&format!("{},{}", u1, u2)).unwrap(),
|
||||
vec![Uuid::parse_str(u1).unwrap(), Uuid::parse_str(u2).unwrap()]
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue