mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-06-26 10:54:26 +02:00
Merge pull request #329 from djmitche/issue94
Implement `ta import` and `ta import-tdb2`
This commit is contained in:
commit
e7d4e1e8f3
15 changed files with 885 additions and 30 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2997,6 +2997,7 @@ dependencies = [
|
|||
"pretty_assertions",
|
||||
"prettytable-rs",
|
||||
"rstest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"taskchampion",
|
||||
"tempfile",
|
||||
|
|
|
@ -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,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<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
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<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (&str, &str)) -> Result<Subcommand, ()> {
|
||||
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 <directory>",
|
||||
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 {
|
||||
|
|
1
cli/src/invocation/cmd/completed.data
Normal file
1
cli/src/invocation/cmd/completed.data
Normal file
|
@ -0,0 +1 @@
|
|||
[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]
|
142
cli/src/invocation/cmd/import_tdb2.rs
Normal file
142
cli/src/invocation/cmd/import_tdb2.rs
Normal file
|
@ -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: WriteColor>(
|
||||
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: WriteColor>(
|
||||
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(())
|
||||
}
|
||||
}
|
265
cli/src/invocation/cmd/import_tw.rs
Normal file
265
cli/src/invocation/cmd/import_tw.rs
Normal file
|
@ -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: WriteColor>(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<HashMap<String, Value>> =
|
||||
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<String> {
|
||||
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<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,
|
||||
mut 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.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<Annotation> = 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<String> = 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<String> = 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<_>>(),
|
||||
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,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;
|
||||
|
|
1
cli/src/invocation/cmd/pending.data
Normal file
1
cli/src/invocation/cmd/pending.data
Normal file
|
@ -0,0 +1 @@
|
|||
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]
|
|
@ -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,
|
||||
..
|
||||
|
|
|
@ -41,6 +41,7 @@ mod errors;
|
|||
mod invocation;
|
||||
mod settings;
|
||||
mod table;
|
||||
mod tdb2;
|
||||
mod usage;
|
||||
|
||||
/// See https://docs.rs/built
|
||||
|
|
326
cli/src/tdb2/mod.rs
Normal file
326
cli/src/tdb2/mod.rs
Normal file
|
@ -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<Line>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct Line {
|
||||
pub(crate) attrs: Vec<Attr>,
|
||||
}
|
||||
|
||||
#[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, ()> {
|
||||
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<Attr>) -> Result<Line, ()> {
|
||||
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<Attr, ()> {
|
||||
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(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
6
cli/src/tdb2/test.data
Normal file
6
cli/src/tdb2/test.data
Normal file
|
@ -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"]
|
|
@ -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<S1, S2>(
|
||||
///
|
||||
/// 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<S1, S2>(
|
||||
&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<Task> {
|
||||
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<Task> {
|
||||
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.
|
||||
|
|
|
@ -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<DateTime<Utc>> {
|
||||
|
|
|
@ -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<TaskMap> {
|
||||
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()?;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue