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