From e3f438d9faac11f50d04c34f72630fc4426c3f17 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Wed, 5 Jan 2022 02:49:04 +0000 Subject: [PATCH 1/9] make taskdb.apply for create/delete not fail if already exists/doesn't exist --- taskchampion/src/replica.rs | 17 +++++++---- taskchampion/src/taskdb/apply.rs | 51 ++++++++++++++++++-------------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index 2b833a323..576de2f7a 100644 --- a/taskchampion/src/replica.rs +++ b/taskchampion/src/replica.rs @@ -99,18 +99,25 @@ impl Replica { .map(move |tm| Task::new(uuid, tm))) } - /// Create a new task. The task must not already exist. + /// Create a new task. pub fn new_task(&mut self, status: Status, description: String) -> anyhow::Result { - self.add_undo_point(false)?; let uuid = Uuid::new_v4(); - let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; - trace!("task {} created", uuid); - let mut task = Task::new(uuid, taskmap).into_mut(self); + let mut task = self.create_task(uuid)?.into_mut(self); task.set_description(description)?; task.set_status(status)?; + trace!("task {} created", uuid); Ok(task.into_immut()) } + /// Create a new, empty task with the given UUID. This is useful for importing tasks, but + /// otherwise should be avoided in favor of `new_task`. If the task already exists, this + /// does nothing and returns the existing task. + pub fn create_task(&mut self, uuid: Uuid) -> anyhow::Result { + self.add_undo_point(false)?; + let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; + Ok(Task::new(uuid, taskmap)) + } + /// 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. This is not a public method as deletion /// should only occur through expiration. diff --git a/taskchampion/src/taskdb/apply.rs b/taskchampion/src/taskdb/apply.rs index 1be3864c9..1e3a3fa83 100644 --- a/taskchampion/src/taskdb/apply.rs +++ b/taskchampion/src/taskdb/apply.rs @@ -4,7 +4,8 @@ use crate::storage::{ReplicaOp, StorageTxn, TaskMap}; /// Apply the given SyncOp to the replica, updating both the task data and adding a /// ReplicaOp to the list of operations. Returns the TaskMap of the task after the -/// operation has been applied (or an empty TaskMap for Delete). +/// operation has been applied (or an empty TaskMap for Delete). It is not an error +/// to create an existing task, nor to delete a nonexistent task. pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow::Result { match op { SyncOp::Create { uuid } => { @@ -14,8 +15,9 @@ pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow:: txn.commit()?; Ok(TaskMap::new()) } else { - // TODO: differentiate error types here? - Err(Error::Database(format!("Task {} already exists", uuid)).into()) + Ok(txn + .get_task(uuid)? + .expect("create_task failed but task does not exist")) } } SyncOp::Delete { uuid } => { @@ -29,7 +31,7 @@ pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow:: txn.commit()?; Ok(TaskMap::new()) } else { - Err(Error::Database(format!("Task {} does not exist", uuid)).into()) + Ok(TaskMap::new()) } } SyncOp::Update { @@ -105,6 +107,7 @@ pub(super) fn apply_op(txn: &mut dyn StorageTxn, op: &SyncOp) -> anyhow::Result< #[cfg(test)] mod tests { use super::*; + use crate::storage::TaskMap; use crate::taskdb::TaskDb; use chrono::Utc; use pretty_assertions::assert_eq; @@ -133,24 +136,33 @@ mod tests { fn test_apply_create_exists() -> anyhow::Result<()> { let mut db = TaskDb::new_inmemory(); let uuid = Uuid::new_v4(); + { + let mut txn = db.storage.txn()?; + txn.create_task(uuid)?; + let mut taskmap = TaskMap::new(); + taskmap.insert("foo".into(), "bar".into()); + txn.set_task(uuid, taskmap)?; + txn.commit()?; + } + let op = SyncOp::Create { uuid }; { let mut txn = db.storage.txn()?; let taskmap = apply_and_record(txn.as_mut(), op.clone())?; - assert_eq!(taskmap.len(), 0); - assert_eq!( - apply_and_record(txn.as_mut(), op) - .err() - .unwrap() - .to_string(), - format!("Task Database Error: Task {} already exists", uuid) - ); + + assert_eq!(taskmap.len(), 1); + assert_eq!(taskmap.get("foo").unwrap(), "bar"); + txn.commit()?; } - // first op was applied - assert_eq!(db.sorted_tasks(), vec![(uuid, vec![])]); - assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]); + // create did not delete the old task.. + assert_eq!( + db.sorted_tasks(), + vec![(uuid, vec![("foo".into(), "bar".into())])] + ); + // create was done "manually" above, and no new op was added + assert_eq!(db.operations(), vec![]); Ok(()) } @@ -384,13 +396,8 @@ mod tests { let op = SyncOp::Delete { uuid }; { let mut txn = db.storage.txn()?; - assert_eq!( - apply_and_record(txn.as_mut(), op) - .err() - .unwrap() - .to_string(), - format!("Task Database Error: Task {} does not exist", uuid) - ); + let taskmap = apply_and_record(txn.as_mut(), op)?; + assert_eq!(taskmap.len(), 0); txn.commit()?; } From e2e0951c816821c80ba022d38ea971e73cc4f20a Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Wed, 5 Jan 2022 03:12:44 +0000 Subject: [PATCH 2/9] Make a public method --- taskchampion/src/replica.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index 576de2f7a..17b058184 100644 --- a/taskchampion/src/replica.rs +++ b/taskchampion/src/replica.rs @@ -47,7 +47,10 @@ 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. - pub(crate) fn update_task( + /// + /// This is a low-level method, and requires knowledge of the Task data model. Prefer to + /// use the [`TaskMut`] methods to modify tasks, where possible. + pub fn update_task( &mut self, uuid: Uuid, property: S1, From 63804b56523d46ad9e2c2bf5413ada90bf8c1a4c Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Wed, 5 Jan 2022 03:12:55 +0000 Subject: [PATCH 3/9] Implement 'ta import' Tests include "TODO" notes for data not handled by TaskChampion, including links to the associated GitHub issues. --- Cargo.lock | 1 + cli/Cargo.toml | 7 +- cli/src/argparse/subcommand.rs | 33 ++++ cli/src/invocation/cmd/import.rs | 257 +++++++++++++++++++++++++++++++ cli/src/invocation/cmd/mod.rs | 1 + cli/src/invocation/mod.rs | 7 + taskchampion/src/task/task.rs | 9 +- 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 cli/src/invocation/cmd/import.rs diff --git a/Cargo.lock b/Cargo.lock index 158717feb..b63a0a2de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2997,6 +2997,7 @@ dependencies = [ "pretty_assertions", "prettytable-rs", "rstest", + "serde", "serde_json", "taskchampion", "tempfile", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 37fc20b19..9c3e63974 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,7 +22,9 @@ termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" -chrono = "0.4" +serde = { version = "^1.0.125", features = ["derive"] } +serde_json = "^1.0" +chrono = { version = "^0.4.10", features = ["serde"] } lazy_static = "1" iso8601-duration = "0.1" dialoguer = "0.8" @@ -30,7 +32,6 @@ dialoguer = "0.8" # only needed for usage-docs # if the mdbook version changes, change it in .github/workflows/publish-docs.yml and .github/workflows/checks.yml as well mdbook = { version = "0.4.10", optional = true } -serde_json = { version = "*", optional = true } [dependencies.taskchampion] path = "../taskchampion" @@ -46,7 +47,7 @@ rstest = "0.10" pretty_assertions = "1" [features] -usage-docs = [ "mdbook", "serde_json" ] +usage-docs = [ "mdbook" ] [[bin]] name = "ta" diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 06cc52490..99de0c46f 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -59,6 +59,7 @@ pub(crate) enum Subcommand { /// Basic operations without args Gc, Sync, + Import, Undo, } @@ -73,6 +74,7 @@ impl Subcommand { Info::parse, Gc::parse, Sync::parse, + Import::parse, Undo::parse, // This must come last since it accepts arbitrary report names Report::parse, @@ -88,6 +90,8 @@ impl Subcommand { Info::get_usage(u); Gc::get_usage(u); Sync::get_usage(u); + Import::get_usage(u); + Undo::get_usage(u); Report::get_usage(u); } } @@ -424,6 +428,35 @@ impl Sync { } } +struct Import; + +impl Import { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Import) + } + map_res(arg_matching(literal("import")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "import", + syntax: "import", + summary: "Import tasks", + description: " + Import tasks into this replica. + + The tasks must be provided in the TaskWarrior JSON format on stdin. If tasks + in the import already exist, they are 'merged'. + + Because TaskChampion lacks the information about the types of UDAs that is stored + in the TaskWarrior configuration, UDA values are imported as simple strings, in the + format they appear in the JSON export. This may cause undesirable results. + ", + }) + } +} + struct Undo; impl Undo { diff --git a/cli/src/invocation/cmd/import.rs b/cli/src/invocation/cmd/import.rs new file mode 100644 index 000000000..b017c73ba --- /dev/null +++ b/cli/src/invocation/cmd/import.rs @@ -0,0 +1,257 @@ +use anyhow::{anyhow, bail}; +use chrono::{DateTime, TimeZone, Utc}; +use serde::{self, Deserialize, Deserializer}; +use serde_json::Value; +use std::collections::HashMap; +use taskchampion::{Replica, Uuid}; +use termcolor::WriteColor; + +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { + writeln!(w, "Importing tasks from stdin.")?; + let tasks: Vec> = + serde_json::from_reader(std::io::stdin()).map_err(|_| anyhow!("Invalid JSON"))?; + + for task_json in &tasks { + import_task(w, replica, task_json)?; + } + + writeln!(w, "{} tasks imported.", tasks.len())?; + Ok(()) +} + +/// Convert the given value to a string, failing on compound types (arrays +/// and objects). +fn stringify(v: &Value) -> anyhow::Result { + Ok(match v { + Value::String(ref s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(true) => "true".to_string(), + Value::Bool(false) => "false".to_string(), + Value::Null => "null".to_string(), + _ => bail!("{:?} cannot be converted to a string", v), + }) +} + +pub fn deserialize_tw_datetime<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + const FORMAT: &str = "%Y%m%dT%H%M%SZ"; + let s = String::deserialize(deserializer)?; + Utc.datetime_from_str(&s, FORMAT) + .map_err(serde::de::Error::custom) +} + +/// Deserialize a string in the TaskWarrior format into a DateTime +#[derive(Deserialize)] +struct TwDateTime(#[serde(deserialize_with = "deserialize_tw_datetime")] DateTime); + +impl TwDateTime { + /// Generate the data-model style UNIX timestamp for this DateTime + fn tc_timestamp(&self) -> String { + self.0.timestamp().to_string() + } +} + +#[derive(Deserialize)] +struct Annotation { + entry: TwDateTime, + description: String, +} + +fn import_task( + w: &mut W, + replica: &mut Replica, + // TOOD: take this by value and consume it + task_json: &HashMap, +) -> anyhow::Result<()> { + let uuid = task_json + .get("uuid") + .ok_or_else(|| anyhow!("task has no uuid"))?; + let uuid = uuid + .as_str() + .ok_or_else(|| anyhow!("uuid is not a string"))?; + let uuid = Uuid::parse_str(uuid)?; + replica.create_task(uuid)?; + + let mut description = None; + for (k, v) in task_json.iter() { + match k.as_ref() { + // `id` is the working-set ID and is not stored + "id" => {} + + // `urgency` is also calculated and not stored + "urgency" => {} + + // `uuid` was already handled + "uuid" => {} + + // `annotations` is a sub-aray + "annotations" => { + let annotations: Vec = serde_json::from_value(v.clone())?; + for ann in annotations { + let k = format!("annotation_{}", ann.entry.tc_timestamp()); + replica.update_task(uuid, k, Some(ann.description))?; + } + } + + // `depends` is a sub-aray + "depends" => { + let deps: Vec = serde_json::from_value(v.clone())?; + for dep in deps { + let k = format!("dep_{}", dep); + replica.update_task(uuid, k, Some("".to_owned()))?; + } + } + + // `tags` is a sub-aray + "tags" => { + let tags: Vec = serde_json::from_value(v.clone())?; + for tag in tags { + let k = format!("tag_{}", tag); + replica.update_task(uuid, k, Some("".to_owned()))?; + } + } + + // convert all datetimes -> epoch integers + "end" | "entry" | "modified" | "wait" | "due" => { + let v: TwDateTime = serde_json::from_value(v.clone())?; + replica.update_task(uuid, k, Some(v.tc_timestamp()))?; + } + + // everything else is inserted directly + _ => { + let v = stringify(v)?; + replica.update_task(uuid, k, Some(v.clone()))?; + if k == "description" { + description = Some(v); + } + } + } + } + + writeln!( + w, + "{} {}", + uuid, + description.unwrap_or_else(|| "(no description)".into()) + )?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use chrono::{TimeZone, Utc}; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::convert::TryInto; + use taskchampion::{Priority, Status}; + + #[test] + fn stringify_string() { + assert_eq!(stringify(&json!("foo")).unwrap(), "foo".to_string()); + } + + #[test] + fn stringify_number() { + assert_eq!(stringify(&json!(2.14)).unwrap(), "2.14".to_string()); + } + + #[test] + fn stringify_bool() { + assert_eq!(stringify(&json!(true)).unwrap(), "true".to_string()); + assert_eq!(stringify(&json!(false)).unwrap(), "false".to_string()); + } + + #[test] + fn stringify_null() { + assert_eq!(stringify(&json!(null)).unwrap(), "null".to_string()); + } + + #[test] + fn stringify_invalid() { + assert!(stringify(&json!([1])).is_err()); + assert!(stringify(&json!({"a": 1})).is_err()); + } + + #[test] + fn test_import() -> anyhow::Result<()> { + let mut w = test_writer(); + let mut replica = test_replica(); + + let task_json = serde_json::from_value(json!({ + "id": 0, + "description": "repair window", + "end": "20211231T175614Z", // TODO (#327) + "entry": "20211117T022410Z", // TODO (#326) + "modified": "20211231T175614Z", + "priority": "M", + "status": "completed", + "uuid": "fa01e916-1587-4c7d-a646-f7be62be8ee7", + "wait": "20211225T001523Z", + "due": "20211225T040000Z", // TODO (#82) + + // TODO: recurrence (#81) + "imask": 2, + "recur": "monthly", + "rtype": "periodic", + "mask": "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--", + + // (legacy) UDAs + "githubcreatedon": "20211110T175919Z", + "githubnamespace": "djmitche", + "githubnumber": 228, + + "tags": [ + "house" + ], + "depends": [ // TODO (#84) + "4f71035d-1704-47f0-885c-6f9134bcefb2" + ], + "annotations": [ + { + "entry": "20211223T142031Z", + "description": "ordered from website" + } + ], + "urgency": 4.16849 + }))?; + import_task(&mut w, &mut replica, &task_json)?; + + let task = replica + .get_task(Uuid::parse_str("fa01e916-1587-4c7d-a646-f7be62be8ee7").unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "repair window"); + assert_eq!(task.get_status(), Status::Completed); + assert_eq!(task.get_priority(), Priority::M); + assert_eq!( + task.get_wait(), + Some(Utc.ymd(2021, 12, 25).and_hms(00, 15, 23)) + ); + assert_eq!( + task.get_modified(), + Some(Utc.ymd(2021, 12, 31).and_hms(17, 56, 14)) + ); + assert!(task.has_tag(&"house".try_into().unwrap())); + assert!(!task.has_tag(&"PENDING".try_into().unwrap())); + assert_eq!( + task.get_annotations().collect::>(), + vec![taskchampion::Annotation { + entry: Utc.ymd(2021, 12, 23).and_hms(14, 20, 31), + description: "ordered from website".into(), + }] + ); + assert_eq!( + task.get_legacy_uda("githubcreatedon"), + Some("20211110T175919Z") + ); + assert_eq!(task.get_legacy_uda("githubnamespace"), Some("djmitche")); + assert_eq!(task.get_legacy_uda("githubnumber"), Some("228")); + + Ok(()) + } +} diff --git a/cli/src/invocation/cmd/mod.rs b/cli/src/invocation/cmd/mod.rs index e7606ac90..5aa3bce12 100644 --- a/cli/src/invocation/cmd/mod.rs +++ b/cli/src/invocation/cmd/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod add; pub(crate) mod config; pub(crate) mod gc; pub(crate) mod help; +pub(crate) mod import; pub(crate) mod info; pub(crate) mod modify; pub(crate) mod report; diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index 41a8c4ce2..e3b468060 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -90,6 +90,13 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate:: return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server); } + Command { + subcommand: Subcommand::Import, + .. + } => { + return cmd::import::execute(&mut w, &mut replica); + } + Command { subcommand: Subcommand::Undo, .. diff --git a/taskchampion/src/task/task.rs b/taskchampion/src/task/task.rs index 40a85ba87..9771bc3d0 100644 --- a/taskchampion/src/task/task.rs +++ b/taskchampion/src/task/task.rs @@ -1,5 +1,5 @@ use super::tag::{SyntheticTag, TagInner}; -use super::{Annotation, Status, Tag, Timestamp}; +use super::{Annotation, Priority, Status, Tag, Timestamp}; use crate::replica::Replica; use crate::storage::TaskMap; use chrono::prelude::*; @@ -118,6 +118,13 @@ impl Task { .unwrap_or("") } + pub fn get_priority(&self) -> Priority { + self.taskmap + .get(Prop::Status.as_ref()) + .map(|s| Priority::from_taskmap(s)) + .unwrap_or(Priority::M) + } + /// 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> { From 4b2ef1913aab5c297a1848ce3361f5b0f49ad1f2 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Thu, 6 Jan 2022 00:17:01 +0000 Subject: [PATCH 4/9] use owned values to avoid unnecessary cloning --- cli/src/invocation/cmd/import.rs | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/cli/src/invocation/cmd/import.rs b/cli/src/invocation/cmd/import.rs index b017c73ba..2ff2ebcb0 100644 --- a/cli/src/invocation/cmd/import.rs +++ b/cli/src/invocation/cmd/import.rs @@ -8,10 +8,10 @@ use termcolor::WriteColor; pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { writeln!(w, "Importing tasks from stdin.")?; - let tasks: Vec> = + let mut tasks: Vec> = serde_json::from_reader(std::io::stdin()).map_err(|_| anyhow!("Invalid JSON"))?; - for task_json in &tasks { + for task_json in tasks.drain(..) { import_task(w, replica, task_json)?; } @@ -21,9 +21,9 @@ pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result /// Convert the given value to a string, failing on compound types (arrays /// and objects). -fn stringify(v: &Value) -> anyhow::Result { +fn stringify(v: Value) -> anyhow::Result { Ok(match v { - Value::String(ref s) => s.clone(), + Value::String(s) => s, Value::Number(n) => n.to_string(), Value::Bool(true) => "true".to_string(), Value::Bool(false) => "false".to_string(), @@ -62,8 +62,7 @@ struct Annotation { fn import_task( w: &mut W, replica: &mut Replica, - // TOOD: take this by value and consume it - task_json: &HashMap, + mut task_json: HashMap, ) -> anyhow::Result<()> { let uuid = task_json .get("uuid") @@ -75,7 +74,7 @@ fn import_task( replica.create_task(uuid)?; let mut description = None; - for (k, v) in task_json.iter() { + for (k, v) in task_json.drain() { match k.as_ref() { // `id` is the working-set ID and is not stored "id" => {} @@ -88,7 +87,7 @@ fn import_task( // `annotations` is a sub-aray "annotations" => { - let annotations: Vec = serde_json::from_value(v.clone())?; + let annotations: Vec = serde_json::from_value(v)?; for ann in annotations { let k = format!("annotation_{}", ann.entry.tc_timestamp()); replica.update_task(uuid, k, Some(ann.description))?; @@ -97,7 +96,7 @@ fn import_task( // `depends` is a sub-aray "depends" => { - let deps: Vec = serde_json::from_value(v.clone())?; + let deps: Vec = serde_json::from_value(v)?; for dep in deps { let k = format!("dep_{}", dep); replica.update_task(uuid, k, Some("".to_owned()))?; @@ -106,7 +105,7 @@ fn import_task( // `tags` is a sub-aray "tags" => { - let tags: Vec = serde_json::from_value(v.clone())?; + let tags: Vec = serde_json::from_value(v)?; for tag in tags { let k = format!("tag_{}", tag); replica.update_task(uuid, k, Some("".to_owned()))?; @@ -115,17 +114,18 @@ fn import_task( // convert all datetimes -> epoch integers "end" | "entry" | "modified" | "wait" | "due" => { - let v: TwDateTime = serde_json::from_value(v.clone())?; + let v: TwDateTime = serde_json::from_value(v)?; replica.update_task(uuid, k, Some(v.tc_timestamp()))?; } // everything else is inserted directly _ => { let v = stringify(v)?; - replica.update_task(uuid, k, Some(v.clone()))?; if k == "description" { - description = Some(v); + // keep a copy of the description for console output + description = Some(v.clone()); } + replica.update_task(uuid, k, Some(v))?; } } } @@ -152,29 +152,29 @@ mod test { #[test] fn stringify_string() { - assert_eq!(stringify(&json!("foo")).unwrap(), "foo".to_string()); + assert_eq!(stringify(json!("foo")).unwrap(), "foo".to_string()); } #[test] fn stringify_number() { - assert_eq!(stringify(&json!(2.14)).unwrap(), "2.14".to_string()); + assert_eq!(stringify(json!(2.14)).unwrap(), "2.14".to_string()); } #[test] fn stringify_bool() { - assert_eq!(stringify(&json!(true)).unwrap(), "true".to_string()); - assert_eq!(stringify(&json!(false)).unwrap(), "false".to_string()); + assert_eq!(stringify(json!(true)).unwrap(), "true".to_string()); + assert_eq!(stringify(json!(false)).unwrap(), "false".to_string()); } #[test] fn stringify_null() { - assert_eq!(stringify(&json!(null)).unwrap(), "null".to_string()); + assert_eq!(stringify(json!(null)).unwrap(), "null".to_string()); } #[test] fn stringify_invalid() { - assert!(stringify(&json!([1])).is_err()); - assert!(stringify(&json!({"a": 1})).is_err()); + assert!(stringify(json!([1])).is_err()); + assert!(stringify(json!({"a": 1})).is_err()); } #[test] @@ -219,7 +219,7 @@ mod test { ], "urgency": 4.16849 }))?; - import_task(&mut w, &mut replica, &task_json)?; + import_task(&mut w, &mut replica, task_json)?; let task = replica .get_task(Uuid::parse_str("fa01e916-1587-4c7d-a646-f7be62be8ee7").unwrap()) From 162a9eae95d982651637f90d860be9751f72cf7e Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Thu, 6 Jan 2022 03:49:26 +0000 Subject: [PATCH 5/9] Support parsing TDB2 files --- cli/src/lib.rs | 1 + cli/src/tdb2/mod.rs | 325 +++++++++++++++++++++++++++++++++++++++++ cli/src/tdb2/test.data | 6 + 3 files changed, 332 insertions(+) create mode 100644 cli/src/tdb2/mod.rs create mode 100644 cli/src/tdb2/test.data diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 8b61c12f8..3b3258f21 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -41,6 +41,7 @@ mod errors; mod invocation; mod settings; mod table; +mod tdb2; mod usage; /// See https://docs.rs/built diff --git a/cli/src/tdb2/mod.rs b/cli/src/tdb2/mod.rs new file mode 100644 index 000000000..f91c8eca1 --- /dev/null +++ b/cli/src/tdb2/mod.rs @@ -0,0 +1,325 @@ +//! TDB2 is TaskWarrior's on-disk database format. The set of tasks is represented in +//! `pending.data` and `completed.data`. There are other `.data` files as well, but those are not +//! used in TaskChampion. +use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct File { + pub(crate) lines: Vec, +} + +#[derive(Clone, PartialEq)] +pub(crate) struct Line { + pub(crate) attrs: Vec, +} + +#[derive(Clone, PartialEq)] +pub(crate) struct Attr { + pub(crate) name: String, + pub(crate) value: String, +} + +impl File { + pub(crate) fn from_str(input: &str) -> Result { + Ok(File::parse(input).map(|(_, res)| res).map_err(|_| ())?) + } + + fn parse(input: &str) -> IResult<&str, File> { + all_consuming(fold_many0( + terminated(Line::parse, char('\n')), + File { lines: vec![] }, + |mut file, line| { + file.lines.push(line); + file + }, + ))(input) + } +} + +impl Line { + /// Parse a line in a TDB2 file. See TaskWarrior's Task::Parse. + fn parse(input: &str) -> IResult<&str, Line> { + fn to_line(input: Vec) -> Result { + Ok(Line { attrs: input }) + } + map_res( + delimited( + char('['), + separated_list0(char(' '), Attr::parse), + char(']'), + ), + to_line, + )(input) + } +} + +impl fmt::Debug for Line { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("line!")?; + f.debug_list().entries(self.attrs.iter()).finish() + } +} + +impl Attr { + /// Parse an attribute (name-value pair). + fn parse(input: &str) -> IResult<&str, Attr> { + fn to_attr(input: (&str, String)) -> Result { + Ok(Attr { + name: input.0.into(), + value: input.1, + }) + } + map_res( + separated_pair(Attr::parse_name, char(':'), Attr::parse_value), + to_attr, + )(input) + } + + /// Parse an attribute name, which is composed of any character but `:`. + fn parse_name(input: &str) -> IResult<&str, &str> { + recognize(many1(none_of(":")))(input) + } + + /// Parse and interpret a quoted string. Note that this does _not_ reverse the effects of + + fn parse_value(input: &str) -> IResult<&str, String> { + // For the parsing part of the job, see Pig::getQuoted in TaskWarrior's libshared, which + // merely finds the end of a string. + // + // The interpretation is defined in json::decode in libshared. Fortunately, the data we + // are reading was created with json::encode, which does not perform unicode escaping. + + fn escaped_string_char(input: &str) -> IResult<&str, char> { + alt(( + // reverse the escaping performed in json::encode + preceded( + char('\\'), + alt(( + // some characters are simply escaped + one_of(r#""\/"#), + // others translate to control characters + value('\x08', char('b')), + value('\x0c', char('f')), + value('\n', char('n')), + value('\r', char('r')), + value('\t', char('t')), + )), + ), + // not a backslash or double-quote + none_of("\"\\"), + ))(input) + } + + let inner = fold_many0( + escaped_string_char, + String::new(), + |mut string, fragment| { + string.push(fragment); + string + }, + ); + + delimited(char('"'), inner, char('"'))(input) + } +} + +impl fmt::Debug for Attr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!("{:?} => {:?}", self.name, self.value)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + macro_rules! line { + ($($n:expr => $v:expr),* $(,)?) => ( + Line{attrs: vec![$(Attr{name: $n.into(), value: $v.into()}),*]} + ); + } + + #[test] + fn file() { + assert_eq!( + File::parse(include_str!("test.data")).unwrap(), + ( + "", + File { + lines: vec![ + line![ + "description" => "snake 🐍", + "entry" => "1641670385", + "modified" => "1641670385", + "priority" => "M", + "status" => "pending", + "uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62", + ], + line![ + "annotation_1585711454" => + "https://blog.tensorflow.org/2020/03/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617", + "description" => "try facemesh", + "entry" => "1585711451", + "modified" => "1592947544", + "priority" => "M", + "project" => "lists", + "status" => "pending", + "tags" => "idea", + "tags_idea" => "x", + "uuid" => "ee855dc7-6f61-408c-bc95-ebb52f7d529c", + ], + line![ + "description" => "testing", + "entry" => "1554074416", + "modified" => "1554074416", + "priority" => "M", + "status" => "pending", + "uuid" => "4578fb67-359b-4483-afe4-fef15925ccd6", + ], + line![ + "description" => "testing2", + "entry" => "1576352411", + "modified" => "1576352411", + "priority" => "M", + "status" => "pending", + "uuid" => "f5982cca-2ea1-4bfd-832c-9bd571dc0743", + ], + line![ + "description" => "new-task", + "entry" => "1576352696", + "modified" => "1576352696", + "priority" => "M", + "status" => "pending", + "uuid" => "cfee3170-f153-4075-aa1d-e20bcac2841b", + ], + line![ + "description" => "foo", + "entry" => "1579398776", + "modified" => "1579398776", + "priority" => "M", + "status" => "pending", + "uuid" => "df74ea94-5122-44fa-965a-637412fbbffc", + ], + ] + } + ) + ); + } + + #[test] + fn empty_line() { + assert_eq!(Line::parse("[]").unwrap(), ("", line![])); + } + + #[test] + fn escaped_line() { + assert_eq!( + Line::parse(r#"[annotation_1585711454:"\"\\\"" abc:"xx"]"#).unwrap(), + ( + "", + line!["annotation_1585711454" => "\"\\\"", "abc" => "xx"] + ) + ); + } + + #[test] + fn escaped_line_backslash() { + assert_eq!( + Line::parse(r#"[abc:"xx" 123:"x\\x"]"#).unwrap(), + ("", line!["abc" => "xx", "123" => "x\\x"]) + ); + } + + #[test] + fn escaped_line_quote() { + assert_eq!( + Line::parse(r#"[abc:"xx" 123:"x\"x"]"#).unwrap(), + ("", line!["abc" => "xx", "123" => "x\"x"]) + ); + } + + #[test] + fn unicode_line() { + assert_eq!( + Line::parse(r#"[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]"#).unwrap(), + ("", line![ + "description" => "snake 🐍", + "entry" => "1641670385", + "modified" => "1641670385", + "priority" => "M", + "status" => "pending", + "uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62", + ])); + } + + #[test] + fn backslashed_attr() { + assert!(Attr::parse(r#"one:"\""#).is_err()); + assert_eq!( + Attr::parse(r#"two:"\\""#).unwrap(), + ( + "", + Attr { + name: "two".into(), + value: r#"\"#.into(), + } + ) + ); + assert!(Attr::parse(r#"three:"\\\""#).is_err()); + assert_eq!( + Attr::parse(r#"four:"\\\\""#).unwrap(), + ( + "", + Attr { + name: "four".into(), + value: r#"\\"#.into(), + } + ) + ); + } + + #[test] + fn backslash_frontslash() { + assert_eq!( + Attr::parse(r#"front:"\/""#).unwrap(), + ( + "", + Attr { + name: "front".into(), + value: r#"/"#.into(), + } + ) + ); + } + + #[test] + fn backslash_control_chars() { + assert_eq!( + Attr::parse(r#"control:"\b\f\n\r\t""#).unwrap(), + ( + "", + Attr { + name: "control".into(), + value: "\x08\x0c\x0a\x0d\x09".into(), + } + ) + ); + } + + #[test] + fn url_attr() { + assert_eq!( + Attr::parse(r#"annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/""#) + .unwrap(), + ( + "", + Attr { + name: "annotation_1585711454".into(), + value: "https://blog.tensorflow.org/2020/03/".into(), + } + ) + ); + } +} diff --git a/cli/src/tdb2/test.data b/cli/src/tdb2/test.data new file mode 100644 index 000000000..f57b9101b --- /dev/null +++ b/cli/src/tdb2/test.data @@ -0,0 +1,6 @@ +[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"] +[annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617" description:"try facemesh" entry:"1585711451" modified:"1592947544" priority:"M" project:"lists" status:"pending" tags:"idea" tags_idea:"x" uuid:"ee855dc7-6f61-408c-bc95-ebb52f7d529c"] +[description:"testing" entry:"1554074416" modified:"1554074416" priority:"M" status:"pending" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"] +[description:"testing2" entry:"1576352411" modified:"1576352411" priority:"M" status:"pending" uuid:"f5982cca-2ea1-4bfd-832c-9bd571dc0743"] +[description:"new-task" entry:"1576352696" modified:"1576352696" priority:"M" status:"pending" uuid:"cfee3170-f153-4075-aa1d-e20bcac2841b"] +[description:"foo" entry:"1579398776" modified:"1579398776" priority:"M" status:"pending" uuid:"df74ea94-5122-44fa-965a-637412fbbffc"] From 69d052603d6cd829de2ad7719063ef961067a8b3 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 8 Jan 2022 22:11:27 +0000 Subject: [PATCH 6/9] ta import-tdb2 --- cli/src/argparse/subcommand.rs | 36 +++++++ cli/src/invocation/cmd/completed.data | 1 + cli/src/invocation/cmd/import.rs | 14 ++- cli/src/invocation/cmd/import_tdb2.rs | 142 ++++++++++++++++++++++++++ cli/src/invocation/cmd/mod.rs | 1 + cli/src/invocation/cmd/pending.data | 1 + cli/src/invocation/mod.rs | 7 ++ cli/src/tdb2/mod.rs | 2 +- 8 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 cli/src/invocation/cmd/completed.data create mode 100644 cli/src/invocation/cmd/import_tdb2.rs create mode 100644 cli/src/invocation/cmd/pending.data diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 99de0c46f..30f56812b 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -60,6 +60,9 @@ pub(crate) enum Subcommand { Gc, Sync, Import, + ImportTDB2 { + path: String, + }, Undo, } @@ -75,6 +78,7 @@ impl Subcommand { Gc::parse, Sync::parse, Import::parse, + ImportTDB2::parse, Undo::parse, // This must come last since it accepts arbitrary report names Report::parse, @@ -91,6 +95,7 @@ impl Subcommand { Gc::get_usage(u); Sync::get_usage(u); Import::get_usage(u); + ImportTDB2::get_usage(u); Undo::get_usage(u); Report::get_usage(u); } @@ -457,6 +462,37 @@ impl Import { } } +struct ImportTDB2; + +impl ImportTDB2 { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (&str, &str)) -> Result { + Ok(Subcommand::ImportTDB2 { + path: input.1.into(), + }) + } + map_res( + pair(arg_matching(literal("import-tdb2")), arg_matching(any)), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "import-tdb2", + syntax: "import-tdb2 ", + summary: "Import tasks from the TaskWarrior data directory", + description: " + Import tasks into this replica from a TaskWarrior data directory. If tasks in the + import already exist, they are 'merged'. This mode of import supports UDAs better + than the `import` subcommand, but requires access to the \"raw\" TaskWarrior data. + + This command supports task directories written by TaskWarrior-2.6.1 or later. + ", + }) + } +} + struct Undo; impl Undo { diff --git a/cli/src/invocation/cmd/completed.data b/cli/src/invocation/cmd/completed.data new file mode 100644 index 000000000..3a48b9cd1 --- /dev/null +++ b/cli/src/invocation/cmd/completed.data @@ -0,0 +1 @@ +[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"] diff --git a/cli/src/invocation/cmd/import.rs b/cli/src/invocation/cmd/import.rs index 2ff2ebcb0..5e33e22ef 100644 --- a/cli/src/invocation/cmd/import.rs +++ b/cli/src/invocation/cmd/import.rs @@ -4,10 +4,13 @@ use serde::{self, Deserialize, Deserializer}; use serde_json::Value; use std::collections::HashMap; use taskchampion::{Replica, Uuid}; -use termcolor::WriteColor; +use termcolor::{Color, ColorSpec, WriteColor}; pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { + w.set_color(ColorSpec::new().set_bold(true))?; writeln!(w, "Importing tasks from stdin.")?; + w.reset()?; + let mut tasks: Vec> = serde_json::from_reader(std::io::stdin()).map_err(|_| anyhow!("Invalid JSON"))?; @@ -15,7 +18,10 @@ pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result import_task(w, replica, task_json)?; } + w.set_color(ColorSpec::new().set_bold(true))?; writeln!(w, "{} tasks imported.", tasks.len())?; + w.reset()?; + Ok(()) } @@ -130,10 +136,12 @@ fn import_task( } } + w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?; + write!(w, "{}", uuid)?; + w.reset()?; writeln!( w, - "{} {}", - uuid, + " {}", description.unwrap_or_else(|| "(no description)".into()) )?; diff --git a/cli/src/invocation/cmd/import_tdb2.rs b/cli/src/invocation/cmd/import_tdb2.rs new file mode 100644 index 000000000..e441652c5 --- /dev/null +++ b/cli/src/invocation/cmd/import_tdb2.rs @@ -0,0 +1,142 @@ +use crate::tdb2; +use anyhow::anyhow; +use std::fs; +use std::path::PathBuf; +use taskchampion::{Replica, Uuid}; +use termcolor::{Color, ColorSpec, WriteColor}; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + path: &str, +) -> Result<(), crate::Error> { + let path: PathBuf = path.into(); + + let mut count = 0; + for file in &["pending.data", "completed.data"] { + let file = path.join(file); + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "Importing tasks from {:?}.", file)?; + w.reset()?; + + let data = fs::read_to_string(file)?; + let content = + tdb2::File::from_str(&data).map_err(|_| anyhow!("Could not parse TDB2 file format"))?; + count += content.lines.len(); + for line in content.lines { + import_task(w, replica, line)?; + } + } + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "{} tasks imported.", count)?; + w.reset()?; + + Ok(()) +} + +fn import_task( + w: &mut W, + replica: &mut Replica, + mut line: tdb2::Line, +) -> anyhow::Result<()> { + let mut uuid = None; + for attr in line.attrs.iter() { + if &attr.name == "uuid" { + uuid = Some(Uuid::parse_str(&attr.value)?); + break; + } + } + let uuid = uuid.ok_or_else(|| anyhow!("task has no uuid"))?; + replica.create_task(uuid)?; + + let mut description = None; + for attr in line.attrs.drain(..) { + // oddly, TaskWarrior represents [ and ] with their HTML entity equivalents + let value = attr.value.replace("&open;", "[").replace("&close;", "]"); + match attr.name.as_ref() { + // `uuid` was already handled + "uuid" => {} + + // everything else is inserted directly + _ => { + if attr.name == "description" { + // keep a copy of the description for console output + description = Some(value.clone()); + } + replica.update_task(uuid, attr.name, Some(value))?; + } + } + } + + w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?; + write!(w, "{}", uuid)?; + w.reset()?; + writeln!( + w, + " {}", + description.unwrap_or_else(|| "(no description)".into()) + )?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use chrono::{TimeZone, Utc}; + use pretty_assertions::assert_eq; + use std::convert::TryInto; + use taskchampion::{Priority, Status}; + use tempfile::TempDir; + + #[test] + fn test_import() -> anyhow::Result<()> { + let mut w = test_writer(); + let mut replica = test_replica(); + let tmp_dir = TempDir::new()?; + + fs::write( + tmp_dir.path().join("pending.data"), + include_bytes!("pending.data"), + )?; + fs::write( + tmp_dir.path().join("completed.data"), + include_bytes!("completed.data"), + )?; + + execute(&mut w, &mut replica, tmp_dir.path().to_str().unwrap())?; + + let task = replica + .get_task(Uuid::parse_str("f19086c2-1f8d-4a6c-9b8d-f94901fb8e62").unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "snake 🐍"); + assert_eq!(task.get_status(), Status::Pending); + assert_eq!(task.get_priority(), Priority::M); + assert_eq!(task.get_wait(), None); + assert_eq!( + task.get_modified(), + Some(Utc.ymd(2022, 1, 8).and_hms(19, 33, 5)) + ); + assert!(task.has_tag(&"reptile".try_into().unwrap())); + assert!(!task.has_tag(&"COMPLETED".try_into().unwrap())); + + let task = replica + .get_task(Uuid::parse_str("4578fb67-359b-4483-afe4-fef15925ccd6").unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "[TEST] foo"); + assert_eq!(task.get_status(), Status::Completed); + assert_eq!(task.get_priority(), Priority::M); + assert_eq!(task.get_wait(), None); + assert_eq!( + task.get_modified(), + Some(Utc.ymd(2019, 3, 31).and_hms(23, 20, 16)) + ); + assert!(!task.has_tag(&"reptile".try_into().unwrap())); + assert!(task.has_tag(&"COMPLETED".try_into().unwrap())); + + Ok(()) + } +} diff --git a/cli/src/invocation/cmd/mod.rs b/cli/src/invocation/cmd/mod.rs index 5aa3bce12..59484ea0b 100644 --- a/cli/src/invocation/cmd/mod.rs +++ b/cli/src/invocation/cmd/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod config; pub(crate) mod gc; pub(crate) mod help; pub(crate) mod import; +pub(crate) mod import_tdb2; pub(crate) mod info; pub(crate) mod modify; pub(crate) mod report; diff --git a/cli/src/invocation/cmd/pending.data b/cli/src/invocation/cmd/pending.data new file mode 100644 index 000000000..5f5590945 --- /dev/null +++ b/cli/src/invocation/cmd/pending.data @@ -0,0 +1 @@ +[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"] diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index e3b468060..7bc1c5616 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -97,6 +97,13 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate:: return cmd::import::execute(&mut w, &mut replica); } + Command { + subcommand: Subcommand::ImportTDB2 { path }, + .. + } => { + return cmd::import_tdb2::execute(&mut w, &mut replica, path.as_ref()); + } + Command { subcommand: Subcommand::Undo, .. diff --git a/cli/src/tdb2/mod.rs b/cli/src/tdb2/mod.rs index f91c8eca1..e23ad585b 100644 --- a/cli/src/tdb2/mod.rs +++ b/cli/src/tdb2/mod.rs @@ -22,7 +22,7 @@ pub(crate) struct Attr { impl File { pub(crate) fn from_str(input: &str) -> Result { - Ok(File::parse(input).map(|(_, res)| res).map_err(|_| ())?) + File::parse(input).map(|(_, res)| res).map_err(|_| ()) } fn parse(input: &str) -> IResult<&str, File> { From 5019ecb4f812acd64a5422e620c9ed77e958aa3e Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sat, 8 Jan 2022 22:34:29 +0000 Subject: [PATCH 7/9] allow windows newlines in TDB2 files --- cli/src/tdb2/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/src/tdb2/mod.rs b/cli/src/tdb2/mod.rs index e23ad585b..0ff59a311 100644 --- a/cli/src/tdb2/mod.rs +++ b/cli/src/tdb2/mod.rs @@ -27,7 +27,8 @@ impl File { fn parse(input: &str) -> IResult<&str, File> { all_consuming(fold_many0( - terminated(Line::parse, char('\n')), + // allow windows or normal newlines + terminated(Line::parse, pair(opt(char('\r')), char('\n'))), File { lines: vec![] }, |mut file, line| { file.lines.push(line); From 656f7e9ea0a10245a5bfac5576a870eb3db80050 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 23 Jan 2022 15:22:41 +0000 Subject: [PATCH 8/9] replica.create_task -> import_task_with_uuid --- cli/src/invocation/cmd/import.rs | 2 +- cli/src/invocation/cmd/import_tdb2.rs | 2 +- taskchampion/src/replica.rs | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/src/invocation/cmd/import.rs b/cli/src/invocation/cmd/import.rs index 5e33e22ef..249c1ad46 100644 --- a/cli/src/invocation/cmd/import.rs +++ b/cli/src/invocation/cmd/import.rs @@ -77,7 +77,7 @@ fn import_task( .as_str() .ok_or_else(|| anyhow!("uuid is not a string"))?; let uuid = Uuid::parse_str(uuid)?; - replica.create_task(uuid)?; + replica.import_task_with_uuid(uuid)?; let mut description = None; for (k, v) in task_json.drain() { diff --git a/cli/src/invocation/cmd/import_tdb2.rs b/cli/src/invocation/cmd/import_tdb2.rs index e441652c5..8db967699 100644 --- a/cli/src/invocation/cmd/import_tdb2.rs +++ b/cli/src/invocation/cmd/import_tdb2.rs @@ -47,7 +47,7 @@ fn import_task( } } let uuid = uuid.ok_or_else(|| anyhow!("task has no uuid"))?; - replica.create_task(uuid)?; + replica.import_task_with_uuid(uuid)?; let mut description = None; for attr in line.attrs.drain(..) { diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index e7862dab5..8d673b23c 100644 --- a/taskchampion/src/replica.rs +++ b/taskchampion/src/replica.rs @@ -105,7 +105,9 @@ impl Replica { /// Create a new task. pub fn new_task(&mut self, status: Status, description: String) -> anyhow::Result { let uuid = Uuid::new_v4(); - let mut task = self.create_task(uuid)?.into_mut(self); + self.add_undo_point(false)?; + let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; + let mut task = Task::new(uuid, taskmap).into_mut(self); task.set_description(description)?; task.set_status(status)?; task.set_entry(Utc::now())?; @@ -116,7 +118,7 @@ impl Replica { /// Create a new, empty task with the given UUID. This is useful for importing tasks, but /// otherwise should be avoided in favor of `new_task`. If the task already exists, this /// does nothing and returns the existing task. - pub fn create_task(&mut self, uuid: Uuid) -> anyhow::Result { + pub fn import_task_with_uuid(&mut self, uuid: Uuid) -> anyhow::Result { self.add_undo_point(false)?; let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; Ok(Task::new(uuid, taskmap)) From 210eb60c86d502dc489d5a98181d3a35ccc60cf1 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 23 Jan 2022 15:27:13 +0000 Subject: [PATCH 9/9] 'ta import' -> 'ta import-tw' --- cli/src/argparse/subcommand.rs | 20 +++++++++---------- .../cmd/{import.rs => import_tw.rs} | 0 cli/src/invocation/cmd/mod.rs | 2 +- cli/src/invocation/mod.rs | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) rename cli/src/invocation/cmd/{import.rs => import_tw.rs} (100%) diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 30f56812b..22c270ba3 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -59,7 +59,7 @@ pub(crate) enum Subcommand { /// Basic operations without args Gc, Sync, - Import, + ImportTW, ImportTDB2 { path: String, }, @@ -77,7 +77,7 @@ impl Subcommand { Info::parse, Gc::parse, Sync::parse, - Import::parse, + ImportTW::parse, ImportTDB2::parse, Undo::parse, // This must come last since it accepts arbitrary report names @@ -94,7 +94,7 @@ impl Subcommand { Info::get_usage(u); Gc::get_usage(u); Sync::get_usage(u); - Import::get_usage(u); + ImportTW::get_usage(u); ImportTDB2::get_usage(u); Undo::get_usage(u); Report::get_usage(u); @@ -433,21 +433,21 @@ impl Sync { } } -struct Import; +struct ImportTW; -impl Import { +impl ImportTW { fn parse(input: ArgList) -> IResult { fn to_subcommand(_: &str) -> Result { - Ok(Subcommand::Import) + Ok(Subcommand::ImportTW) } - map_res(arg_matching(literal("import")), to_subcommand)(input) + map_res(arg_matching(literal("import-tw")), to_subcommand)(input) } fn get_usage(u: &mut usage::Usage) { u.subcommands.push(usage::Subcommand { - name: "import", - syntax: "import", - summary: "Import tasks", + name: "import-tw", + syntax: "import-tw", + summary: "Import tasks from TaskWarrior export", description: " Import tasks into this replica. diff --git a/cli/src/invocation/cmd/import.rs b/cli/src/invocation/cmd/import_tw.rs similarity index 100% rename from cli/src/invocation/cmd/import.rs rename to cli/src/invocation/cmd/import_tw.rs diff --git a/cli/src/invocation/cmd/mod.rs b/cli/src/invocation/cmd/mod.rs index 59484ea0b..b5d1a21d6 100644 --- a/cli/src/invocation/cmd/mod.rs +++ b/cli/src/invocation/cmd/mod.rs @@ -4,8 +4,8 @@ pub(crate) mod add; pub(crate) mod config; pub(crate) mod gc; pub(crate) mod help; -pub(crate) mod import; pub(crate) mod import_tdb2; +pub(crate) mod import_tw; pub(crate) mod info; pub(crate) mod modify; pub(crate) mod report; diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index 7bc1c5616..0ae3f44e0 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -91,10 +91,10 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate:: } Command { - subcommand: Subcommand::Import, + subcommand: Subcommand::ImportTW, .. } => { - return cmd::import::execute(&mut w, &mut replica); + return cmd::import_tw::execute(&mut w, &mut replica); } Command {