Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified tests/main.sqlite
Binary file not shown.
39 changes: 38 additions & 1 deletion tests/test_things.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
)
PROJECTS = 4
UPCOMING = 1
UPCOMING_REPEATING = 1
DEADLINE_PAST = 3
DEADLINE_FUTURE = 1
DEADLINE = DEADLINE_PAST + DEADLINE_FUTURE
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion things/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
36 changes: 30 additions & 6 deletions things/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -68,6 +76,7 @@
"checklist",
"heading",
"heading_title",
"next_instance_start_date",
"project",
"project_title",
"reminder_time",
Expand Down Expand Up @@ -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

Expand All @@ -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]

Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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)

Expand All @@ -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.
Expand All @@ -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}
Expand All @@ -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)}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down