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:
Dustin J. Mitchell 2020-11-23 12:38:32 -05:00
parent c2c2a00ed5
commit 634aaadb73
10 changed files with 276 additions and 490 deletions

16
Cargo.lock generated
View file

@ -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",

View file

@ -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"

View file

@ -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)

View file

@ -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
View 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

View file

@ -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);
}
}
}

View file

@ -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]

View file

@ -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;

View file

@ -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
}
}

View file

@ -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()]
);
}
}