diff --git a/doc/man/taskrc.5.in b/doc/man/taskrc.5.in index 1a764af43..2a459d83d 100644 --- a/doc/man/taskrc.5.in +++ b/doc/man/taskrc.5.in @@ -216,6 +216,12 @@ Note that this should be used in the form of a command line override (task rc.gc=0 ...), and not permanently used in the .taskrc file, as this significantly affects performance in the long term. +.TP +.B expiration.on-sync=0 +If set, old tasks will be deleted automatically after each synchronization. +Tasks are identified as "old" when they have status "Deleted" and have not +been modified for 180 days. + .TP .B hooks=1 This master control switch enables hook script processing. The default value diff --git a/scripts/vim/syntax/taskrc.vim b/scripts/vim/syntax/taskrc.vim index 983cee93e..069d51049 100644 --- a/scripts/vim/syntax/taskrc.vim +++ b/scripts/vim/syntax/taskrc.vim @@ -132,6 +132,7 @@ syn match taskrcGoodKey '^\s*\Vexpressions='he=e-1 syn match taskrcGoodKey '^\s*\Vextensions='he=e-1 syn match taskrcGoodKey '^\s*\Vfontunderline='he=e-1 syn match taskrcGoodKey '^\s*\Vgc='he=e-1 +syn match taskrcGoodKey '^\s*\Vexpiration.on-sync='he=e-1 syn match taskrcGoodKey '^\s*\Vhooks='he=e-1 syn match taskrcGoodKey '^\s*\Vhooks.location='he=e-1 syn match taskrcGoodKey '^\s*\Vhyphenate='he=e-1 diff --git a/src/Context.cpp b/src/Context.cpp index 14b3106e7..b64203442 100644 --- a/src/Context.cpp +++ b/src/Context.cpp @@ -118,6 +118,7 @@ std::string configurationDefaults = "json.array=1 # Enclose JSON output in [ ]\n" "abbreviation.minimum=2 # Shortest allowed abbreviation\n" "news.version= # Latest version highlights read by the user\n" + "expiration.on-sync=0 # Expire old tasks on sync\n" "\n" "# Dates\n" "dateformat=Y-M-D # Preferred input and display date format\n" @@ -853,13 +854,8 @@ int Context::dispatch (std::string &out) // The command know whether they need a GC. if (c->needs_gc ()) { - run_gc = config.getBoolean ("gc"); tdb2.gc (); } - else - { - run_gc = false; - } // This is something that is only needed for write commands with no other // filter processing. diff --git a/src/Context.h b/src/Context.h index 310613eef..023178ccc 100644 --- a/src/Context.h +++ b/src/Context.h @@ -98,7 +98,6 @@ public: Hooks hooks {}; bool determine_color_use {true}; bool use_color {true}; - bool run_gc {true}; bool verbosity_legacy {false}; std::set verbosity {}; std::vector headers {}; diff --git a/src/TDB2.cpp b/src/TDB2.cpp index e871d4854..069e81b74 100644 --- a/src/TDB2.cpp +++ b/src/TDB2.cpp @@ -303,6 +303,7 @@ void TDB2::show_diff ( } } +//////////////////////////////////////////////////////////////////////////////// void TDB2::gc () { Timer timer; @@ -316,6 +317,12 @@ void TDB2::gc () Context::getContext ().time_gc_us += timer.total_us (); } +//////////////////////////////////////////////////////////////////////////////// +void TDB2::expire_tasks () +{ + replica.expire_tasks (); +} + //////////////////////////////////////////////////////////////////////////////// // Latest ID is that of the last pending task. int TDB2::latest_id () diff --git a/src/TDB2.h b/src/TDB2.h index 5716ec844..8f83c9452 100644 --- a/src/TDB2.h +++ b/src/TDB2.h @@ -56,6 +56,7 @@ public: void get_changes (std::vector &); void revert (); void gc (); + void expire_tasks (); int latest_id (); // Generalized task accessors. diff --git a/src/commands/CmdNews.cpp b/src/commands/CmdNews.cpp index 1bbbab0a5..2d236d3b9 100644 --- a/src/commands/CmdNews.cpp +++ b/src/commands/CmdNews.cpp @@ -172,6 +172,7 @@ std::vector NewsItem::all () { std::vector items; version2_6_0(items); version3_0_0(items); + version3_1_0(items); return items; } @@ -537,6 +538,27 @@ void NewsItem::version3_0_0 (std::vector& items) { items.push_back(sync); } +void NewsItem::version3_1_0 (std::vector& items) { + Version version("3.1.0"); + NewsItem sync { + version, + /*title=*/"Purging and Expiring Tasks", + /*bg_title=*/"", + /*background=*/"", + /*punchline=*/ + "Support for `task purge` has been restored, and new support added for automatically expiring\n" + "old tasks.\n\n" + /*update=*/ + "The `task purge` command removes tasks entirely, in contrast to `task delete` which merely sets\n" + "the task status to 'Deleted'. This functionality existed in versions 2.x but was temporarily\n" + "removed in 3.0.\n\n" + "The new `expiration.on-sync` configuration parameter controls automatic expiration of old tasks.\n" + "An old task is one with status 'Deleted' that has not been modified in 180 days. This\n" + "functionality is optional and not enabled by default." + }; + items.push_back(sync); +} + //////////////////////////////////////////////////////////////////////////////// int CmdNews::execute (std::string& output) { diff --git a/src/commands/CmdNews.h b/src/commands/CmdNews.h index ba5222176..beee1af1e 100644 --- a/src/commands/CmdNews.h +++ b/src/commands/CmdNews.h @@ -49,6 +49,7 @@ public: static std::vector all(); static void version2_6_0 (std::vector&); static void version3_0_0 (std::vector&); + static void version3_1_0 (std::vector&); private: NewsItem ( diff --git a/src/commands/CmdShow.cpp b/src/commands/CmdShow.cpp index c120e7499..97de521fc 100644 --- a/src/commands/CmdShow.cpp +++ b/src/commands/CmdShow.cpp @@ -160,6 +160,7 @@ int CmdShow::execute (std::string& output) " due" " editor" " exit.on.missing.db" + " expiration.on-sync" " expressions" " fontunderline" " gc" diff --git a/src/commands/CmdSync.cpp b/src/commands/CmdSync.cpp index 0f117209c..cbf871d4c 100644 --- a/src/commands/CmdSync.cpp +++ b/src/commands/CmdSync.cpp @@ -106,7 +106,12 @@ int CmdSync::execute (std::string& output) << '\n'; } - Context::getContext ().tdb2.sync(std::move(server), false); + Context &context = Context::getContext (); + context.tdb2.sync(std::move(server), false); + + if (context.config.getBoolean ("expiration.on-sync")) { + context.tdb2.expire_tasks (); + } output = out.str (); return status; diff --git a/src/tc/Replica.cpp b/src/tc/Replica.cpp index 2cc0765bd..bc3a4654c 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::expire_tasks () +{ + auto res = tc_replica_expire_tasks (&*inner); + if (res != TC_RESULT_OK) { + throw replica_error (); + } +} + //////////////////////////////////////////////////////////////////////////////// void tc::Replica::sync (Server server, bool avoid_snapshots) { diff --git a/src/tc/Replica.h b/src/tc/Replica.h index 6a7fcc371..f0a10098c 100644 --- a/src/tc/Replica.h +++ b/src/tc/Replica.h @@ -92,6 +92,7 @@ namespace tc { tc::Task new_task (Status status, const std::string &description); tc::Task import_task_with_uuid (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); tc::ffi::TCReplicaOpList get_undo_ops (); void commit_undo_ops (tc::ffi::TCReplicaOpList tc_undo_ops, int32_t *undone_out); diff --git a/src/tc/lib/src/replica.rs b/src/tc/lib/src/replica.rs index e6956f431..9e63aa041 100644 --- a/src/tc/lib/src/replica.rs +++ b/src/tc/lib/src/replica.rs @@ -533,6 +533,31 @@ pub unsafe extern "C" fn tc_replica_sync( ) } +#[ffizz_header::item] +#[ffizz(order = 902)] +/// Expire old, deleted tasks. +/// +/// Expiration entails removal of tasks from the replica. Any modifications that occur after +/// the deletion (such as operations synchronized from other replicas) will do nothing. +/// +/// Tasks are eligible for expiration when they have status Deleted and have not been modified +/// for 180 days (about six months). Note that completed tasks are not eligible. +/// +/// ```c +/// EXTERN_C TCResult tc_replica_expire_tasks(struct TCReplica *rep); +/// ``` +#[no_mangle] +pub unsafe extern "C" fn tc_replica_expire_tasks(rep: *mut TCReplica) -> TCResult { + wrap( + rep, + |rep| { + rep.expire_tasks()?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + #[ffizz_header::item] #[ffizz(order = 902)] /// Return undo local operations until the most recent UndoPoint. diff --git a/src/tc/lib/taskchampion.h b/src/tc/lib/taskchampion.h index dfbe1ba7d..a8f5760b0 100644 --- a/src/tc/lib/taskchampion.h +++ b/src/tc/lib/taskchampion.h @@ -552,6 +552,15 @@ EXTERN_C TCResult tc_replica_commit_undo_ops(struct TCReplica *rep, TCReplicaOpL // free the returned string. EXTERN_C struct TCString tc_replica_error(struct TCReplica *rep); +// Expire old, deleted tasks. +// +// Expiration entails removal of tasks from the replica. Any modifications that occur after +// the deletion (such as operations synchronized from other replicas) will do nothing. +// +// Tasks are eligible for expiration when they have status Deleted and have not been modified +// for 180 days (about six months). Note that completed tasks are not eligible. +EXTERN_C TCResult tc_replica_expire_tasks(struct TCReplica *rep); + // Get an existing task by its UUID. // // Returns NULL when the task does not exist, and on error. Consult tc_replica_error diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 26a18b1ce..433f0b8c3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -111,6 +111,7 @@ set (pythonTests encoding.test.py enpassant.test.py exec.test.py + expiration.test.py export.test.py feature.559.test.py feature.default.project.test.py diff --git a/test/expiration.test.py b/test/expiration.test.py new file mode 100755 index 000000000..c8a76a44a --- /dev/null +++ b/test/expiration.test.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +############################################################################### +# +# 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 +# +############################################################################### + +import sys +import os +import unittest +import time +# Ensure python finds the local simpletap module +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from basetest import Task, TestCase +from basetest.utils import mkstemp + + +class TestImport(TestCase): + def setUp(self): + self.t = Task() + # Set up local sync within the TASKDATA directory, so that it will be + # deleted properly. + self.t.config("sync.local.server_dir", self.t.datadir) + + def exists(self, uuid): + code, out, err = self.t(f"_get {uuid}.status") + return out.strip() != "" + + def test_expiration(self): + """Only tasks that are deleted and have a modification in the past are expired.""" + yesterday = int(time.time()) - 3600 * 24 + last_year = int(time.time()) - 265 * 3600 * 24 + old_pending = "a1111111-a111-a111-a111-a11111111111" + old_completed = "a2222222-a222-a222-a222-a22222222222" + new_deleted = "a3333333-a333-a333-a333-a33333333333" + old_deleted = "a4444444-a444-a444-a444-a44444444444" + task_data = f"""[ + {{"uuid":"{old_pending}","status":"pending","modified":"{last_year}","description":"x"}}, + {{"uuid":"{old_completed}","status":"completed","modified":"{last_year}","description":"x"}}, + {{"uuid":"{new_deleted}","status":"deleted","modified":"{yesterday}","description":"x"}}, + {{"uuid":"{old_deleted}","status":"deleted","modified":"{last_year}","description":"x"}} +] +""" + code, out, err = self.t("import -", input=task_data) + self.assertIn("Imported 4 tasks", err) + + # By default, expiration does not occur. + code, out, err = self.t("sync") + self.assertTrue(self.exists(old_pending)) + self.assertTrue(self.exists(old_completed)) + self.assertTrue(self.exists(new_deleted)) + self.assertTrue(self.exists(old_deleted)) + + # Configure expiration on sync. The old_deleted task + # should be removed. + self.t.config("expiration.on-sync", "1") + code, out, err = self.t("sync") + self.assertTrue(self.exists(old_pending)) + self.assertTrue(self.exists(old_completed)) + self.assertTrue(self.exists(new_deleted)) + self.assertFalse(self.exists(old_deleted)) + +if __name__ == "__main__": + from simpletap import TAPTestRunner + unittest.main(testRunner=TAPTestRunner()) + +# vim: ai sts=4 et sw=4 ft=python