diff --git a/tests/main.sqlite b/tests/main.sqlite index 9c7e6d6..00cbf5f 100644 Binary files a/tests/main.sqlite and b/tests/main.sqlite differ diff --git a/tests/test_things.py b/tests/test_things.py index 699ccff..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") @@ -350,6 +355,38 @@ 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"])) + + # 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 # 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 2d46ad8..912d26a 100644 --- a/things/api.py +++ b/things/api.py @@ -126,6 +126,18 @@ 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. + + 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'. @@ -182,6 +194,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) @@ -531,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 e66f9e2..7050b79 100644 --- a/things/database.py +++ b/things/database.py @@ -46,6 +46,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). +REPEATING_TEMPLATE_TO_FILTER: dict[bool | None, str] = { + True: "rt1_recurrenceRule IS NOT NULL", + False: "rt1_recurrenceRule IS NULL", +} + TYPE_TO_FILTER = { "to-do": "type = 0", "project": "type = 1", @@ -68,6 +76,7 @@ "checklist", "heading", "heading_title", + "next_instance_start_date", "project", "project_title", "reminder_time", @@ -103,6 +112,11 @@ # 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. +# 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 @@ -125,9 +139,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] @@ -140,8 +151,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 @@ -213,6 +222,8 @@ def get_tasks( # pylint: disable=R0914,R0913,R0917 deadline_suppressed: bool | None = None, 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", @@ -230,11 +241,13 @@ 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)) 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) @@ -250,6 +263,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, "" + ) # Sometimes a task is _not_ set to trashed, but its context # (project or heading it is contained within) is set to trashed. @@ -269,7 +285,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} @@ -283,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)} @@ -518,6 +536,11 @@ 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"CASE WHEN {next_instance_start_date} >= {THINGSDATE_YEAR_2}" + f" THEN {next_instance_start_date} END" + ) return f""" SELECT DISTINCT @@ -567,6 +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, + {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",