From 735b2cec7df28d51d650d55806db4aef999ca308 Mon Sep 17 00:00:00 2001 From: Chad Timblin Date: Sun, 25 Oct 2026 07:35:15 -0500 Subject: [PATCH 1/2] Add is_repeating_template parameter and next_instance_start_date (#122) Repeating to-dos are stored as template rows carrying a recurrence rule, and get_tasks() hard-coded "rt1_recurrenceRule IS NULL" into every query, so templates were invisible to the whole API. is_repeating_template on things.tasks() and Database.get_tasks() follows the trashed parameter: False (default) excludes templates as before, True returns only templates, None returns both. Templates have no start date, so task rows now carry next_instance_start_date, the date Things creates the next instance; it is present only on templates, and a stored 0001-01-01 placeholder reads as unset. The test database's repeating template gets a next instance date of 2030-01-01 so the field has something to show. Code written by Claude Fable 5.1 (Anthropic). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 ++++++++ tests/main.sqlite | Bin 180224 -> 180224 bytes tests/test_things.py | 23 +++++++++++++++++++++++ things/api.py | 11 +++++++++++ things/database.py | 31 +++++++++++++++++++++++++------ 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40236bc..f71ad34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `is_repeating_template` parameter in `tasks()` and `Database.get_tasks()` (#122) + - `False` (default) excludes repeating templates, as before; `True` returns only templates; `None` returns both +- `next_instance_start_date` field on repeating templates: the date Things will create the next instance + ## [1.0.0] - 2026-02-10 ### Added diff --git a/tests/main.sqlite b/tests/main.sqlite index 9c7e6d615969006bd2fca57c3ad0974892371be5..00cbf5f94a4710a2ad04f655a546246c104d903e 100644 GIT binary patch delta 119 zcmZo@;BEk-7RD`1i~@!X3=E=7@(lcWeE0c$c<1wc;K^c==YGVU!u68NoAVxL87DJG z4f}WYNVX$v=B%?=xi>bxWn^m2oZceIq`}CteXS(ZUM42?chhx*m?lo2D9AK#dx9{N WFw69VLO>>?1XCm9_AD8uX^R1kJ0bl5 delta 137 zcmZo@;BEk-7RD`1i~@%2#SEg16IuE5`0n%h@XqJ?z>~$<#PN%xhJ%smAUhk|JT`6C zC9Lu+jm&SD;~6J1>1}L`XPiDulxf~{9#N(~76u@g?k&YMZ@ak|lL!ll-#f9fYx_$n irbb4l*3{{WLQEPERe>^0V%w92n3gbY&yr!9wip1V!XtG6 diff --git a/tests/test_things.py b/tests/test_things.py index a30b127..fc64ac4 100644 --- a/tests/test_things.py +++ b/tests/test_things.py @@ -352,6 +352,29 @@ def test_tasks(self): with self.assertRaises((sqlite3.ProgrammingError, ValueError)): things.tasks(area="\0") + def test_repeating_templates(self): + # Templates are excluded by default, as before. + todos = things.todos() + self.assertFalse(any("next_instance_start_date" in todo for todo in todos)) + + templates = things.todos(is_repeating_template=True) + self.assertEqual(1, len(templates)) + template = templates[0] + self.assertEqual("Repeating To-Do", template["title"]) + self.assertEqual("2030-01-01", template["next_instance_start_date"]) + self.assertIsNone(template["start_date"]) + + both = things.todos(is_repeating_template=None) + self.assertEqual(len(todos) + 1, len(both)) + + # The instance Things created from the template is a regular to-do. + instance = things.get("K9bx7h1xCJdevvyWardZDq") + self.assertNotIn("next_instance_start_date", instance) + self.assertIn("next_instance_start_date", things.get(template["uuid"])) + + with self.assertRaises(ValueError): + things.tasks(is_repeating_template="invalid_value") + def test_tasks_stopdate_timezones(self): # see https://github.com/thingsapi/things.py/issues/117 # this test looks at changes to two tasks based on timezone, on either side of midnight UTC diff --git a/things/api.py b/things/api.py index 51dfb32..c3b8f1e 100644 --- a/things/api.py +++ b/things/api.py @@ -128,6 +128,15 @@ def tasks(uuid=None, include_items=False, **kwargs): # noqa: C901 only return tasks whose context has been trashed. - `context_trashed == None`, include both kind of tasks. + is_repeating_template : bool or None, optional, default False + A repeating to-do is stored as a template, from which Things + creates each instance. A template has no start date; instead it + carries `next_instance_start_date`, the date Things will create + the next instance. Only templates have that field. + - `is_repeating_template == False` (default), exclude templates. + - `is_repeating_template == True`, only include templates. + - `is_repeating_template == None`, include both kind of tasks. + last : str, optional Limit returned tasks to tasks created within the last X days, weeks, or years. For example: '3d', '5w', or '1y'. @@ -184,6 +193,8 @@ def tasks(uuid=None, include_items=False, **kwargs): # noqa: C901 12 >>> things.tasks(status='completed', last='1w', count_only=True) 0 + >>> things.tasks(is_repeating_template=True) + [{'uuid': 'N1PJHsbjct4mb1bhcs7aHa', 'type': 'to-do', 'title': 'Repeating To-Do', ... """ database = pop_database(kwargs) diff --git a/things/database.py b/things/database.py index fb1958a..e21d084 100755 --- a/things/database.py +++ b/things/database.py @@ -48,6 +48,14 @@ TRASHED_TO_FILTER = {True: "trashed = 1", False: "trashed = 0"} +# A repeating to-do is stored as a template row carrying a recurrence rule; +# Things creates each instance from it. See `things.api.tasks`. +# Code written by Claude Fable 5.1 (Anthropic), reviewed by Chad Timblin. +REPEATING_TEMPLATE_TO_FILTER = { + True: "rt1_recurrenceRule IS NOT NULL", + False: "rt1_recurrenceRule IS NULL", +} + TYPE_TO_FILTER = { "to-do": "type = 0", "project": "type = 1", @@ -70,6 +78,7 @@ "checklist", "heading", "heading_title", + "next_instance_start_date", "project", "project_title", "reminder_time", @@ -105,6 +114,8 @@ # See `convert_isodate_sql_expression_to_thingsdate` for details. DATE_DEADLINE = "deadline" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary DATE_START = "startDate" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary +# Set on repeating templates only: the date Things creates the next instance. +DATE_NEXT_INSTANCE_START = "rt1_nextInstanceStartDate" # INTEGER: Things date # See 'convert_thingstime_sql_expression_to_isotime' for details. REMINDER_TIME = "reminderTime" # INTEGER: hhhhhmmmmmm00000000000000000000, in binary @@ -127,9 +138,6 @@ IS_ANYTIME = START_TO_FILTER["Anytime"] IS_SOMEDAY = START_TO_FILTER["Someday"] -# Repeats -IS_NOT_RECURRING = "rt1_recurrenceRule IS NULL" - # Trash IS_TRASHED = TRASHED_TO_FILTER[True] @@ -142,8 +150,6 @@ # IS_NOT_SCHEDULED = f"{DATE_START} IS NULL" # IS_DEADLINE = f"{DATE_DEADLINE} IS NOT NULL" # RECURRING_IS_NOT_PAUSED = "rt1_instanceCreationPaused = 0" -# IS_RECURRING = "rt1_recurrenceRule IS NOT NULL" -# RECURRING_HAS_NEXT_STARTDATE = ("rt1_nextInstanceStartDate IS NOT NULL") # IS_NOT_TRASHED = TRASHED_TO_FILTER[False] # pylint: disable=R0904,R0902 @@ -215,6 +221,7 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 deadline_suppressed: Optional[bool] = None, trashed: Optional[bool] = False, context_trashed: Optional[bool] = False, + is_repeating_template: Optional[bool] = False, last: Optional[str] = None, search_query: Optional[str] = None, index: str = "index", @@ -237,6 +244,7 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) validate("type", type, [None] + list(TYPE_TO_FILTER)) validate("context_trashed", context_trashed, [None, True, False]) + validate("is_repeating_template", is_repeating_template, [None, True, False]) validate("index", index, list(INDICES)) validate_offset("last", last) @@ -252,6 +260,9 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore + repeating_template_filter: str = REPEATING_TEMPLATE_TO_FILTER.get( + is_repeating_template, "" # type: ignore + ) # Sometimes a task is _not_ set to trashed, but its context # (project or heading it is contained within) is set to trashed. @@ -271,7 +282,8 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 ) where_predicate = f""" - TASK.{IS_NOT_RECURRING} + TRUE + {repeating_template_filter and f"AND TASK.{repeating_template_filter}"} {trashed_filter and f"AND TASK.{trashed_filter}"} {project_trashed_filter} {project_of_heading_trashed_filter} @@ -520,6 +532,9 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): reminder_time_expression = convert_thingstime_sql_expression_to_isotime( f"TASK.{REMINDER_TIME}" ) + next_instance_start_date_expression = convert_thingsdate_sql_expression_to_isodate( + f"TASK.{DATE_NEXT_INSTANCE_START}" + ) return f""" SELECT DISTINCT @@ -569,6 +584,10 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1 END AS checklist, {start_date_expression} AS start_date, + CASE + WHEN TASK.{DATE_NEXT_INSTANCE_START} >> 16 > 1 + THEN {next_instance_start_date_expression} + END AS next_instance_start_date, {deadline_expression} AS deadline, {reminder_time_expression} AS "reminder_time", datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date", From 55fec12d65b99ad0b558730668d7aa9497c27a63 Mon Sep 17 00:00:00 2001 From: Chad Timblin Date: Tue, 27 Oct 2026 10:29:54 -0500 Subject: [PATCH 2/2] Include repeating to-dos in upcoming(); compute the next-instance SQL upfront Review feedback on #152: things.upcoming() now also returns repeating templates whose next instance is in the future, sorted by start_date or next_instance_start_date, whichever is set. A next_instance_start_date filter on tasks() (same options as start_date) backs that. The SQL for the field is built upfront like the other date expressions, with the year-1 placeholder named instead of bit-shifted inline, and the changelog entry is dropped (the maintainers write it at release). Code written by Claude Fable 5.1 (Anthropic). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 8 -------- tests/test_things.py | 16 +++++++++++++++- things/api.py | 24 +++++++++++++++++++++++- things/database.py | 17 +++++++++++------ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f71ad34..40236bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] - -### Added - -- `is_repeating_template` parameter in `tasks()` and `Database.get_tasks()` (#122) - - `False` (default) excludes repeating templates, as before; `True` returns only templates; `None` returns both -- `next_instance_start_date` field on repeating templates: the date Things will create the next instance - ## [1.0.0] - 2026-02-10 ### Added diff --git a/tests/test_things.py b/tests/test_things.py index 4aaf7b3..a2ed822 100644 --- a/tests/test_things.py +++ b/tests/test_things.py @@ -38,6 +38,7 @@ ) PROJECTS = 4 UPCOMING = 1 +UPCOMING_REPEATING = 1 DEADLINE_PAST = 3 DEADLINE_FUTURE = 1 DEADLINE = DEADLINE_PAST + DEADLINE_FUTURE @@ -127,7 +128,11 @@ def test_trashed(self): def test_upcoming(self): tasks = things.upcoming() - self.assertEqual(UPCOMING, len(tasks)) + self.assertEqual(UPCOMING + UPCOMING_REPEATING, len(tasks)) + self.assertEqual("2030-01-01", tasks[-1]["next_instance_start_date"]) + self.assertEqual( + UPCOMING + UPCOMING_REPEATING, things.upcoming(count_only=True) + ) def test_deadlines(self): tasks = things.tasks(deadline="past") @@ -370,8 +375,17 @@ def test_repeating_templates(self): self.assertNotIn("next_instance_start_date", instance) self.assertIn("next_instance_start_date", things.get(template["uuid"])) + # The next-instance date filters like a start date. + for value, expected in (("future", 1), ("past", 0), (False, 0)): + templates = things.todos( + is_repeating_template=True, next_instance_start_date=value + ) + self.assertEqual(expected, len(templates)) + with self.assertRaises(ValueError): things.tasks(is_repeating_template="invalid_value") + with self.assertRaises(ValueError): + things.tasks(next_instance_start_date="invalid_value") def test_tasks_stopdate_timezones(self): # see https://github.com/thingsapi/things.py/issues/117 diff --git a/things/api.py b/things/api.py index cb0919c..912d26a 100644 --- a/things/api.py +++ b/things/api.py @@ -135,6 +135,9 @@ def tasks(uuid=None, include_items=False, **kwargs): # noqa: C901 - `is_repeating_template == True`, only include templates. - `is_repeating_template == None`, include both kind of tasks. + next_instance_start_date : same options as start_date, signifies the date \ + Things will create the next instance of a repeating to-do (templates only) + last : str, optional Limit returned tasks to tasks created within the last X days, weeks, or years. For example: '3d', '5w', or '1y'. @@ -542,12 +545,31 @@ def upcoming(**kwargs): """ Read Upcoming tasks into dicts. + Repeating to-dos are included once Things has scheduled their next + instance, dated by `next_instance_start_date`, as in the app's + Upcoming list. + Note: unscheduled tasks with a deadline are not included here. See the `things.api.deadline` function instead. For details on parameters, see `things.api.tasks`. """ - return tasks(start_date="future", start="Someday", **kwargs) + database = pop_database(kwargs) + kwargs["database"] = database + + scheduled = tasks(start_date="future", start="Someday", **kwargs) + repeating = tasks( + is_repeating_template=True, + next_instance_start_date="future", + start="Someday", + **kwargs, + ) + if kwargs.get("count_only"): + return scheduled + repeating + + result = [*scheduled, *repeating] + result.sort(key=lambda task: task["start_date"] or task["next_instance_start_date"]) + return result def anytime(**kwargs): diff --git a/things/database.py b/things/database.py index a68fb08..7050b79 100644 --- a/things/database.py +++ b/things/database.py @@ -48,7 +48,7 @@ # A repeating to-do is stored as a template row carrying a recurrence rule; # Things creates each instance from it. See `things.api.tasks`. -# Code written by Claude Fable 5.1 (Anthropic), reviewed by Chad Timblin. +# Code written by Claude Fable 5.1 (Anthropic). REPEATING_TEMPLATE_TO_FILTER: dict[bool | None, str] = { True: "rt1_recurrenceRule IS NOT NULL", False: "rt1_recurrenceRule IS NULL", @@ -113,7 +113,10 @@ DATE_DEADLINE = "deadline" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary DATE_START = "startDate" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary # Set on repeating templates only: the date Things creates the next instance. +# Things stores 0001-01-01 instead of NULL when there is none; no real Things +# date is below year 2, so anything under THINGSDATE_YEAR_2 is that placeholder. DATE_NEXT_INSTANCE_START = "rt1_nextInstanceStartDate" # INTEGER: Things date +THINGSDATE_YEAR_2 = 2 << 16 # See 'convert_thingstime_sql_expression_to_isotime' for details. REMINDER_TIME = "reminderTime" # INTEGER: hhhhhmmmmmm00000000000000000000, in binary @@ -220,6 +223,7 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 trashed: bool | None = False, context_trashed: bool | None = False, is_repeating_template: bool | None = False, + next_instance_start_date: str | bool | None = None, last: str | None = None, search_query: str | None = None, index: str = "index", @@ -237,6 +241,7 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 validate("deadline_suppressed", deadline_suppressed, [None, True, False]) validate("start", start, [None] + list(START_TO_FILTER)) validate_date("start_date", start_date) + validate_date("next_instance_start_date", next_instance_start_date) validate_date("stop_date", stop_date) validate("status", status, [None] + list(STATUS_TO_FILTER)) validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) @@ -295,6 +300,7 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)} {make_filter("TAG.title", tag)} {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)} + {make_thingsdate_filter(f"TASK.{DATE_NEXT_INSTANCE_START}", next_instance_start_date)} {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)} {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)} {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)} @@ -530,8 +536,10 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): reminder_time_expression = convert_thingstime_sql_expression_to_isotime( f"TASK.{REMINDER_TIME}" ) + next_instance_start_date = f"TASK.{DATE_NEXT_INSTANCE_START}" next_instance_start_date_expression = convert_thingsdate_sql_expression_to_isodate( - f"TASK.{DATE_NEXT_INSTANCE_START}" + f"CASE WHEN {next_instance_start_date} >= {THINGSDATE_YEAR_2}" + f" THEN {next_instance_start_date} END" ) return f""" @@ -582,10 +590,7 @@ def make_tasks_sql_query(where_predicate=None, order_predicate=None): WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1 END AS checklist, {start_date_expression} AS start_date, - CASE - WHEN TASK.{DATE_NEXT_INSTANCE_START} >> 16 > 1 - THEN {next_instance_start_date_expression} - END AS next_instance_start_date, + {next_instance_start_date_expression} AS next_instance_start_date, {deadline_expression} AS deadline, {reminder_time_expression} AS "reminder_time", datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date",