Restore support for task info journal (#3671)

This support was removed before Taskwarrior-3.x, and is now restored,
including the original tests removed in
ddd367232e
This commit is contained in:
Dustin J. Mitchell 2024-11-06 07:39:39 -05:00 committed by GitHub
parent 7da23aee1c
commit c9967c20e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 438 additions and 101 deletions

View file

@ -13,6 +13,7 @@ add_library (task STATIC CLI2.cpp CLI2.h
Filter.cpp Filter.h
Hooks.cpp Hooks.h
Lexer.cpp Lexer.h
Operation.cpp Operation.h
TDB2.cpp TDB2.h
Task.cpp Task.h
Variant.cpp Variant.h

73
src/Operation.cpp Normal file
View file

@ -0,0 +1,73 @@
////////////////////////////////////////////////////////////////////////////////
//
// Copyright 2006 - 2024, Tomas Babej, Paul Beckingham, Federico Hernandez.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// https://www.opensource.org/licenses/mit-license.php
//
////////////////////////////////////////////////////////////////////////////////
#include <cmake.h>
// cmake.h include header must come first
#include <Operation.h>
#include <taskchampion-cpp/lib.h>
#include <vector>
////////////////////////////////////////////////////////////////////////////////
Operation::Operation(const tc::Operation& op) : op(&op) {}
////////////////////////////////////////////////////////////////////////////////
std::vector<Operation> Operation::operations(const rust::Vec<tc::Operation>& operations) {
return {operations.begin(), operations.end()};
}
////////////////////////////////////////////////////////////////////////////////
Operation& Operation::operator=(const Operation& other) {
op = other.op;
return *this;
}
////////////////////////////////////////////////////////////////////////////////
bool Operation::operator<(Operation& other) const {
if (is_create()) {
return !other.is_create();
} else if (is_update()) {
if (other.is_create()) {
return false;
} else if (other.is_update()) {
return get_timestamp() < other.get_timestamp();
} else {
return true;
}
} else if (is_delete()) {
if (other.is_create() || other.is_update() || other.is_delete()) {
return false;
} else {
return true;
}
} else if (is_undo_point()) {
return !other.is_undo_point();
}
return false; // not reachable
}
////////////////////////////////////////////////////////////////////////////////

88
src/Operation.h Normal file
View file

@ -0,0 +1,88 @@
////////////////////////////////////////////////////////////////////////////////
//
// Copyright 2006 - 2024, Tomas Babej, Paul Beckingham, Federico Hernandez.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
// https://www.opensource.org/licenses/mit-license.php
//
////////////////////////////////////////////////////////////////////////////////
#ifndef INCLUDED_OPERATIOn
#define INCLUDED_OPERATIOn
#include <taskchampion-cpp/lib.h>
#include <optional>
#include <vector>
// Representation of a TaskChampion operation.
//
// This class wraps `tc::Operation&` and thus cannot outlive that underlying
// type.
class Operation {
public:
explicit Operation(const tc::Operation &);
Operation(const Operation &other) = default;
Operation &operator=(const Operation &other);
// Create a vector of Operations given the result of `Replica::get_undo_operations` or
// `Replica::get_task_operations`. The resulting vector must not outlive the input `rust::Vec`.
static std::vector<Operation> operations(const rust::Vec<tc::Operation> &);
// Methods from the underlying `tc::Operation`.
bool is_create() const { return op->is_create(); }
bool is_update() const { return op->is_update(); }
bool is_delete() const { return op->is_delete(); }
bool is_undo_point() const { return op->is_undo_point(); }
std::string get_uuid() const { return std::string(op->get_uuid().to_string()); }
::rust::Vec<::tc::PropValuePair> get_old_task() const { return op->get_old_task(); };
std::string get_property() const {
std::string value;
op->get_property(value);
return value;
}
std::optional<std::string> get_value() const {
std::optional<std::string> value{std::string()};
if (!op->get_value(value.value())) {
value = std::nullopt;
}
return value;
}
std::optional<std::string> get_old_value() const {
std::optional<std::string> old_value{std::string()};
if (!op->get_old_value(old_value.value())) {
old_value = std::nullopt;
}
return old_value;
}
time_t get_timestamp() const { return static_cast<time_t>(op->get_timestamp()); }
// Define a partial order on Operations:
// - Create < Update < Delete < UndoPoint
// - Given two updates, sort by timestamp
bool operator<(Operation &other) const;
private:
const tc::Operation *op;
};
#endif
////////////////////////////////////////////////////////////////////////////////

View file

@ -244,8 +244,7 @@ void TDB2::revert() {
////////////////////////////////////////////////////////////////////////////////
bool TDB2::confirm_revert(rust::Vec<tc::Operation>& undo_ops) {
// TODO Use show_diff rather than this basic listing of operations, though
// this might be a worthy undo.style itself.
// TODO: convert to Operation and use that type for display, similar to CmdInfo.
// Count non-undo operations
int ops_count = 0;

View file

@ -1243,14 +1243,14 @@ void Task::fixTagsAttribute() {
bool Task::isTagAttr(const std::string& attr) { return attr.compare(0, 4, "tag_") == 0; }
////////////////////////////////////////////////////////////////////////////////
const std::string Task::tag2Attr(const std::string& tag) const {
std::string Task::tag2Attr(const std::string& tag) {
std::stringstream tag_attr;
tag_attr << "tag_" << tag;
return tag_attr.str();
}
////////////////////////////////////////////////////////////////////////////////
const std::string Task::attr2Tag(const std::string& attr) const {
std::string Task::attr2Tag(const std::string& attr) {
assert(isTagAttr(attr));
return attr.substr(4);
}
@ -1271,14 +1271,14 @@ void Task::fixDependsAttribute() {
bool Task::isDepAttr(const std::string& attr) { return attr.compare(0, 4, "dep_") == 0; }
////////////////////////////////////////////////////////////////////////////////
const std::string Task::dep2Attr(const std::string& tag) const {
std::string Task::dep2Attr(const std::string& tag) {
std::stringstream tag_attr;
tag_attr << "dep_" << tag;
return tag_attr.str();
}
////////////////////////////////////////////////////////////////////////////////
const std::string Task::attr2Dep(const std::string& attr) const {
std::string Task::attr2Dep(const std::string& attr) {
assert(isDepAttr(attr));
return attr.substr(4);
}
@ -2151,92 +2151,6 @@ std::string Task::diff(const Task& after) const {
return out.str();
}
////////////////////////////////////////////////////////////////////////////////
// Similar to diff, but formatted for inclusion in the output of the info command
std::string Task::diffForInfo(const Task& after, const std::string& dateformat,
long& last_timestamp, const long current_timestamp) const {
// Attributes are all there is, so figure the different attribute names
// between before and after.
std::vector<std::string> beforeAtts;
for (auto& att : data) beforeAtts.push_back(att.first);
std::vector<std::string> afterAtts;
for (auto& att : after.data) afterAtts.push_back(att.first);
std::vector<std::string> beforeOnly;
std::vector<std::string> afterOnly;
listDiff(beforeAtts, afterAtts, beforeOnly, afterOnly);
// Now start generating a description of the differences.
std::stringstream out;
for (auto& name : beforeOnly) {
if (isAnnotationAttr(name)) {
out << format("Annotation '{1}' deleted.\n", get(name));
} else if (isTagAttr(name)) {
out << format("Tag '{1}' deleted.\n", attr2Tag(name));
} else if (isDepAttr(name)) {
out << format("Dependency on '{1}' deleted.\n", attr2Dep(name));
} else if (name == "depends" || name == "tags") {
// do nothing for legacy attributes
} else if (name == "start") {
Datetime started(get("start"));
Datetime stopped;
if (after.has("end"))
// Task was marked as finished, use end time
stopped = Datetime(after.get("end"));
else
// Start attribute was removed, use modification time
stopped = Datetime(current_timestamp);
out << format("{1} deleted (duration: {2}).", Lexer::ucFirst(name),
Duration(stopped - started).format())
<< "\n";
} else {
out << format("{1} deleted.\n", Lexer::ucFirst(name));
}
}
for (auto& name : afterOnly) {
if (isAnnotationAttr(name)) {
out << format("Annotation of '{1}' added.\n", after.get(name));
} else if (isTagAttr(name)) {
out << format("Tag '{1}' added.\n", attr2Tag(name));
} else if (isDepAttr(name)) {
out << format("Dependency on '{1}' added.\n", attr2Dep(name));
} else if (name == "depends" || name == "tags") {
// do nothing for legacy attributes
} else {
if (name == "start") last_timestamp = current_timestamp;
out << format("{1} set to '{2}'.", Lexer::ucFirst(name),
renderAttribute(name, after.get(name), dateformat))
<< "\n";
}
}
for (auto& name : beforeAtts)
if (name != "uuid" && name != "modified" && get(name) != after.get(name) && get(name) != "" &&
after.get(name) != "") {
if (name == "depends" || name == "tags") {
// do nothing for legacy attributes
} else if (isTagAttr(name) || isDepAttr(name)) {
// ignore new attributes
} else if (isAnnotationAttr(name)) {
out << format("Annotation changed to '{1}'.\n", after.get(name));
} else
out << format("{1} changed from '{2}' to '{3}'.", Lexer::ucFirst(name),
renderAttribute(name, get(name), dateformat),
renderAttribute(name, after.get(name), dateformat))
<< "\n";
}
// Shouldn't just say nothing.
if (out.str().length() == 0) out << "No changes made.\n";
return out.str();
}
////////////////////////////////////////////////////////////////////////////////
// Similar to diff, but formatted as a side-by-side table for an Undo preview
Table Task::diffForUndoSide(const Task& after) const {

View file

@ -164,6 +164,11 @@ class Task {
void substitute(const std::string&, const std::string&, const std::string&);
#endif
static std::string tag2Attr(const std::string&);
static std::string attr2Tag(const std::string&);
static std::string dep2Attr(const std::string&);
static std::string attr2Dep(const std::string&);
void validate_add();
void validate(bool applyDefault = true);
@ -176,8 +181,6 @@ class Task {
#endif
std::string diff(const Task& after) const;
std::string diffForInfo(const Task& after, const std::string& dateformat, long& last_timestamp,
const long current_timestamp) const;
Table diffForUndoSide(const Task& after) const;
Table diffForUndoPatch(const Task& after, const Datetime& lastChange) const;
@ -190,10 +193,6 @@ class Task {
void validate_before(const std::string&, const std::string&);
const std::string encode(const std::string&) const;
const std::string decode(const std::string&) const;
const std::string tag2Attr(const std::string&) const;
const std::string attr2Tag(const std::string&) const;
const std::string dep2Attr(const std::string&) const;
const std::string attr2Dep(const std::string&) const;
void fixDependsAttribute();
void fixTagsAttribute();

View file

@ -33,13 +33,16 @@
#include <Duration.h>
#include <Filter.h>
#include <Lexer.h>
#include <Operation.h>
#include <format.h>
#include <main.h>
#include <math.h>
#include <shared.h>
#include <stdlib.h>
#include <taskchampion-cpp/lib.h>
#include <util.h>
#include <algorithm>
#include <sstream>
////////////////////////////////////////////////////////////////////////////////
@ -477,9 +480,68 @@ int CmdInfo::execute(std::string& output) {
urgencyDetails.set(row, 5, rightJustify(format(task.urgency(), 4, 4), 6));
}
// Create a third table, containing undo log change details.
Table journal;
setHeaderUnderline(journal);
if (Context::getContext().config.getBoolean("obfuscate")) journal.obfuscate();
if (Context::getContext().config.getBoolean("color")) journal.forceColor();
journal.width(Context::getContext().getWidth());
journal.add("Date");
journal.add("Modification");
if (Context::getContext().config.getBoolean("journal.info")) {
auto& replica = Context::getContext().tdb2.replica();
tc::Uuid tcuuid = tc::uuid_from_string(uuid);
auto tcoperations = replica->get_task_operations(tcuuid);
auto operations = Operation::operations(tcoperations);
// Sort by type (Create < Update < Delete < UndoPoint) and then by timestamp.
std::sort(operations.begin(), operations.end());
long last_timestamp = 0;
for (size_t i = 0; i < operations.size(); i++) {
auto& op = operations[i];
// Only display updates -- creation and deletion aren't interesting.
if (!op.is_update()) {
continue;
}
// Group operations that occur within 1s of this one. This is a heuristic
// for operations performed in the same `task` invocation, and allows e.g.,
// `task done end:-2h` to take the updated `end` value into account. It also
// groups these events into a single "row" of the table for better layout.
size_t group_start = i;
for (i++; i < operations.size(); i++) {
auto& op2 = operations[i];
if (!op2.is_update() || op2.get_timestamp() - op.get_timestamp() > 1) {
break;
}
}
size_t group_end = i;
i--;
std::optional<std::string> msg =
formatForInfo(operations, group_start, group_end, dateformat, last_timestamp);
if (!msg) {
continue;
}
int row = journal.addRow();
Datetime timestamp(op.get_timestamp());
journal.set(row, 0, timestamp.toString(dateformat));
journal.set(row, 1, *msg);
}
}
out << optionalBlankLine() << view.render() << '\n';
if (urgencyDetails.rows() > 0) out << urgencyDetails.render() << '\n';
if (journal.rows() > 0) out << journal.render() << '\n';
}
output = out.str();
@ -502,3 +564,105 @@ void CmdInfo::urgencyTerm(Table& view, const std::string& label, float measure,
}
////////////////////////////////////////////////////////////////////////////////
std::optional<std::string> CmdInfo::formatForInfo(const std::vector<Operation>& operations,
size_t group_start, size_t group_end,
const std::string& dateformat, long& last_start) {
std::stringstream out;
for (auto i = group_start; i < group_end; i++) {
auto& operation = operations[i];
assert(operation.is_update());
// Extract the parts of the Update operation.
std::string prop = operation.get_property();
std::optional<std::string> value = operation.get_value();
std::optional<std::string> old_value = operation.get_old_value();
Datetime timestamp(operation.get_timestamp());
// Never care about modifying the modification time, or the legacy properties `depends` and
// `tags`.
if (prop == "modified" || prop == "depends" || prop == "tags") {
continue;
}
// Handle property deletions
if (!value && old_value) {
if (Task::isAnnotationAttr(prop)) {
out << format("Annotation '{1}' deleted.\n", *old_value);
} else if (Task::isTagAttr(prop)) {
out << format("Tag '{1}' deleted.\n", Task::attr2Tag(prop));
} else if (Task::isDepAttr(prop)) {
out << format("Dependency on '{1}' deleted.\n", Task::attr2Dep(prop));
} else if (prop == "start") {
Datetime started(last_start);
Datetime stopped = timestamp;
// If any update in this group sets the `end` property, use that instead of the
// timestamp deleting the `start` property as the stop time.
// See https://github.com/GothenburgBitFactory/taskwarrior/issues/2514
for (auto i = group_start; i < group_end; i++) {
auto& op = operations[i];
assert(op.is_update());
if (op.get_property() == "end") {
try {
stopped = op.get_value().value();
} catch (std::string) {
// Fall back to the 'start' timestamp if its value is un-parseable.
stopped = op.get_timestamp();
}
}
}
out << format("{1} deleted (duration: {2}).", Lexer::ucFirst(prop),
Duration(stopped - started).format())
<< "\n";
} else {
out << format("{1} deleted.\n", Lexer::ucFirst(prop));
}
}
// Handle property additions.
if (value && !old_value) {
if (Task::isAnnotationAttr(prop)) {
out << format("Annotation of '{1}' added.\n", *value);
} else if (Task::isTagAttr(prop)) {
out << format("Tag '{1}' added.\n", Task::attr2Tag(prop));
} else if (Task::isDepAttr(prop)) {
out << format("Dependency on '{1}' added.\n", Task::attr2Dep(prop));
} else {
// Record the last start time for later duration calculation.
if (prop == "start") {
last_start = Datetime(value.value()).toEpoch();
}
out << format("{1} set to '{2}'.", Lexer::ucFirst(prop),
renderAttribute(prop, *value, dateformat))
<< "\n";
}
}
// Handle property changes.
if (value && old_value) {
if (Task::isTagAttr(prop) || Task::isDepAttr(prop)) {
// Dependencies and tags do not have meaningful values.
} else if (Task::isAnnotationAttr(prop)) {
out << format("Annotation changed to '{1}'.\n", *value);
} else {
// Record the last start time for later duration calculation.
if (prop == "start") {
last_start = Datetime(value.value()).toEpoch();
}
out << format("{1} changed from '{2}' to '{3}'.", Lexer::ucFirst(prop),
renderAttribute(prop, *old_value, dateformat),
renderAttribute(prop, *value, dateformat))
<< "\n";
}
}
}
if (out.str().length() == 0) return std::nullopt;
return out.str();
}
////////////////////////////////////////////////////////////////////////////////

View file

@ -28,9 +28,12 @@
#define INCLUDED_CMDINFO
#include <Command.h>
#include <Operation.h>
#include <Table.h>
#include <taskchampion-cpp/lib.h>
#include <string>
#include <vector>
class CmdInfo : public Command {
public:
@ -39,6 +42,10 @@ class CmdInfo : public Command {
private:
void urgencyTerm(Table&, const std::string&, float, float) const;
// Format a group of update operations for display in `task info`.
std::optional<std::string> formatForInfo(const std::vector<Operation>& operations,
size_t group_start, size_t group_end,
const std::string& dateformat, long& last_start);
};
#endif

View file

@ -133,6 +133,9 @@ mod ffi {
/// Get an existing task by its UUID.
fn get_task_data(&mut self, uuid: Uuid) -> Result<OptionTaskData>;
/// Get the operations for a task task by its UUID.
fn get_task_operations(&mut self, uuid: Uuid) -> Result<Vec<Operation>>;
/// Return the operations back to and including the last undo point, or since the last sync if
/// no undo point is found.
fn get_undo_operations(&mut self) -> Result<Vec<Operation>>;
@ -529,6 +532,10 @@ impl Replica {
Ok(self.0.get_task_data(uuid.into())?.into())
}
fn get_task_operations(&mut self, uuid: ffi::Uuid) -> Result<Vec<Operation>, CppError> {
Ok(from_tc_operations(self.0.get_task_operations(uuid.into())?))
}
fn get_undo_operations(&mut self) -> Result<Vec<Operation>, CppError> {
Ok(from_tc_operations(self.0.get_undo_operations()?))
}
@ -921,6 +928,26 @@ mod test {
assert_eq!(t.take().get_uuid(), uuid);
}
#[test]
fn get_task_operations() {
cxx::let_cxx_string!(prop = "prop");
cxx::let_cxx_string!(value = "value");
let mut rep = new_replica_in_memory().unwrap();
let uuid = uuid_v4();
assert!(rep.get_task_operations(uuid).unwrap().is_empty());
let mut operations = new_operations();
let mut t = create_task(uuid, &mut operations);
t.update(&prop, &value, &mut operations);
rep.commit_operations(operations).unwrap();
let ops = rep.get_task_operations(uuid).unwrap();
assert_eq!(ops.len(), 2);
assert!(ops[0].is_create());
assert!(ops[1].is_update());
}
#[test]
fn task_properties() {
cxx::let_cxx_string!(prop = "prop");

View file

@ -174,6 +174,7 @@ set (pythonTests
timesheet.test.py
tw-1379.test.py
tw-1837.test.py
tw-1999.test.py
tw-20.test.py
tw-2575.test.py
tw-262.test.py

View file

@ -98,6 +98,9 @@ class TestInfoCommand(TestCase):
self.assertRegex(out, r"Urgency\s+\d+(\.\d+)?")
self.assertRegex(out, r"Priority\s+H")
self.assertRegex(out, r"Annotation of 'bar' added\.")
self.assertRegex(out, r"Tag 'tag' added\.")
self.assertRegex(out, r"tatus set to 'recurring'\.")
self.assertIn("project", out)
self.assertIn("active", out)
self.assertIn("annotations", out)

62
test/tw-1999.test.py Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
###############################################################################
#
# Copyright 2006 - 2021, Tomas Babej, Paul Beckingham, Federico Hernandez.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# https://www.opensource.org/licenses/mit-license.php
#
###############################################################################
import sys
import os
import unittest
# Ensure python finds the local simpletap module
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from basetest import Task, TestCase
class TestBug1999(TestCase):
"""Bug 1999: Taskwarrior reports wrong active time"""
def setUp(self):
self.t = Task()
def test_correct_active_time(self):
"""Ensure correct active time locally"""
desc = "Testing task"
self.t(("add", desc))
self.t(("start", "1"))
self.t.faketime("+10m")
self.t(("stop", "1"))
code, out, err = self.t(("info", "1"))
self.assertRegex(out, "duration: 0:10:0[0-5]")
if __name__ == "__main__":
from simpletap import TAPTestRunner
unittest.main(testRunner=TAPTestRunner())
# vim: ai sts=4 et sw=4 ft=python

View file

@ -6,7 +6,6 @@ task add Something I did yesterday
task 1 mod start:yesterday+18h
task 1 done end:yesterday+20h
# this does not work without journal.info
# Check that 2 hour interval is reported by task info
#task info | grep -F "Start deleted"
#[[ ! -z `task info | grep -F "Start deleted (duration: 2:00:00)."` ]]
task info | grep -F "Start deleted"
[[ ! -z `task info | grep -F "Start deleted (duration: 2:00:00)."` ]]