Merge pull request #329 from djmitche/issue94

Implement `ta import` and `ta import-tdb2`
This commit is contained in:
Dustin J. Mitchell 2022-01-24 10:23:22 -05:00 committed by GitHub
commit e7d4e1e8f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 885 additions and 30 deletions

1
Cargo.lock generated
View file

@ -2997,6 +2997,7 @@ dependencies = [
"pretty_assertions", "pretty_assertions",
"prettytable-rs", "prettytable-rs",
"rstest", "rstest",
"serde",
"serde_json", "serde_json",
"taskchampion", "taskchampion",
"tempfile", "tempfile",

View file

@ -22,7 +22,9 @@ termcolor = "^1.1.2"
atty = "^0.2.14" atty = "^0.2.14"
toml = "^0.5.8" toml = "^0.5.8"
toml_edit = "^0.2.0" 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" lazy_static = "1"
iso8601-duration = "0.1" iso8601-duration = "0.1"
dialoguer = "0.8" dialoguer = "0.8"
@ -30,7 +32,6 @@ dialoguer = "0.8"
# only needed for usage-docs # 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 # 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 } mdbook = { version = "0.4.10", optional = true }
serde_json = { version = "*", optional = true }
[dependencies.taskchampion] [dependencies.taskchampion]
path = "../taskchampion" path = "../taskchampion"
@ -46,7 +47,7 @@ rstest = "0.10"
pretty_assertions = "1" pretty_assertions = "1"
[features] [features]
usage-docs = [ "mdbook", "serde_json" ] usage-docs = [ "mdbook" ]
[[bin]] [[bin]]
name = "ta" name = "ta"

View file

@ -59,6 +59,10 @@ pub(crate) enum Subcommand {
/// Basic operations without args /// Basic operations without args
Gc, Gc,
Sync, Sync,
ImportTW,
ImportTDB2 {
path: String,
},
Undo, Undo,
} }
@ -73,6 +77,8 @@ impl Subcommand {
Info::parse, Info::parse,
Gc::parse, Gc::parse,
Sync::parse, Sync::parse,
ImportTW::parse,
ImportTDB2::parse,
Undo::parse, Undo::parse,
// This must come last since it accepts arbitrary report names // This must come last since it accepts arbitrary report names
Report::parse, Report::parse,
@ -88,6 +94,9 @@ impl Subcommand {
Info::get_usage(u); Info::get_usage(u);
Gc::get_usage(u); Gc::get_usage(u);
Sync::get_usage(u); Sync::get_usage(u);
ImportTW::get_usage(u);
ImportTDB2::get_usage(u);
Undo::get_usage(u);
Report::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; struct Undo;
impl Undo { impl Undo {

View file

@ -0,0 +1 @@
[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]

View 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(())
}
}

View 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(())
}
}

View file

@ -4,6 +4,8 @@ pub(crate) mod add;
pub(crate) mod config; pub(crate) mod config;
pub(crate) mod gc; pub(crate) mod gc;
pub(crate) mod help; pub(crate) mod help;
pub(crate) mod import_tdb2;
pub(crate) mod import_tw;
pub(crate) mod info; pub(crate) mod info;
pub(crate) mod modify; pub(crate) mod modify;
pub(crate) mod report; pub(crate) mod report;

View file

@ -0,0 +1 @@
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]

View file

@ -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); 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 { Command {
subcommand: Subcommand::Undo, subcommand: Subcommand::Undo,
.. ..

View file

@ -41,6 +41,7 @@ mod errors;
mod invocation; mod invocation;
mod settings; mod settings;
mod table; mod table;
mod tdb2;
mod usage; mod usage;
/// See https://docs.rs/built /// See https://docs.rs/built

326
cli/src/tdb2/mod.rs Normal file
View 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
View 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"]

View file

@ -47,7 +47,10 @@ impl Replica {
/// Update an existing task. If the value is Some, the property is added or updated. If the /// 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 /// value is None, the property is deleted. It is not an error to delete a nonexistent
/// property. /// 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, &mut self,
uuid: Uuid, uuid: Uuid,
property: S1, property: S1,
@ -99,19 +102,28 @@ impl Replica {
.map(move |tm| Task::new(uuid, tm))) .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> { pub fn new_task(&mut self, status: Status, description: String) -> anyhow::Result<Task> {
self.add_undo_point(false)?;
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
self.add_undo_point(false)?;
let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?;
trace!("task {} created", uuid);
let mut task = Task::new(uuid, taskmap).into_mut(self); let mut task = Task::new(uuid, taskmap).into_mut(self);
task.set_description(description)?; task.set_description(description)?;
task.set_status(status)?; task.set_status(status)?;
task.set_entry(Utc::now())?; task.set_entry(Utc::now())?;
trace!("task {} created", uuid);
Ok(task.into_immut()) 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 /// 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 /// Deleted; this is the final purge of the task. This is not a public method as deletion
/// should only occur through expiration. /// should only occur through expiration.

View file

@ -1,5 +1,5 @@
use super::tag::{SyntheticTag, TagInner}; use super::tag::{SyntheticTag, TagInner};
use super::{Annotation, Status, Tag, Timestamp}; use super::{Annotation, Priority, Status, Tag, Timestamp};
use crate::replica::Replica; use crate::replica::Replica;
use crate::storage::TaskMap; use crate::storage::TaskMap;
use chrono::prelude::*; use chrono::prelude::*;
@ -119,6 +119,13 @@ impl Task {
.unwrap_or("") .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 /// Get the wait time. If this value is set, it will be returned, even
/// if it is in the past. /// if it is in the past.
pub fn get_wait(&self) -> Option<DateTime<Utc>> { pub fn get_wait(&self) -> Option<DateTime<Utc>> {

View file

@ -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 /// 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 /// 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> { pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow::Result<TaskMap> {
match op { match op {
SyncOp::Create { uuid } => { SyncOp::Create { uuid } => {
@ -14,8 +15,9 @@ pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow::
txn.commit()?; txn.commit()?;
Ok(TaskMap::new()) Ok(TaskMap::new())
} else { } else {
// TODO: differentiate error types here? Ok(txn
Err(Error::Database(format!("Task {} already exists", uuid)).into()) .get_task(uuid)?
.expect("create_task failed but task does not exist"))
} }
} }
SyncOp::Delete { uuid } => { SyncOp::Delete { uuid } => {
@ -29,7 +31,7 @@ pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow::
txn.commit()?; txn.commit()?;
Ok(TaskMap::new()) Ok(TaskMap::new())
} else { } else {
Err(Error::Database(format!("Task {} does not exist", uuid)).into()) Ok(TaskMap::new())
} }
} }
SyncOp::Update { SyncOp::Update {
@ -105,6 +107,7 @@ pub(super) fn apply_op(txn: &mut dyn StorageTxn, op: &SyncOp) -> anyhow::Result<
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::storage::TaskMap;
use crate::taskdb::TaskDb; use crate::taskdb::TaskDb;
use chrono::Utc; use chrono::Utc;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@ -133,24 +136,33 @@ mod tests {
fn test_apply_create_exists() -> anyhow::Result<()> { fn test_apply_create_exists() -> anyhow::Result<()> {
let mut db = TaskDb::new_inmemory(); let mut db = TaskDb::new_inmemory();
let uuid = Uuid::new_v4(); 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 op = SyncOp::Create { uuid };
{ {
let mut txn = db.storage.txn()?; let mut txn = db.storage.txn()?;
let taskmap = apply_and_record(txn.as_mut(), op.clone())?; let taskmap = apply_and_record(txn.as_mut(), op.clone())?;
assert_eq!(taskmap.len(), 0);
assert_eq!( assert_eq!(taskmap.len(), 1);
apply_and_record(txn.as_mut(), op) assert_eq!(taskmap.get("foo").unwrap(), "bar");
.err()
.unwrap()
.to_string(),
format!("Task Database Error: Task {} already exists", uuid)
);
txn.commit()?; txn.commit()?;
} }
// first op was applied // create did not delete the old task..
assert_eq!(db.sorted_tasks(), vec![(uuid, vec![])]); assert_eq!(
assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]); 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(()) Ok(())
} }
@ -384,13 +396,8 @@ mod tests {
let op = SyncOp::Delete { uuid }; let op = SyncOp::Delete { uuid };
{ {
let mut txn = db.storage.txn()?; let mut txn = db.storage.txn()?;
assert_eq!( let taskmap = apply_and_record(txn.as_mut(), op)?;
apply_and_record(txn.as_mut(), op) assert_eq!(taskmap.len(), 0);
.err()
.unwrap()
.to_string(),
format!("Task Database Error: Task {} does not exist", uuid)
);
txn.commit()?; txn.commit()?;
} }