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> {