diff --git a/doc/man/task.1.in b/doc/man/task.1.in index 9a0444cc3..e7e8a71b9 100644 --- a/doc/man/task.1.in +++ b/doc/man/task.1.in @@ -427,6 +427,15 @@ Modifies the existing task with provided information. .B task prepend Prepends description text to an existing task. Is affected by the context. +.TP +.B task purge +Permanently removes the specified tasks from the data files. Only +tasks that are already deleted can be purged. This command has a +local-only effect and changes introduced by it are not synced. +Is affected by the context. + +Warning: causes permanent, non-revertible loss of data. + .TP .B task start Marks the specified tasks as started. Is affected by the context. @@ -1280,6 +1289,7 @@ active context. Here is a list of the commands that are affected: log prepend projects + purge start stats stop diff --git a/src/TDB2.cpp b/src/TDB2.cpp index 069e81b74..5bfb9133b 100644 --- a/src/TDB2.cpp +++ b/src/TDB2.cpp @@ -194,6 +194,13 @@ void TDB2::modify (Task& task) } } +//////////////////////////////////////////////////////////////////////////////// +void TDB2::purge (Task& task) +{ + auto uuid = task.get ("uuid"); + replica.delete_task (uuid); +} + //////////////////////////////////////////////////////////////////////////////// const tc::WorkingSet &TDB2::working_set () { diff --git a/src/TDB2.h b/src/TDB2.h index 8f83c9452..87bfa325d 100644 --- a/src/TDB2.h +++ b/src/TDB2.h @@ -53,6 +53,7 @@ public: void open_replica (const std::string&, bool create_if_missing); void add (Task&); void modify (Task&); + void purge (Task&); void get_changes (std::vector &); void revert (); void gc (); diff --git a/src/commands/CmdPurge.cpp b/src/commands/CmdPurge.cpp index ffde6543e..09550cdcd 100644 --- a/src/commands/CmdPurge.cpp +++ b/src/commands/CmdPurge.cpp @@ -27,13 +27,17 @@ #include #include #include +#include +#include +#include +#include //////////////////////////////////////////////////////////////////////////////// CmdPurge::CmdPurge () { _keyword = "purge"; _usage = "task purge"; - _description = "(deprecated; does nothing)"; + _description = "Removes the specified tasks from the data files. Causes permanent loss of data."; _read_only = false; _displays_id = false; _needs_confirm = true; @@ -46,11 +50,134 @@ CmdPurge::CmdPurge () } //////////////////////////////////////////////////////////////////////////////// -int CmdPurge::execute (std::string&) +// Purges the task, while taking care of: +// - dependencies on this task +// - child tasks +void CmdPurge::handleRelations (Task& task, std::vector& tasks) { - Context::getContext ().footnote ("As of version 3.0, this command has no effect."); - Context::getContext ().footnote ("Deleted tasks are removed from the task list automatically after they expire."); - return 0; + handleDeps (task); + handleChildren (task, tasks); + tasks.push_back(task); +} + +//////////////////////////////////////////////////////////////////////////////// +// Makes sure that any task having the dependency on the task being purged +// has that dependency removed, to preserve referential integrity. +void CmdPurge::handleDeps (Task& task) +{ + std::string uuid = task.get ("uuid"); + + for (auto& blockedConst: Context::getContext ().tdb2.all_tasks ()) + { + Task& blocked = const_cast(blockedConst); + if (blocked.hasDependency (uuid)) + { + blocked.removeDependency (uuid); + Context::getContext ().tdb2.modify (blocked); + } + } +} + +//////////////////////////////////////////////////////////////////////////////// +// Makes sure that with any recurrence parent are all the child tasks removed +// as well. If user chooses not to, the whole command is aborted. +void CmdPurge::handleChildren (Task& task, std::vector& tasks) +{ + // If this is not a recurrence parent, we have no job here + if (!task.has ("mask")) + return; + + std::string uuid = task.get ("uuid"); + std::vector children; + + // Find all child tasks + for (auto& childConst: Context::getContext ().tdb2.all_tasks ()) + { + Task& child = const_cast (childConst); + + if (child.get ("parent") == uuid) + { + if (child.getStatus () != Task::deleted) + // In case any child task is not deleted, bail out + throw format ("Task '{1}' is a recurrence template. Its child task {2} must be deleted before it can be purged.", + task.get ("description"), + child.identifier (true)); + else + children.push_back (child); + } + } + + // If there are no children, our job is done + if (children.empty ()) + return; + + // Ask for confirmation to purge them, if needed + std::string question = format ("Task '{1}' is a recurrence template. All its {2} deleted children tasks will be purged as well. Continue?", + task.get ("description"), + children.size ()); + + if (Context::getContext ().config.getBoolean ("recurrence.confirmation") || + (Context::getContext ().config.get ("recurrence.confirmation") == "prompt" + && confirm (question))) + { + for (auto& child: children) + handleRelations (child, tasks); + } + else + throw std::string ("Purge operation aborted."); +} + + +//////////////////////////////////////////////////////////////////////////////// +int CmdPurge::execute (std::string&) +{ + int rc = 0; + std::vector tasks; + bool matched_deleted = false; + + Filter filter; + std::vector filtered; + + // Apply filter. + filter.subset (filtered); + if (filtered.size () == 0) + { + Context::getContext ().footnote ("No tasks specified."); + return 1; + } + + for (auto& task : filtered) + { + // Allow purging of deleted tasks only. Hence no need to deal with: + // - unblocked tasks notifications (deleted tasks are not blocking) + // - project changes (deleted tasks not included in progress) + // It also has the nice property of being explicit - users need to + // mark tasks as deleted before purging. + if (task.getStatus () == Task::deleted) + { + // Mark that at least one deleted task matched the filter + matched_deleted = true; + + std::string question; + question = format ("Permanently remove task {1} '{2}'?", + task.identifier (true), + task.get ("description")); + + if (permission (question, filtered.size ())) + handleRelations (task, tasks); + } + } + + // Now that any exceptions are handled, actually purge the tasks. + for (auto& task: tasks) { + Context::getContext ().tdb2.purge (task); + } + + if (filtered.size () > 0 and ! matched_deleted) + Context::getContext ().footnote ("No deleted tasks specified. Maybe you forgot to delete tasks first?"); + + feedback_affected (tasks.size() == 1 ? "Purged {1} task." : "Purged {1} tasks.", tasks.size()); + return rc; } //////////////////////////////////////////////////////////////////////////////// diff --git a/src/commands/CmdPurge.h b/src/commands/CmdPurge.h index 96db2977d..c97fbf970 100644 --- a/src/commands/CmdPurge.h +++ b/src/commands/CmdPurge.h @@ -28,10 +28,15 @@ #define INCLUDED_CMDPURGE #include +#include #include class CmdPurge : public Command { +private: + void handleRelations (Task& task, std::vector& tasks); + void handleChildren (Task& task, std::vector& tasks); + void handleDeps (Task& task); public: CmdPurge (); int execute (std::string&); diff --git a/src/tc/Replica.cpp b/src/tc/Replica.cpp index bc3a4654c..f33a617dc 100644 --- a/src/tc/Replica.cpp +++ b/src/tc/Replica.cpp @@ -144,6 +144,15 @@ tc::Task tc::Replica::import_task_with_uuid (const std::string &uuid) return Task (tctask); } +//////////////////////////////////////////////////////////////////////////////// +void tc::Replica::delete_task (const std::string &uuid) +{ + auto res = tc_replica_delete_task (&*inner, uuid2tc (uuid)); + if (res != TC_RESULT_OK) { + throw replica_error (); + } +} + //////////////////////////////////////////////////////////////////////////////// void tc::Replica::expire_tasks () { diff --git a/src/tc/Replica.h b/src/tc/Replica.h index f0a10098c..4b0ff3eda 100644 --- a/src/tc/Replica.h +++ b/src/tc/Replica.h @@ -91,6 +91,7 @@ namespace tc { std::optional get_task (const std::string &uuid); tc::Task new_task (Status status, const std::string &description); tc::Task import_task_with_uuid (const std::string &uuid); + void delete_task (const std::string &uuid); // TODO: struct TCTask *tc_replica_import_task_with_uuid(struct TCReplica *rep, struct TCUuid tcuuid); void expire_tasks(); void sync(Server server, bool avoid_snapshots); diff --git a/src/tc/lib/src/replica.rs b/src/tc/lib/src/replica.rs index 9e63aa041..340ef878b 100644 --- a/src/tc/lib/src/replica.rs +++ b/src/tc/lib/src/replica.rs @@ -501,6 +501,35 @@ pub unsafe extern "C" fn tc_replica_import_task_with_uuid( ) } +#[ffizz_header::item] +#[ffizz(order = 902)] +/// 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. +/// +/// Deletion may interact poorly with modifications to the same task on other replicas. For +/// example, if a task is deleted on replica 1 and its description modified on replica 2, then +/// after both replicas have fully synced, the resulting task will only have a `description` +/// property. +/// +/// ```c +/// EXTERN_C TCResult tc_replica_delete_task(struct TCReplica *rep, struct TCUuid tcuuid); +/// ``` +#[no_mangle] +pub unsafe extern "C" fn tc_replica_delete_task(rep: *mut TCReplica, tcuuid: TCUuid) -> TCResult { + wrap( + rep, + |rep| { + // SAFETY: + // - tcuuid is a valid TCUuid (all bytes are valid) + // - tcuuid is Copy so ownership doesn't matter + let uuid = unsafe { TCUuid::val_from_arg(tcuuid) }; + rep.delete_task(uuid)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + #[ffizz_header::item] #[ffizz(order = 902)] /// Synchronize this replica with a server. diff --git a/src/tc/lib/taskchampion.h b/src/tc/lib/taskchampion.h index a8f5760b0..f0571d469 100644 --- a/src/tc/lib/taskchampion.h +++ b/src/tc/lib/taskchampion.h @@ -547,6 +547,15 @@ EXTERN_C struct TCTaskList tc_replica_all_tasks(struct TCReplica *rep); // there are no operations that can be done. EXTERN_C TCResult tc_replica_commit_undo_ops(struct TCReplica *rep, TCReplicaOpList tc_undo_ops, int32_t *undone_out); +// 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. +// +// Deletion may interact poorly with modifications to the same task on other replicas. For +// example, if a task is deleted on replica 1 and its description modified on replica 1, then +// after both replicas have fully synced, the resulting task will only have a `description` +// property. +EXTERN_C TCResult tc_replica_delete_task(struct TCReplica *rep, struct TCUuid tcuuid); + // Get the latest error for a replica, or a string with NULL ptr if no error exists. Subsequent // calls to this function will return NULL. The rep pointer must not be NULL. The caller must // free the returned string. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 433f0b8c3..1f2447386 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -147,6 +147,7 @@ set (pythonTests prepend.test.py pri_sort.test.py project.test.py + purge.test.py quotes.test.py rc.override.test.py recurrence.test.py diff --git a/test/purge.test.py b/test/purge.test.py new file mode 100755 index 000000000..092814ba8 --- /dev/null +++ b/test/purge.test.py @@ -0,0 +1,162 @@ +#!/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 TestDelete(TestCase): + def setUp(self): + self.t = Task() + + def test_add_delete_purge(self): + """Verify that add/delete/purge successfully purges a task""" + self.t("add one") + uuid = self.t("_get 1.uuid")[1].strip() + + code, out, err = self.t("1 delete", input="y\n") + self.assertIn("Deleted 1 task.", out) + + code, out, err = self.t(uuid + " purge", input="y\n") + self.assertIn("Purged 1 task.", out) + + code, out, err = self.t("uuids") + self.assertNotIn(uuid, out) + + def test_purge_remove_deps(self): + """Purge command removes broken dependency references""" + self.t("add one") + self.t("add two dep:1") + uuid = self.t("_get 1.uuid")[1].strip() + + code, out, err = self.t("1 delete", input="y\n") + self.assertIn("Deleted 1 task.", out) + + code, out, err = self.t(uuid + " purge", input="y\n") + self.assertIn("Purged 1 task.", out) + + code, out, err = self.t("uuids") + self.assertNotIn(uuid, out) + + dependencies = self.t("_get 1.depends")[1].strip() + self.assertNotIn(uuid, dependencies) + + def test_purge_children(self): + """Purge command indirectly purges child tasks""" + self.t("add one recur:daily due:yesterday") + uuid = self.t("_get 1.uuid")[1].strip() + + # A dummy call to report, so that recurrence tasks get generated + self.t("list") + + code, out, err = self.t("1 delete", input="y\ny\n") + self.assertIn("Deleted 4 tasks.", out) + + code, out, err = self.t(uuid + " purge", input="y\ny\n") + self.assertIn("Purged 4 tasks.", out) + + code, out, err = self.t("uuids") + self.assertEqual('\n', out) + + def test_purge_children_fail_pending(self): + """Purge aborts if task has pending children""" + self.t("add one recur:daily due:yesterday") + uuid = self.t("_get 1.uuid")[1].strip() + + # A dummy call to report, so that recurrence tasks get generated + self.t("list") + + code, out, err = self.t("1 delete", input="y\nn\n") + self.assertIn("Deleted 1 task.", out) + + code, out, err = self.t.runError(uuid + " purge", input="y\n") + # The id of the problematic task is not deterministic, as there are + # three child tasks. + self.assertIn("child task", err) + self.assertIn("must be deleted before", err) + + # Check that nothing was purged + code, out, err = self.t("count") + self.assertEqual('4\n', out) + + def test_purge_children_fail_confirm(self): + """Purge aborts if user does not agree with it affecting child tasks""" + self.t("add one recur:daily due:yesterday") + uuid = self.t("_get 1.uuid")[1].strip() + + # A dummy call to report, so that recurrence tasks get generated + self.t("list") + + code, out, err = self.t("1 delete", input="y\ny\n") + self.assertIn("Deleted 4 tasks.", out) + + # Do not agree with purging of the child tasks + code, out, err = self.t.runError(uuid + " purge", input="y\nn\n") + self.assertIn("Purge operation aborted.", err) + + # Check that nothing was purged + code, out, err = self.t("count") + self.assertEqual('4\n', out) + + def test_purge_children(self): + """Purge command removes dependencies on indirectly purged tasks""" + self.t("add one recur:daily due:yesterday") + uuid = self.t("_get 1.uuid")[1].strip() + + # A dummy call to report, so that recurrence tasks get generated + self.t("list") + self.t("add two dep:4") + + # Check that the dependency is present + dependencies = self.t("_get 5.depends")[1].strip() + self.assertNotEqual("", dependencies) + + code, out, err = self.t("1 delete", input="y\ny\n") + self.assertIn("Deleted 4 tasks.", out) + + code, out, err = self.t(uuid + " purge", input="y\ny\n") + self.assertIn("Purged 4 tasks.", out) + + # Make sure we are dealing with the intended task + description = self.t("_get 1.description")[1].strip() + self.assertEqual("two", description) + + # Check that the dependency was removed + dependencies = self.t("_get 1.depends")[1].strip() + self.assertEqual("", dependencies) + +if __name__ == "__main__": + from simpletap import TAPTestRunner + unittest.main(testRunner=TAPTestRunner()) + +# vim: ai sts=4 et sw=4 ft=python