mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-06-26 10:54:26 +02:00
Implement 'ta import'
Tests include "TODO" notes for data not handled by TaskChampion, including links to the associated GitHub issues.
This commit is contained in:
parent
e2e0951c81
commit
63804b5652
7 changed files with 311 additions and 4 deletions
|
@ -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"
|
||||
|
|
|
@ -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<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
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 {
|
||||
|
|
257
cli/src/invocation/cmd/import.rs
Normal file
257
cli/src/invocation/cmd/import.rs
Normal file
|
@ -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: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
|
||||
writeln!(w, "Importing tasks from stdin.")?;
|
||||
let tasks: Vec<HashMap<String, Value>> =
|
||||
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<String> {
|
||||
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<DateTime<Utc>, 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<Utc>);
|
||||
|
||||
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: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
// TOOD: take this by value and consume it
|
||||
task_json: &HashMap<String, Value>,
|
||||
) -> 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<Annotation> = 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<String> = 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<String> = 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<_>>(),
|
||||
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(())
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
..
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue