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..22c270ba3 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -59,6 +59,10 @@ pub(crate) enum Subcommand { /// Basic operations without args Gc, Sync, + ImportTW, + ImportTDB2 { + path: String, + }, Undo, } @@ -73,6 +77,8 @@ impl Subcommand { Info::parse, Gc::parse, Sync::parse, + ImportTW::parse, + ImportTDB2::parse, Undo::parse, // This must come last since it accepts arbitrary report names Report::parse, @@ -88,6 +94,9 @@ impl Subcommand { Info::get_usage(u); Gc::get_usage(u); Sync::get_usage(u); + ImportTW::get_usage(u); + ImportTDB2::get_usage(u); + Undo::get_usage(u); Report::get_usage(u); } } @@ -424,6 +433,66 @@ impl Sync { } } +struct ImportTW; + +impl ImportTW { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::ImportTW) + } + map_res(arg_matching(literal("import-tw")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "import-tw", + syntax: "import-tw", + summary: "Import tasks from TaskWarrior export", + 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 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_tdb2.rs b/cli/src/invocation/cmd/import_tdb2.rs new file mode 100644 index 000000000..8db967699 --- /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.import_task_with_uuid(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/import_tw.rs b/cli/src/invocation/cmd/import_tw.rs new file mode 100644 index 000000000..249c1ad46 --- /dev/null +++ b/cli/src/invocation/cmd/import_tw.rs @@ -0,0 +1,265 @@ +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::{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"))?; + + for task_json in tasks.drain(..) { + import_task(w, replica, task_json)?; + } + + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "{} tasks imported.", tasks.len())?; + w.reset()?; + + 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(s) => s, + 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, + mut 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.import_task_with_uuid(uuid)?; + + let mut description = None; + for (k, v) in task_json.drain() { + 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)?; + 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)?; + 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)?; + 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)?; + replica.update_task(uuid, k, Some(v.tc_timestamp()))?; + } + + // everything else is inserted directly + _ => { + let v = stringify(v)?; + if k == "description" { + // keep a copy of the description for console output + description = Some(v.clone()); + } + replica.update_task(uuid, k, Some(v))?; + } + } + } + + 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 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..b5d1a21d6 100644 --- a/cli/src/invocation/cmd/mod.rs +++ b/cli/src/invocation/cmd/mod.rs @@ -4,6 +4,8 @@ pub(crate) mod add; pub(crate) mod config; pub(crate) mod gc; pub(crate) mod help; +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/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 41a8c4ce2..0ae3f44e0 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -90,6 +90,20 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate:: return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server); } + Command { + subcommand: Subcommand::ImportTW, + .. + } => { + return cmd::import_tw::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/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..0ff59a311 --- /dev/null +++ b/cli/src/tdb2/mod.rs @@ -0,0 +1,326 @@ +//! 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 { + File::parse(input).map(|(_, res)| res).map_err(|_| ()) + } + + fn parse(input: &str) -> IResult<&str, File> { + all_consuming(fold_many0( + // allow windows or normal newlines + terminated(Line::parse, pair(opt(char('\r')), 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"] diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index 5a02a59d6..8d673b23c 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, @@ -99,19 +102,28 @@ 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(); + self.add_undo_point(false)?; let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; - trace!("task {} created", uuid); let mut task = Task::new(uuid, taskmap).into_mut(self); task.set_description(description)?; task.set_status(status)?; task.set_entry(Utc::now())?; + 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 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)) + } + /// 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/task/task.rs b/taskchampion/src/task/task.rs index 12832103a..452c85e82 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::*; @@ -119,6 +119,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> { 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()?; }