From 443dfada38212b1272b6302598c8bd30a22ef0a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 May 2023 19:37:13 +0000 Subject: [PATCH] Updated docs --- Pipfile.lock | 33 +- docs/things/database.html | 4763 +++++++++++++++++++------------------ 2 files changed, 2417 insertions(+), 2379 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 503362d..cdfe17c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "37ac51f7712a1239d254a1b31197f1dc7434e15667f1d4137e6f0dbae3a27078" + "sha256": "59a7465aeadc03b6617c4235a0c084fabd0c3fdc65f2b34b95ea49a6fdfee44c" }, "pipfile-spec": 6, "requires": { @@ -347,6 +347,37 @@ "index": "pypi", "version": "==7.3.1" }, + "pytest-cov": { + "hashes": [ + "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b", + "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470" + ], + "index": "pypi", + "version": "==4.0.0" + }, + "ruff": { + "hashes": [ + "sha256:03ff42bc91ceca58e0f0f072cb3f9286a9208f609812753474e799a997cdad1a", + "sha256:11ddcfbab32cf5c420ea9dd5531170ace5a3e59c16d9251c7bd2581f7b16f602", + "sha256:1f19f59ca3c28742955241fb452f3346241ddbd34e72ac5cb3d84fadebcf6bc8", + "sha256:3569bcdee679045c09c0161fabc057599759c49219a08d9a4aad2cc3982ccba3", + "sha256:374b161753a247904aec7a32d45e165302b76b6e83d22d099bf3ff7c232c888f", + "sha256:3f5dc7aac52c58e82510217e3c7efd80765c134c097c2815d59e40face0d1fe6", + "sha256:56347da63757a56cbce7d4b3d6044ca4f1941cd1bbff3714f7554360c3361f83", + "sha256:5a20658f0b97d207c7841c13d528f36d666bf445b00b01139f28a8ccb80093bb", + "sha256:6da8ee25ef2f0cc6cc8e6e20942c1d44d25a36dce35070d7184655bc14f63f63", + "sha256:9ca0a1ddb1d835b5f742db9711c6cf59f213a1ad0088cb1e924a005fd399e7d8", + "sha256:a374434e588e06550df0f8dcb74777290f285678de991fda4e1063c367ab2eb2", + "sha256:bbeb857b1e508a4487bdb02ca1e6d41dd8d5ac5335a5246e25de8a3dff38c1ff", + "sha256:bd81b8e681b9eaa6cf15484f3985bd8bd97c3d114e95bff3e8ea283bf8865062", + "sha256:cec2f4b84a14b87f1b121488649eb5b4eaa06467a2387373f750da74bdcb5679", + "sha256:e131b4dbe798c391090c6407641d6ab12c0fa1bb952379dde45e5000e208dabb", + "sha256:f062059b8289a4fab7f6064601b811d447c2f9d3d432a17f689efe4d68988450", + "sha256:f3b59ccff57b21ef0967ea8021fd187ec14c528ec65507d8bcbe035912050776" + ], + "index": "pypi", + "version": "==0.0.269" + }, "setuptools": { "hashes": [ "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f", diff --git a/docs/things/database.html b/docs/things/database.html index 11abc81..6912a1d 100644 --- a/docs/things/database.html +++ b/docs/things/database.html @@ -165,7 +165,7 @@

8import re 9import sqlite3 10from textwrap import dedent - 11from typing import Optional + 11from typing import Optional, Union 12 13 14# -------------------------------------------------- @@ -173,1165 +173,1171 @@

16# -------------------------------------------------- 17 18 - 19# Database filepath - 20DEFAULT_FILEROOT = os.path.expanduser( + 19# Database filepath with glob pattern for version 3.15.16+ + 20DEFAULT_FILEPATH_31616502 = ( 21 "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac" - 22) - 23# Migration for April 2023 update - 24if os.path.isfile(f"{DEFAULT_FILEROOT}/Things Database.thingsdatabase"): - 25 for filename in glob.glob(os.path.join(DEFAULT_FILEROOT, "ThingsData-*")): - 26 DEFAULT_FILEROOT = filename - 27DEFAULT_FILEPATH = f"{DEFAULT_FILEROOT}/Things Database.thingsdatabase/main.sqlite" + 22 "/ThingsData-*/Things Database.thingsdatabase/main.sqlite" + 23) + 24DEFAULT_FILEPATH_31516502 = ( + 25 "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac" + 26 "/Things Database.thingsdatabase/main.sqlite" + 27) 28 - 29ENVIRONMENT_VARIABLE_WITH_FILEPATH = "THINGSDB" - 30 - 31# Translate app language to database language - 32 - 33START_TO_FILTER = { - 34 "Inbox": "start = 0", - 35 "Anytime": "start = 1", - 36 "Someday": "start = 2", - 37} - 38 - 39STATUS_TO_FILTER = { - 40 "incomplete": "status = 0", - 41 "canceled": "status = 2", - 42 "completed": "status = 3", - 43} - 44 - 45TRASHED_TO_FILTER = {True: "trashed = 1", False: "trashed = 0"} - 46 - 47TYPE_TO_FILTER = { - 48 "to-do": "type = 0", - 49 "project": "type = 1", - 50 "heading": "type = 2", - 51} - 52 - 53# Dates - 54 - 55DATES = ("future", "past", True, False) - 56 - 57# Indices - 58 - 59INDICES = ("index", "todayIndex") - 60 - 61# Response modification - 62 - 63COLUMNS_TO_OMIT_IF_NONE = ( - 64 "area", - 65 "area_title", - 66 "checklist", - 67 "heading", - 68 "heading_title", - 69 "project", - 70 "project_title", - 71 "trashed", - 72 "tags", - 73) - 74COLUMNS_TO_TRANSFORM_TO_BOOL = ("checklist", "tags", "trashed") - 75 - 76# -------------------------------------------------- - 77# Table names - 78# -------------------------------------------------- - 79 - 80TABLE_AREA = "TMArea" - 81TABLE_AREATAG = "TMAreaTag" - 82TABLE_CHECKLIST_ITEM = "TMChecklistItem" - 83TABLE_META = "Meta" - 84TABLE_TAG = "TMTag" - 85TABLE_TASK = "TMTask" - 86TABLE_TASKTAG = "TMTaskTag" - 87TABLE_SETTINGS = "TMSettings" - 88 - 89# -------------------------------------------------- - 90# Date Columns - 91# -------------------------------------------------- - 92 - 93# Note that the columns below -- contrary to their names -- seem to - 94# store the full UTC datetime, not just the date. - 95DATE_CREATED = "creationDate" # REAL: Unix date & time, UTC - 96DATE_MODIFIED = "userModificationDate" # REAL: Unix date & time, UTC - 97DATE_STOP = "stopDate" # REAL: Unix date & time, UTC - 98 - 99# These are stored in a Things date format. - 100# See `convert_isodate_sql_expression_to_thingsdate` for details. - 101DATE_DEADLINE = "deadline" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary - 102DATE_START = "startDate" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary + 29try: + 30 DEFAULT_FILEPATH = next(glob.iglob(os.path.expanduser(DEFAULT_FILEPATH_31616502))) + 31except StopIteration: + 32 DEFAULT_FILEPATH = os.path.expanduser(DEFAULT_FILEPATH_31516502) + 33 + 34ENVIRONMENT_VARIABLE_WITH_FILEPATH = "THINGSDB" + 35 + 36# Translate app language to database language + 37 + 38START_TO_FILTER = { + 39 "Inbox": "start = 0", + 40 "Anytime": "start = 1", + 41 "Someday": "start = 2", + 42} + 43 + 44STATUS_TO_FILTER = { + 45 "incomplete": "status = 0", + 46 "canceled": "status = 2", + 47 "completed": "status = 3", + 48} + 49 + 50TRASHED_TO_FILTER = {True: "trashed = 1", False: "trashed = 0"} + 51 + 52TYPE_TO_FILTER = { + 53 "to-do": "type = 0", + 54 "project": "type = 1", + 55 "heading": "type = 2", + 56} + 57 + 58# Dates + 59 + 60DATES = ("future", "past", True, False) + 61 + 62# Indices + 63 + 64INDICES = ("index", "todayIndex") + 65 + 66# Response modification + 67 + 68COLUMNS_TO_OMIT_IF_NONE = ( + 69 "area", + 70 "area_title", + 71 "checklist", + 72 "heading", + 73 "heading_title", + 74 "project", + 75 "project_title", + 76 "trashed", + 77 "tags", + 78) + 79COLUMNS_TO_TRANSFORM_TO_BOOL = ("checklist", "tags", "trashed") + 80 + 81# -------------------------------------------------- + 82# Table names + 83# -------------------------------------------------- + 84 + 85TABLE_AREA = "TMArea" + 86TABLE_AREATAG = "TMAreaTag" + 87TABLE_CHECKLIST_ITEM = "TMChecklistItem" + 88TABLE_META = "Meta" + 89TABLE_TAG = "TMTag" + 90TABLE_TASK = "TMTask" + 91TABLE_TASKTAG = "TMTaskTag" + 92TABLE_SETTINGS = "TMSettings" + 93 + 94# -------------------------------------------------- + 95# Date Columns + 96# -------------------------------------------------- + 97 + 98# Note that the columns below -- contrary to their names -- seem to + 99# store the full UTC datetime, not just the date. + 100DATE_CREATED = "creationDate" # REAL: Unix date & time, UTC + 101DATE_MODIFIED = "userModificationDate" # REAL: Unix date & time, UTC + 102DATE_STOP = "stopDate" # REAL: Unix date & time, UTC 103 - 104# -------------------------------------------------- - 105# Various filters - 106# -------------------------------------------------- - 107 - 108# Type - 109IS_TODO = TYPE_TO_FILTER["to-do"] - 110IS_PROJECT = TYPE_TO_FILTER["project"] - 111IS_HEADING = TYPE_TO_FILTER["heading"] + 104# These are stored in a Things date format. + 105# See `convert_isodate_sql_expression_to_thingsdate` for details. + 106DATE_DEADLINE = "deadline" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary + 107DATE_START = "startDate" # INTEGER: YYYYYYYYYYYMMMMDDDDD0000000, in binary + 108 + 109# -------------------------------------------------- + 110# Various filters + 111# -------------------------------------------------- 112 - 113# Status - 114IS_INCOMPLETE = STATUS_TO_FILTER["incomplete"] - 115IS_CANCELED = STATUS_TO_FILTER["canceled"] - 116IS_COMPLETED = STATUS_TO_FILTER["completed"] + 113# Type + 114IS_TODO = TYPE_TO_FILTER["to-do"] + 115IS_PROJECT = TYPE_TO_FILTER["project"] + 116IS_HEADING = TYPE_TO_FILTER["heading"] 117 - 118# Start - 119IS_INBOX = START_TO_FILTER["Inbox"] - 120IS_ANYTIME = START_TO_FILTER["Anytime"] - 121IS_SOMEDAY = START_TO_FILTER["Someday"] + 118# Status + 119IS_INCOMPLETE = STATUS_TO_FILTER["incomplete"] + 120IS_CANCELED = STATUS_TO_FILTER["canceled"] + 121IS_COMPLETED = STATUS_TO_FILTER["completed"] 122 - 123# Repeats - 124IS_NOT_RECURRING = "rt1_recurrenceRule IS NULL" - 125 - 126# Trash - 127IS_TRASHED = TRASHED_TO_FILTER[True] - 128 - 129# -------------------------------------------------- - 130# Fields and filters not yet used in the implementation. - 131# This information might be of relevance in the future. - 132# -------------------------------------------------- - 133# - 134# IS_SCHEDULED = f"{DATE_START} IS NOT NULL" - 135# IS_NOT_SCHEDULED = f"{DATE_START} IS NULL" - 136# IS_DEADLINE = f"{DATE_DEADLINE} IS NOT NULL" - 137# RECURRING_IS_NOT_PAUSED = "rt1_instanceCreationPaused = 0" - 138# IS_RECURRING = "rt1_recurrenceRule IS NOT NULL" - 139# RECURRING_HAS_NEXT_STARTDATE = ("rt1_nextInstanceStartDate IS NOT NULL") - 140# IS_NOT_TRASHED = TRASHED_TO_FILTER[False] - 141 - 142# pylint: disable=R0904,R0902 - 143 - 144 - 145class Database: - 146 """ - 147 Access Things SQL database. + 123# Start + 124IS_INBOX = START_TO_FILTER["Inbox"] + 125IS_ANYTIME = START_TO_FILTER["Anytime"] + 126IS_SOMEDAY = START_TO_FILTER["Someday"] + 127 + 128# Repeats + 129IS_NOT_RECURRING = "rt1_recurrenceRule IS NULL" + 130 + 131# Trash + 132IS_TRASHED = TRASHED_TO_FILTER[True] + 133 + 134# -------------------------------------------------- + 135# Fields and filters not yet used in the implementation. + 136# This information might be of relevance in the future. + 137# -------------------------------------------------- + 138# + 139# IS_SCHEDULED = f"{DATE_START} IS NOT NULL" + 140# IS_NOT_SCHEDULED = f"{DATE_START} IS NULL" + 141# IS_DEADLINE = f"{DATE_DEADLINE} IS NOT NULL" + 142# RECURRING_IS_NOT_PAUSED = "rt1_instanceCreationPaused = 0" + 143# IS_RECURRING = "rt1_recurrenceRule IS NOT NULL" + 144# RECURRING_HAS_NEXT_STARTDATE = ("rt1_nextInstanceStartDate IS NOT NULL") + 145# IS_NOT_TRASHED = TRASHED_TO_FILTER[False] + 146 + 147# pylint: disable=R0904,R0902 148 - 149 Parameters - 150 ---------- - 151 filepath : str, optional - 152 Any valid path of a SQLite database file generated by the Things app. - 153 If the environment variable `THINGSDB` is set, then use that path. - 154 Otherwise, access the default database path. - 155 - 156 print_sql : bool, default False - 157 Print every SQL query performed. Some may contain '?' and ':' - 158 characters which correspond to SQLite parameter tokens. - 159 See https://www.sqlite.org/lang_expr.html#varparam + 149 + 150class Database: + 151 """ + 152 Access Things SQL database. + 153 + 154 Parameters + 155 ---------- + 156 filepath : str, optional + 157 Any valid path of a SQLite database file generated by the Things app. + 158 If the environment variable `THINGSDB` is set, then use that path. + 159 Otherwise, access the default database path. 160 - 161 :raises AssertionError: If the database version is too old. - 162 """ - 163 - 164 debug = False + 161 print_sql : bool, default False + 162 Print every SQL query performed. Some may contain '?' and ':' + 163 characters which correspond to SQLite parameter tokens. + 164 See https://www.sqlite.org/lang_expr.html#varparam 165 - 166 # pylint: disable=R0913 - 167 def __init__(self, filepath=None, print_sql=False): - 168 """Set up the database.""" - 169 self.filepath = ( - 170 filepath - 171 or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH) - 172 or DEFAULT_FILEPATH - 173 ) - 174 self.print_sql = print_sql - 175 if self.print_sql: - 176 self.execute_query_count = 0 - 177 - 178 # Test for migrated database in Things 3.15.16+ - 179 # -------------------------------- - 180 assert self.get_version() > 21, ( - 181 "Your database is in an older format. " - 182 "Run 'pip install things.py==0.0.14' to downgrade to an older " - 183 "version of this library." - 184 ) - 185 - 186 # Automated migration to new database location in Things 3.12.6/3.13.1 - 187 # -------------------------------- - 188 try: - 189 with open(self.filepath, encoding="utf-8") as file: - 190 if "Your database file has been moved there" in file.readline(): - 191 self.filepath = DEFAULT_FILEPATH - 192 except (UnicodeDecodeError, FileNotFoundError, PermissionError): - 193 pass # binary file (old database) or doesn't exist - 194 # -------------------------------- - 195 - 196 # Core methods - 197 - 198 def get_tasks( # pylint: disable=R0914 - 199 self, - 200 uuid=None, - 201 type=None, # pylint: disable=W0622 - 202 status=None, - 203 start=None, - 204 area=None, - 205 project=None, - 206 heading=None, - 207 tag=None, - 208 start_date=None, - 209 stop_date=None, - 210 deadline=None, - 211 deadline_suppressed=None, - 212 trashed=False, - 213 context_trashed=False, - 214 last=None, - 215 search_query=None, - 216 index="index", - 217 count_only=False, - 218 ): - 219 """Get tasks. See `things.api.tasks` for details on parameters.""" - 220 if uuid: - 221 return self.get_task_by_uuid(uuid, count_only=count_only) - 222 - 223 # Overwrites - 224 start = start and start.title() - 225 - 226 # Validation - 227 validate_date("deadline", deadline) - 228 validate("deadline_suppressed", deadline_suppressed, [None, True, False]) - 229 validate("start", start, [None] + list(START_TO_FILTER)) - 230 validate_date("start_date", start_date) - 231 validate_date("stop_date", stop_date) - 232 validate("status", status, [None] + list(STATUS_TO_FILTER)) - 233 validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) - 234 validate("type", type, [None] + list(TYPE_TO_FILTER)) - 235 validate("context_trashed", context_trashed, [None, True, False]) - 236 validate("index", index, list(INDICES)) - 237 validate_offset("last", last) - 238 - 239 if tag is not None: - 240 valid_tags = self.get_tags(titles_only=True) - 241 validate("tag", tag, [None] + list(valid_tags)) - 242 - 243 # Query - 244 # TK: might consider executing SQL with parameters instead. - 245 # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute - 246 - 247 start_filter: str = START_TO_FILTER.get(start, "") # type: ignore - 248 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore - 249 trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore - 250 type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore + 166 :raises AssertionError: If the database version is too old. + 167 """ + 168 + 169 debug = False + 170 + 171 # pylint: disable=R0913 + 172 def __init__(self, filepath=None, print_sql=False): + 173 """Set up the database.""" + 174 self.filepath = ( + 175 filepath + 176 or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH) + 177 or DEFAULT_FILEPATH + 178 ) + 179 self.print_sql = print_sql + 180 if self.print_sql: + 181 self.execute_query_count = 0 + 182 + 183 # Test for migrated database in Things 3.15.16+ + 184 # -------------------------------- + 185 assert self.get_version() > 21, ( + 186 "Your database is in an older format. " + 187 "Run 'pip install things.py==0.0.14' to downgrade to an older " + 188 "version of this library." + 189 ) + 190 + 191 # Automated migration to new database location in Things 3.12.6/3.13.1 + 192 # -------------------------------- + 193 try: + 194 with open(self.filepath, encoding="utf-8") as file: + 195 if "Your database file has been moved there" in file.readline(): + 196 self.filepath = DEFAULT_FILEPATH + 197 except (UnicodeDecodeError, FileNotFoundError, PermissionError): + 198 pass # binary file (old database) or doesn't exist + 199 # -------------------------------- + 200 + 201 # Core methods + 202 + 203 def get_tasks( # pylint: disable=R0914 + 204 self, + 205 uuid: Optional[str] = None, + 206 type: Optional[str] = None, # pylint: disable=W0622 + 207 status: Optional[str] = None, + 208 start: Optional[str] = None, + 209 area: Optional[Union[str, bool]] = None, + 210 project: Optional[Union[str, bool]] = None, + 211 heading: Optional[str] = None, + 212 tag: Optional[Union[str, bool]] = None, + 213 start_date: Optional[Union[str, bool]] = None, + 214 stop_date: Optional[Union[str, bool]] = None, + 215 deadline: Optional[Union[str, bool]] = None, + 216 deadline_suppressed: Optional[bool] = None, + 217 trashed: Optional[bool] = False, + 218 context_trashed: Optional[bool] = False, + 219 last: Optional[str] = None, + 220 search_query: Optional[str] = None, + 221 index: str = "index", + 222 count_only: bool = False, + 223 ): + 224 """Get tasks. See `things.api.tasks` for details on parameters.""" + 225 if uuid: + 226 return self.get_task_by_uuid(uuid, count_only=count_only) + 227 + 228 # Overwrites + 229 start = start and start.title() + 230 + 231 # Validation + 232 validate_date("deadline", deadline) + 233 validate("deadline_suppressed", deadline_suppressed, [None, True, False]) + 234 validate("start", start, [None] + list(START_TO_FILTER)) + 235 validate_date("start_date", start_date) + 236 validate_date("stop_date", stop_date) + 237 validate("status", status, [None] + list(STATUS_TO_FILTER)) + 238 validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER)) + 239 validate("type", type, [None] + list(TYPE_TO_FILTER)) + 240 validate("context_trashed", context_trashed, [None, True, False]) + 241 validate("index", index, list(INDICES)) + 242 validate_offset("last", last) + 243 + 244 if tag is not None: + 245 valid_tags = self.get_tags(titles_only=True) + 246 validate("tag", tag, [None] + list(valid_tags)) + 247 + 248 # Query + 249 # TK: might consider executing SQL with parameters instead. + 250 # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute 251 - 252 # Sometimes a task is _not_ set to trashed, but its context - 253 # (project or heading it is contained within) is set to trashed. - 254 # In those cases, the task wouldn't show up in any app view - 255 # except for "Trash". - 256 project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed) - 257 project_of_heading_trashed_filter = make_truthy_filter( - 258 "PROJECT_OF_HEADING.trashed", context_trashed - 259 ) - 260 - 261 # As a task assigned to a heading is not directly assigned to a project anymore, - 262 # we need to check if the heading is assigned to a project. - 263 # See, e.g. https://github.com/thingsapi/things.py/issues/94 - 264 project_filter = make_or_filter( - 265 make_filter("TASK.project", project), - 266 make_filter("PROJECT_OF_HEADING.uuid", project), - 267 ) - 268 - 269 where_predicate = f""" - 270 TASK.{IS_NOT_RECURRING} - 271 {trashed_filter and f"AND TASK.{trashed_filter}"} - 272 {project_trashed_filter} - 273 {project_of_heading_trashed_filter} - 274 {type_filter and f"AND TASK.{type_filter}"} - 275 {start_filter and f"AND TASK.{start_filter}"} - 276 {status_filter and f"AND TASK.{status_filter}"} - 277 {make_filter('TASK.uuid', uuid)} - 278 {make_filter("TASK.area", area)} - 279 {project_filter} - 280 {make_filter("TASK.heading", heading)} - 281 {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)} - 282 {make_filter("TAG.title", tag)} - 283 {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)} - 284 {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)} - 285 {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)} - 286 {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)} - 287 {make_search_filter(search_query)} - 288 """ - 289 order_predicate = f'TASK."{index}"' - 290 - 291 sql_query = make_tasks_sql_query(where_predicate, order_predicate) - 292 - 293 if count_only: - 294 return self.get_count(sql_query) + 252 start_filter: str = START_TO_FILTER.get(start, "") # type: ignore + 253 status_filter: str = STATUS_TO_FILTER.get(status, "") # type: ignore + 254 trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "") # type: ignore + 255 type_filter: str = TYPE_TO_FILTER.get(type, "") # type: ignore + 256 + 257 # Sometimes a task is _not_ set to trashed, but its context + 258 # (project or heading it is contained within) is set to trashed. + 259 # In those cases, the task wouldn't show up in any app view + 260 # except for "Trash". + 261 project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed) + 262 project_of_heading_trashed_filter = make_truthy_filter( + 263 "PROJECT_OF_HEADING.trashed", context_trashed + 264 ) + 265 + 266 # As a task assigned to a heading is not directly assigned to a project anymore, + 267 # we need to check if the heading is assigned to a project. + 268 # See, e.g. https://github.com/thingsapi/things.py/issues/94 + 269 project_filter = make_or_filter( + 270 make_filter("TASK.project", project), + 271 make_filter("PROJECT_OF_HEADING.uuid", project), + 272 ) + 273 + 274 where_predicate = f""" + 275 TASK.{IS_NOT_RECURRING} + 276 {trashed_filter and f"AND TASK.{trashed_filter}"} + 277 {project_trashed_filter} + 278 {project_of_heading_trashed_filter} + 279 {type_filter and f"AND TASK.{type_filter}"} + 280 {start_filter and f"AND TASK.{start_filter}"} + 281 {status_filter and f"AND TASK.{status_filter}"} + 282 {make_filter('TASK.uuid', uuid)} + 283 {make_filter("TASK.area", area)} + 284 {project_filter} + 285 {make_filter("TASK.heading", heading)} + 286 {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)} + 287 {make_filter("TAG.title", tag)} + 288 {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)} + 289 {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)} + 290 {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)} + 291 {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)} + 292 {make_search_filter(search_query)} + 293 """ + 294 order_predicate = f'TASK."{index}"' 295 - 296 return self.execute_query(sql_query) + 296 sql_query = make_tasks_sql_query(where_predicate, order_predicate) 297 - 298 def get_task_by_uuid(self, uuid, count_only=False): - 299 """Get a task by uuid. Raise `ValueError` if not found.""" - 300 where_predicate = "TASK.uuid = ?" - 301 sql_query = make_tasks_sql_query(where_predicate) - 302 parameters = (uuid,) - 303 - 304 if count_only: - 305 return self.get_count(sql_query, parameters) - 306 - 307 result = self.execute_query(sql_query, parameters) - 308 if not result: - 309 raise ValueError(f"No such task uuid found: {uuid!r}") - 310 - 311 return result - 312 - 313 def get_areas(self, uuid=None, tag=None, count_only=False): - 314 """Get areas. See `api.areas` for details on parameters.""" - 315 # Validation - 316 if tag is not None: - 317 valid_tags = self.get_tags(titles_only=True) - 318 validate("tag", tag, [None] + list(valid_tags)) - 319 - 320 if ( - 321 uuid - 322 and count_only is False - 323 and not self.get_areas(uuid=uuid, count_only=True) - 324 ): - 325 raise ValueError(f"No such area uuid found: {uuid!r}") - 326 - 327 # Query - 328 sql_query = f""" - 329 SELECT DISTINCT - 330 AREA.uuid, - 331 'area' as type, - 332 AREA.title, - 333 CASE - 334 WHEN AREA_TAG.areas IS NOT NULL THEN 1 - 335 END AS tags - 336 FROM - 337 {TABLE_AREA} AS AREA - 338 LEFT OUTER JOIN - 339 {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid - 340 LEFT OUTER JOIN - 341 {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags - 342 WHERE - 343 TRUE - 344 {make_filter('TAG.title', tag)} - 345 {make_filter('AREA.uuid', uuid)} - 346 ORDER BY AREA."index" - 347 """ - 348 - 349 if count_only: - 350 return self.get_count(sql_query) - 351 - 352 return self.execute_query(sql_query) + 298 if count_only: + 299 return self.get_count(sql_query) + 300 + 301 return self.execute_query(sql_query) + 302 + 303 def get_task_by_uuid(self, uuid, count_only=False): + 304 """Get a task by uuid. Raise `ValueError` if not found.""" + 305 where_predicate = "TASK.uuid = ?" + 306 sql_query = make_tasks_sql_query(where_predicate) + 307 parameters = (uuid,) + 308 + 309 if count_only: + 310 return self.get_count(sql_query, parameters) + 311 + 312 result = self.execute_query(sql_query, parameters) + 313 if not result: + 314 raise ValueError(f"No such task uuid found: {uuid!r}") + 315 + 316 return result + 317 + 318 def get_areas(self, uuid=None, tag=None, count_only=False): + 319 """Get areas. See `api.areas` for details on parameters.""" + 320 # Validation + 321 if tag is not None: + 322 valid_tags = self.get_tags(titles_only=True) + 323 validate("tag", tag, [None] + list(valid_tags)) + 324 + 325 if ( + 326 uuid + 327 and count_only is False + 328 and not self.get_areas(uuid=uuid, count_only=True) + 329 ): + 330 raise ValueError(f"No such area uuid found: {uuid!r}") + 331 + 332 # Query + 333 sql_query = f""" + 334 SELECT DISTINCT + 335 AREA.uuid, + 336 'area' as type, + 337 AREA.title, + 338 CASE + 339 WHEN AREA_TAG.areas IS NOT NULL THEN 1 + 340 END AS tags + 341 FROM + 342 {TABLE_AREA} AS AREA + 343 LEFT OUTER JOIN + 344 {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid + 345 LEFT OUTER JOIN + 346 {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags + 347 WHERE + 348 TRUE + 349 {make_filter('TAG.title', tag)} + 350 {make_filter('AREA.uuid', uuid)} + 351 ORDER BY AREA."index" + 352 """ 353 - 354 def get_checklist_items(self, todo_uuid=None): - 355 """Get checklist items.""" - 356 sql_query = f""" - 357 SELECT - 358 CHECKLIST_ITEM.title, - 359 CASE - 360 WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' - 361 WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' - 362 WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' - 363 END AS status, - 364 date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date, - 365 'checklist-item' as type, - 366 CHECKLIST_ITEM.uuid, - 367 datetime( - 368 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" - 369 ) AS created, - 370 datetime( - 371 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" - 372 ) AS modified - 373 FROM - 374 {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM - 375 WHERE - 376 CHECKLIST_ITEM.task = ? - 377 ORDER BY CHECKLIST_ITEM."index" - 378 """ - 379 return self.execute_query(sql_query, (todo_uuid,)) - 380 - 381 def get_tags(self, title=None, area=None, task=None, titles_only=False): - 382 """Get tags. See `api.tags` for details on parameters.""" - 383 # Validation - 384 if title is not None: - 385 valid_titles = self.get_tags(titles_only=True) - 386 validate("title", title, [None] + list(valid_titles)) - 387 - 388 # Query - 389 if task: - 390 return self.get_tags_of_task(task) - 391 if area: - 392 return self.get_tags_of_area(area) - 393 - 394 if titles_only: - 395 sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"' - 396 return self.execute_query(sql_query, row_factory=list_factory) - 397 - 398 sql_query = f""" - 399 SELECT - 400 uuid, 'tag' AS type, title, shortcut - 401 FROM - 402 {TABLE_TAG} - 403 WHERE - 404 TRUE - 405 {make_filter('title', title)} - 406 ORDER BY "index" - 407 """ - 408 - 409 return self.execute_query(sql_query) - 410 - 411 def get_tags_of_task(self, task_uuid): - 412 """Get tag titles of task.""" - 413 sql_query = f""" - 414 SELECT - 415 TAG.title - 416 FROM - 417 {TABLE_TASKTAG} AS TASK_TAG - 418 LEFT OUTER JOIN - 419 {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags - 420 WHERE - 421 TASK_TAG.tasks = ? - 422 ORDER BY TAG."index" - 423 """ - 424 return self.execute_query( - 425 sql_query, parameters=(task_uuid,), row_factory=list_factory - 426 ) - 427 - 428 def get_tags_of_area(self, area_uuid): - 429 """Get tag titles for area.""" - 430 sql_query = f""" - 431 SELECT - 432 AREA.title - 433 FROM - 434 {TABLE_AREATAG} AS AREA_TAG - 435 LEFT OUTER JOIN - 436 {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags - 437 WHERE - 438 AREA_TAG.areas = ? - 439 ORDER BY AREA."index" - 440 """ - 441 return self.execute_query( - 442 sql_query, parameters=(area_uuid,), row_factory=list_factory - 443 ) - 444 - 445 def get_version(self): - 446 """Get Things Database version.""" - 447 sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'" - 448 result = self.execute_query(sql_query, row_factory=list_factory) - 449 plist_bytes = result[0].encode() - 450 return plistlib.loads(plist_bytes) - 451 - 452 # pylint: disable=R1710 - 453 def get_url_scheme_auth_token(self): - 454 """Get the Things URL scheme authentication token.""" - 455 sql_query = f""" - 456 SELECT - 457 uriSchemeAuthenticationToken - 458 FROM - 459 {TABLE_SETTINGS} - 460 WHERE - 461 uuid = 'RhAzEf6qDxCD5PmnZVtBZR' - 462 """ - 463 rows = self.execute_query(sql_query, row_factory=list_factory) - 464 return rows[0] - 465 - 466 def get_count(self, sql_query, parameters=()): - 467 """Count number of results.""" - 468 count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)""" - 469 rows = self.execute_query( - 470 count_sql_query, row_factory=list_factory, parameters=parameters - 471 ) - 472 return rows[0] - 473 - 474 # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?) - 475 def execute_query(self, sql_query, parameters=(), row_factory=None): - 476 """Run the actual SQL query.""" - 477 if self.print_sql or self.debug: - 478 if not hasattr(self, "execute_query_count"): - 479 # This is needed for historical `self.debug`. - 480 # TK: might consider removing `debug` flag. - 481 self.execute_query_count = 0 - 482 self.execute_query_count += 1 - 483 if self.debug: - 484 print(f"/* Filepath {self.filepath!r} */") - 485 print(f"/* Query {self.execute_query_count} */") - 486 if parameters: - 487 print(f"/* Parameters: {parameters!r} */") - 488 print() - 489 print(prettify_sql(sql_query)) - 490 print() - 491 - 492 # "ro" means read-only - 493 # See: https://sqlite.org/uri.html#recognized_query_parameters - 494 uri = f"file:{self.filepath}?mode=ro" - 495 connection = sqlite3.connect(uri, uri=True) # pylint: disable=E1101 - 496 connection.row_factory = row_factory or dict_factory - 497 cursor = connection.cursor() - 498 cursor.execute(sql_query, parameters) - 499 - 500 return cursor.fetchall() - 501 - 502 - 503# Helper functions + 354 if count_only: + 355 return self.get_count(sql_query) + 356 + 357 return self.execute_query(sql_query) + 358 + 359 def get_checklist_items(self, todo_uuid=None): + 360 """Get checklist items.""" + 361 sql_query = f""" + 362 SELECT + 363 CHECKLIST_ITEM.title, + 364 CASE + 365 WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete' + 366 WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled' + 367 WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed' + 368 END AS status, + 369 date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date, + 370 'checklist-item' as type, + 371 CHECKLIST_ITEM.uuid, + 372 datetime( + 373 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" + 374 ) AS created, + 375 datetime( + 376 CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime" + 377 ) AS modified + 378 FROM + 379 {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM + 380 WHERE + 381 CHECKLIST_ITEM.task = ? + 382 ORDER BY CHECKLIST_ITEM."index" + 383 """ + 384 return self.execute_query(sql_query, (todo_uuid,)) + 385 + 386 def get_tags(self, title=None, area=None, task=None, titles_only=False): + 387 """Get tags. See `api.tags` for details on parameters.""" + 388 # Validation + 389 if title is not None: + 390 valid_titles = self.get_tags(titles_only=True) + 391 validate("title", title, [None] + list(valid_titles)) + 392 + 393 # Query + 394 if task: + 395 return self.get_tags_of_task(task) + 396 if area: + 397 return self.get_tags_of_area(area) + 398 + 399 if titles_only: + 400 sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"' + 401 return self.execute_query(sql_query, row_factory=list_factory) + 402 + 403 sql_query = f""" + 404 SELECT + 405 uuid, 'tag' AS type, title, shortcut + 406 FROM + 407 {TABLE_TAG} + 408 WHERE + 409 TRUE + 410 {make_filter('title', title)} + 411 ORDER BY "index" + 412 """ + 413 + 414 return self.execute_query(sql_query) + 415 + 416 def get_tags_of_task(self, task_uuid): + 417 """Get tag titles of task.""" + 418 sql_query = f""" + 419 SELECT + 420 TAG.title + 421 FROM + 422 {TABLE_TASKTAG} AS TASK_TAG + 423 LEFT OUTER JOIN + 424 {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags + 425 WHERE + 426 TASK_TAG.tasks = ? + 427 ORDER BY TAG."index" + 428 """ + 429 return self.execute_query( + 430 sql_query, parameters=(task_uuid,), row_factory=list_factory + 431 ) + 432 + 433 def get_tags_of_area(self, area_uuid): + 434 """Get tag titles for area.""" + 435 sql_query = f""" + 436 SELECT + 437 AREA.title + 438 FROM + 439 {TABLE_AREATAG} AS AREA_TAG + 440 LEFT OUTER JOIN + 441 {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags + 442 WHERE + 443 AREA_TAG.areas = ? + 444 ORDER BY AREA."index" + 445 """ + 446 return self.execute_query( + 447 sql_query, parameters=(area_uuid,), row_factory=list_factory + 448 ) + 449 + 450 def get_version(self): + 451 """Get Things Database version.""" + 452 sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'" + 453 result = self.execute_query(sql_query, row_factory=list_factory) + 454 plist_bytes = result[0].encode() + 455 return plistlib.loads(plist_bytes) + 456 + 457 # pylint: disable=R1710 + 458 def get_url_scheme_auth_token(self): + 459 """Get the Things URL scheme authentication token.""" + 460 sql_query = f""" + 461 SELECT + 462 uriSchemeAuthenticationToken + 463 FROM + 464 {TABLE_SETTINGS} + 465 WHERE + 466 uuid = 'RhAzEf6qDxCD5PmnZVtBZR' + 467 """ + 468 rows = self.execute_query(sql_query, row_factory=list_factory) + 469 return rows[0] + 470 + 471 def get_count(self, sql_query, parameters=()): + 472 """Count number of results.""" + 473 count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)""" + 474 rows = self.execute_query( + 475 count_sql_query, row_factory=list_factory, parameters=parameters + 476 ) + 477 return rows[0] + 478 + 479 # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?) + 480 def execute_query(self, sql_query, parameters=(), row_factory=None): + 481 """Run the actual SQL query.""" + 482 if self.print_sql or self.debug: + 483 if not hasattr(self, "execute_query_count"): + 484 # This is needed for historical `self.debug`. + 485 # TK: might consider removing `debug` flag. + 486 self.execute_query_count = 0 + 487 self.execute_query_count += 1 + 488 if self.debug: + 489 print(f"/* Filepath {self.filepath!r} */") + 490 print(f"/* Query {self.execute_query_count} */") + 491 if parameters: + 492 print(f"/* Parameters: {parameters!r} */") + 493 print() + 494 print(prettify_sql(sql_query)) + 495 print() + 496 + 497 # "ro" means read-only + 498 # See: https://sqlite.org/uri.html#recognized_query_parameters + 499 uri = f"file:{self.filepath}?mode=ro" + 500 connection = sqlite3.connect(uri, uri=True) # pylint: disable=E1101 + 501 connection.row_factory = row_factory or dict_factory + 502 cursor = connection.cursor() + 503 cursor.execute(sql_query, parameters) 504 - 505 - 506def make_tasks_sql_query(where_predicate=None, order_predicate=None): - 507 """Make SQL query for Task table.""" - 508 where_predicate = where_predicate or "TRUE" - 509 order_predicate = order_predicate or 'TASK."index"' + 505 return cursor.fetchall() + 506 + 507 + 508# Helper functions + 509 510 - 511 start_date_expression = convert_thingsdate_sql_expression_to_isodate( - 512 f"TASK.{DATE_START}" - 513 ) - 514 deadline_expression = convert_thingsdate_sql_expression_to_isodate( - 515 f"TASK.{DATE_DEADLINE}" - 516 ) - 517 - 518 return f""" - 519 SELECT DISTINCT - 520 TASK.uuid, - 521 CASE - 522 WHEN TASK.{IS_TODO} THEN 'to-do' - 523 WHEN TASK.{IS_PROJECT} THEN 'project' - 524 WHEN TASK.{IS_HEADING} THEN 'heading' - 525 END AS type, + 511def make_tasks_sql_query(where_predicate=None, order_predicate=None): + 512 """Make SQL query for Task table.""" + 513 where_predicate = where_predicate or "TRUE" + 514 order_predicate = order_predicate or 'TASK."index"' + 515 + 516 start_date_expression = convert_thingsdate_sql_expression_to_isodate( + 517 f"TASK.{DATE_START}" + 518 ) + 519 deadline_expression = convert_thingsdate_sql_expression_to_isodate( + 520 f"TASK.{DATE_DEADLINE}" + 521 ) + 522 + 523 return f""" + 524 SELECT DISTINCT + 525 TASK.uuid, 526 CASE - 527 WHEN TASK.{IS_TRASHED} THEN 1 - 528 END AS trashed, - 529 TASK.title, - 530 CASE - 531 WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete' - 532 WHEN TASK.{IS_CANCELED} THEN 'canceled' - 533 WHEN TASK.{IS_COMPLETED} THEN 'completed' - 534 END AS status, + 527 WHEN TASK.{IS_TODO} THEN 'to-do' + 528 WHEN TASK.{IS_PROJECT} THEN 'project' + 529 WHEN TASK.{IS_HEADING} THEN 'heading' + 530 END AS type, + 531 CASE + 532 WHEN TASK.{IS_TRASHED} THEN 1 + 533 END AS trashed, + 534 TASK.title, 535 CASE - 536 WHEN AREA.uuid IS NOT NULL THEN AREA.uuid - 537 END AS area, - 538 CASE - 539 WHEN AREA.uuid IS NOT NULL THEN AREA.title - 540 END AS area_title, - 541 CASE - 542 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid - 543 END AS project, - 544 CASE - 545 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.title - 546 END AS project_title, - 547 CASE - 548 WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid - 549 END AS heading, - 550 CASE - 551 WHEN HEADING.uuid IS NOT NULL THEN HEADING.title - 552 END AS heading_title, - 553 TASK.notes, - 554 CASE - 555 WHEN TAG.uuid IS NOT NULL THEN 1 - 556 END AS tags, - 557 CASE - 558 WHEN TASK.{IS_INBOX} THEN 'Inbox' - 559 WHEN TASK.{IS_ANYTIME} THEN 'Anytime' - 560 WHEN TASK.{IS_SOMEDAY} THEN 'Someday' - 561 END AS start, + 536 WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete' + 537 WHEN TASK.{IS_CANCELED} THEN 'canceled' + 538 WHEN TASK.{IS_COMPLETED} THEN 'completed' + 539 END AS status, + 540 CASE + 541 WHEN AREA.uuid IS NOT NULL THEN AREA.uuid + 542 END AS area, + 543 CASE + 544 WHEN AREA.uuid IS NOT NULL THEN AREA.title + 545 END AS area_title, + 546 CASE + 547 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid + 548 END AS project, + 549 CASE + 550 WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.title + 551 END AS project_title, + 552 CASE + 553 WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid + 554 END AS heading, + 555 CASE + 556 WHEN HEADING.uuid IS NOT NULL THEN HEADING.title + 557 END AS heading_title, + 558 TASK.notes, + 559 CASE + 560 WHEN TAG.uuid IS NOT NULL THEN 1 + 561 END AS tags, 562 CASE - 563 WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1 - 564 END AS checklist, - 565 date({start_date_expression}) AS start_date, - 566 date({deadline_expression}) AS deadline, - 567 datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date", - 568 datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created, - 569 datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified, - 570 TASK.'index', - 571 TASK.todayIndex AS today_index - 572 FROM - 573 {TABLE_TASK} AS TASK - 574 LEFT OUTER JOIN - 575 {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid - 576 LEFT OUTER JOIN - 577 {TABLE_AREA} AREA ON TASK.area = AREA.uuid - 578 LEFT OUTER JOIN - 579 {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid - 580 LEFT OUTER JOIN - 581 {TABLE_TASK} PROJECT_OF_HEADING - 582 ON HEADING.project = PROJECT_OF_HEADING.uuid + 563 WHEN TASK.{IS_INBOX} THEN 'Inbox' + 564 WHEN TASK.{IS_ANYTIME} THEN 'Anytime' + 565 WHEN TASK.{IS_SOMEDAY} THEN 'Someday' + 566 END AS start, + 567 CASE + 568 WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1 + 569 END AS checklist, + 570 date({start_date_expression}) AS start_date, + 571 date({deadline_expression}) AS deadline, + 572 datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date", + 573 datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created, + 574 datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified, + 575 TASK.'index', + 576 TASK.todayIndex AS today_index + 577 FROM + 578 {TABLE_TASK} AS TASK + 579 LEFT OUTER JOIN + 580 {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid + 581 LEFT OUTER JOIN + 582 {TABLE_AREA} AREA ON TASK.area = AREA.uuid 583 LEFT OUTER JOIN - 584 {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks + 584 {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid 585 LEFT OUTER JOIN - 586 {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid - 587 LEFT OUTER JOIN - 588 {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM - 589 ON TASK.uuid = CHECKLIST_ITEM.task - 590 WHERE - 591 {where_predicate} - 592 ORDER BY - 593 {order_predicate} - 594 """ - 595 - 596 - 597# In alphabetical order from here... - 598 - 599 - 600def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True): - 601 """ - 602 Return a SQL expression of an isodate converted into a "Things date". + 586 {TABLE_TASK} PROJECT_OF_HEADING + 587 ON HEADING.project = PROJECT_OF_HEADING.uuid + 588 LEFT OUTER JOIN + 589 {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks + 590 LEFT OUTER JOIN + 591 {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid + 592 LEFT OUTER JOIN + 593 {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM + 594 ON TASK.uuid = CHECKLIST_ITEM.task + 595 WHERE + 596 {where_predicate} + 597 ORDER BY + 598 {order_predicate} + 599 """ + 600 + 601 + 602# In alphabetical order from here... 603 - 604 A _Things date_ is an integer where the binary digits are - 605 YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day. - 606 - 607 For example, the ISO 8601 date '2021-03-28' corresponds to the Things - 608 date 132464128 as integer; in binary that is: - 609 111111001010011111000000000 - 610 YYYYYYYYYYYMMMMDDDDD0000000 - 611 2021 3 28 - 612 - 613 Parameters - 614 ---------- - 615 sql_expression : str - 616 A sql expression evaluating to an ISO 8601 date str. + 604 + 605def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True): + 606 """ + 607 Return a SQL expression of an isodate converted into a "Things date". + 608 + 609 A _Things date_ is an integer where the binary digits are + 610 YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day. + 611 + 612 For example, the ISO 8601 date '2021-03-28' corresponds to the Things + 613 date 132464128 as integer; in binary that is: + 614 111111001010011111000000000 + 615 YYYYYYYYYYYMMMMDDDDD0000000 + 616 2021 3 28 617 - 618 null_possible : bool - 619 Can the input `sql_expression` evaluate to NULL? - 620 - 621 Returns - 622 ------- - 623 str - 624 A sql expression representing a "Things date" as integer. + 618 Parameters + 619 ---------- + 620 sql_expression : str + 621 A sql expression evaluating to an ISO 8601 date str. + 622 + 623 null_possible : bool + 624 Can the input `sql_expression` evaluate to NULL? 625 - 626 Example + 626 Returns 627 ------- - 628 >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')") - 629 "(CASE WHEN date('now', 'localtime') THEN \ - 630 ((strftime('%Y', date('now', 'localtime')) << 16) \ - 631 | (strftime('%m', date('now', 'localtime')) << 12) \ - 632 | (strftime('%d', date('now', 'localtime')) << 7)) \ - 633 ELSE date('now', 'localtime') END)" - 634 >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'") - 635 "(CASE WHEN '2023-05-22' THEN \ - 636 ((strftime('%Y', '2023-05-22') << 16) \ - 637 | (strftime('%m', '2023-05-22') << 12) \ - 638 | (strftime('%d', '2023-05-22') << 7)) \ - 639 ELSE '2023-05-22' END)" - 640 """ - 641 isodate = sql_expression - 642 - 643 year = f"strftime('%Y', {isodate}) << 16" - 644 month = f"strftime('%m', {isodate}) << 12" - 645 day = f"strftime('%d', {isodate}) << 7" - 646 - 647 thingsdate = f"(({year}) | ({month}) | ({day}))" - 648 - 649 if null_possible: - 650 # when isodate is NULL, return isodate as-is - 651 return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)" - 652 - 653 return thingsdate - 654 - 655 - 656def convert_thingsdate_sql_expression_to_isodate(sql_expression): - 657 """ - 658 Return SQL expression as string. + 628 str + 629 A sql expression representing a "Things date" as integer. + 630 + 631 Example + 632 ------- + 633 >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')") + 634 "(CASE WHEN date('now', 'localtime') THEN \ + 635 ((strftime('%Y', date('now', 'localtime')) << 16) \ + 636 | (strftime('%m', date('now', 'localtime')) << 12) \ + 637 | (strftime('%d', date('now', 'localtime')) << 7)) \ + 638 ELSE date('now', 'localtime') END)" + 639 >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'") + 640 "(CASE WHEN '2023-05-22' THEN \ + 641 ((strftime('%Y', '2023-05-22') << 16) \ + 642 | (strftime('%m', '2023-05-22') << 12) \ + 643 | (strftime('%d', '2023-05-22') << 7)) \ + 644 ELSE '2023-05-22' END)" + 645 """ + 646 isodate = sql_expression + 647 + 648 year = f"strftime('%Y', {isodate}) << 16" + 649 month = f"strftime('%m', {isodate}) << 12" + 650 day = f"strftime('%d', {isodate}) << 7" + 651 + 652 thingsdate = f"(({year}) | ({month}) | ({day}))" + 653 + 654 if null_possible: + 655 # when isodate is NULL, return isodate as-is + 656 return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)" + 657 + 658 return thingsdate 659 - 660 Parameters - 661 ---------- - 662 sql_expression : str - 663 A sql expression pointing to a "Things date" integer in - 664 format YYYYYYYYYYYMMMMDDDDD0000000, in binary. - 665 See: `convert_isodate_sql_expression_to_thingsdate` for details. - 666 - 667 Example - 668 ------- - 669 >>> convert_thingsdate_sql_expression_to_isodate('132464128') - 670 "CASE WHEN 132464128 THEN \ - 671 printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \ - 672 (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END" - 673 >>> convert_thingsdate_sql_expression_to_isodate('startDate') - 674 "CASE WHEN startDate THEN \ - 675 printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \ - 676 (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END" - 677 """ - 678 y_mask = 0b111111111110000000000000000 - 679 m_mask = 0b000000000001111000000000000 - 680 d_mask = 0b000000000000000111110000000 - 681 - 682 thingsdate = sql_expression - 683 year = f"({thingsdate} & {y_mask}) >> 16" - 684 month = f"({thingsdate} & {m_mask}) >> 12" - 685 day = f"({thingsdate} & {d_mask}) >> 7" + 660 + 661def convert_thingsdate_sql_expression_to_isodate(sql_expression): + 662 """ + 663 Return SQL expression as string. + 664 + 665 Parameters + 666 ---------- + 667 sql_expression : str + 668 A sql expression pointing to a "Things date" integer in + 669 format YYYYYYYYYYYMMMMDDDDD0000000, in binary. + 670 See: `convert_isodate_sql_expression_to_thingsdate` for details. + 671 + 672 Example + 673 ------- + 674 >>> convert_thingsdate_sql_expression_to_isodate('132464128') + 675 "CASE WHEN 132464128 THEN \ + 676 printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \ + 677 (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END" + 678 >>> convert_thingsdate_sql_expression_to_isodate('startDate') + 679 "CASE WHEN startDate THEN \ + 680 printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \ + 681 (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END" + 682 """ + 683 y_mask = 0b111111111110000000000000000 + 684 m_mask = 0b000000000001111000000000000 + 685 d_mask = 0b000000000000000111110000000 686 - 687 isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})" - 688 # when thingsdate is NULL, return thingsdate as-is - 689 return f"CASE WHEN {thingsdate} THEN {isodate} ELSE {thingsdate} END" - 690 + 687 thingsdate = sql_expression + 688 year = f"({thingsdate} & {y_mask}) >> 16" + 689 month = f"({thingsdate} & {m_mask}) >> 12" + 690 day = f"({thingsdate} & {d_mask}) >> 7" 691 - 692def dict_factory(cursor, row): - 693 """ - 694 Convert SQL result into a dictionary. + 692 isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})" + 693 # when thingsdate is NULL, return thingsdate as-is + 694 return f"CASE WHEN {thingsdate} THEN {isodate} ELSE {thingsdate} END" 695 - 696 See also: - 697 https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory - 698 """ - 699 result = {} - 700 for index, column in enumerate(cursor.description): - 701 key, value = column[0], row[index] - 702 if value is None and key in COLUMNS_TO_OMIT_IF_NONE: - 703 continue - 704 if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL: - 705 value = bool(value) - 706 result[key] = value - 707 return result - 708 - 709 - 710def escape_string(string): - 711 r""" - 712 Escape SQLite string literal. + 696 + 697def dict_factory(cursor, row): + 698 """ + 699 Convert SQL result into a dictionary. + 700 + 701 See also: + 702 https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory + 703 """ + 704 result = {} + 705 for index, column in enumerate(cursor.description): + 706 key, value = column[0], row[index] + 707 if value is None and key in COLUMNS_TO_OMIT_IF_NONE: + 708 continue + 709 if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL: + 710 value = bool(value) + 711 result[key] = value + 712 return result 713 - 714 Three notes: - 715 - 716 1. A single quote within a SQLite string can be encoded by putting - 717 two single quotes in a row. Escapes using the backslash character - 718 are not supported in SQLite. - 719 - 720 2. Null characters '\0' within strings can lead to surprising - 721 behavior. However, `cursor.execute` will already throw a `ValueError` - 722 if it finds a null character in the query, so we let it handle - 723 this case for us. + 714 + 715def escape_string(string): + 716 r""" + 717 Escape SQLite string literal. + 718 + 719 Three notes: + 720 + 721 1. A single quote within a SQLite string can be encoded by putting + 722 two single quotes in a row. Escapes using the backslash character + 723 are not supported in SQLite. 724 - 725 3. Eventually we might want to make use of parameters instead of - 726 manually escaping. Since this will require some refactoring, - 727 we are going with the easiest solution for now. - 728 - 729 See: https://www.sqlite.org/lang_expr.html#literal_values_constants_ - 730 """ - 731 return string.replace("'", "''") - 732 + 725 2. Null characters '\0' within strings can lead to surprising + 726 behavior. However, `cursor.execute` will already throw a `ValueError` + 727 if it finds a null character in the query, so we let it handle + 728 this case for us. + 729 + 730 3. Eventually we might want to make use of parameters instead of + 731 manually escaping. Since this will require some refactoring, + 732 we are going with the easiest solution for now. 733 - 734def isodate_to_yyyyyyyyyyymmmmddddd(value: str): - 735 """ - 736 Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000. + 734 See: https://www.sqlite.org/lang_expr.html#literal_values_constants_ + 735 """ + 736 return string.replace("'", "''") 737 - 738 Y is year, M is month, D is day as binary. - 739 See also `convert_isodate_sql_expression_to_thingsdate`. - 740 - 741 Parameters - 742 ---------- - 743 value : str - 744 ISO 8601 date str + 738 + 739def isodate_to_yyyyyyyyyyymmmmddddd(value: str): + 740 """ + 741 Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000. + 742 + 743 Y is year, M is month, D is day as binary. + 744 See also `convert_isodate_sql_expression_to_thingsdate`. 745 - 746 Example - 747 ------- - 748 >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28') - 749 132464128 - 750 """ - 751 year, month, day = map(int, value.split("-")) - 752 return year << 16 | month << 12 | day << 7 - 753 - 754 - 755def list_factory(_cursor, row): - 756 """Convert SQL selects of one column into a list.""" - 757 return row[0] + 746 Parameters + 747 ---------- + 748 value : str + 749 ISO 8601 date str + 750 + 751 Example + 752 ------- + 753 >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28') + 754 132464128 + 755 """ + 756 year, month, day = map(int, value.split("-")) + 757 return year << 16 | month << 12 | day << 7 758 759 - 760def make_filter(column, value): - 761 """ - 762 Return SQL filter 'AND {column} = "{value}"'. + 760def list_factory(_cursor, row): + 761 """Convert SQL selects of one column into a list.""" + 762 return row[0] 763 - 764 Special handling if `value` is `bool` or `None`. - 765 - 766 Examples - 767 -------- - 768 >>> make_filter('title', 'Important') - 769 "AND title = 'Important'" + 764 + 765def make_filter(column, value): + 766 """ + 767 Return SQL filter 'AND {column} = "{value}"'. + 768 + 769 Special handling if `value` is `bool` or `None`. 770 - 771 >>> make_filter('startDate', True) - 772 'AND startDate IS NOT NULL' - 773 - 774 >>> make_filter('startDate', False) - 775 'AND startDate IS NULL' - 776 - 777 >>> make_filter('title', None) - 778 '' - 779 """ - 780 default = f"AND {column} = '{escape_string(str(value))}'" - 781 return { - 782 None: "", - 783 False: f"AND {column} IS NULL", - 784 True: f"AND {column} IS NOT NULL", - 785 }.get(value, default) - 786 - 787 - 788def make_or_filter(*filters): - 789 """Join filters with OR.""" - 790 filters = filter(None, filters) # type: ignore - 791 filters = [remove_prefix(filter, "AND ") for filter in filters] # type: ignore - 792 filters = " OR ".join(filters) # type: ignore - 793 return f"AND ({filters})" if filters else "" - 794 - 795 - 796def make_search_filter(query: Optional[str]) -> str: - 797 """ - 798 Return a SQL filter to search tasks by a string query. + 771 Examples + 772 -------- + 773 >>> make_filter('title', 'Important') + 774 "AND title = 'Important'" + 775 + 776 >>> make_filter('startDate', True) + 777 'AND startDate IS NOT NULL' + 778 + 779 >>> make_filter('startDate', False) + 780 'AND startDate IS NULL' + 781 + 782 >>> make_filter('title', None) + 783 '' + 784 """ + 785 default = f"AND {column} = '{escape_string(str(value))}'" + 786 return { + 787 None: "", + 788 False: f"AND {column} IS NULL", + 789 True: f"AND {column} IS NOT NULL", + 790 }.get(value, default) + 791 + 792 + 793def make_or_filter(*filters): + 794 """Join filters with OR.""" + 795 filters = filter(None, filters) # type: ignore + 796 filters = [remove_prefix(filter, "AND ") for filter in filters] # type: ignore + 797 filters = " OR ".join(filters) # type: ignore + 798 return f"AND ({filters})" if filters else "" 799 - 800 Example: - 801 -------- - 802 >>> make_search_filter('dinner') #doctest: +REPORT_NDIFF - 803 "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR AREA.title LIKE '%dinner%')" - 804 """ - 805 if not query: - 806 return "" - 807 - 808 query = escape_string(query) - 809 - 810 # noqa todo 'TMChecklistItem.title' - 811 columns = ["TASK.title", "TASK.notes", "AREA.title"] - 812 - 813 sub_searches = (f"{column} LIKE '%{query}%'" for column in columns) - 814 - 815 return f"AND ({' OR '.join(sub_searches)})" - 816 - 817 - 818def make_thingsdate_filter(date_column: str, value) -> str: - 819 """ - 820 Return a SQL filter for "Things date" columns. - 821 - 822 Parameters - 823 ---------- - 824 date_column : str - 825 Name of the column that has date information on a task - 826 stored as an INTEGER in "Things date" format. - 827 See `convert_isodate_sql_expression_to_thingsdate` for details - 828 on Things dates. - 829 - 830 value : bool, 'future', 'past', ISO 8601 date str, or None - 831 `True` or `False` indicates whether a date is set or not. - 832 `'future'` or `'past'` indicates a date in the future or past. - 833 ISO 8601 date str is in the format "YYYY-MM-DD", possibly - 834 prefixed with an operator such as ">YYYY-MM-DD", - 835 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. - 836 `None` indicates any value. - 837 - 838 Returns - 839 ------- - 840 str - 841 A date filter for the SQL query. If `value == None`, then - 842 return the empty string. + 800 + 801def make_search_filter(query: Optional[str]) -> str: + 802 """ + 803 Return a SQL filter to search tasks by a string query. + 804 + 805 Example: + 806 -------- + 807 >>> make_search_filter('dinner') + 808 "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR \ + 809 AREA.title LIKE '%dinner%')" + 810 """ + 811 if not query: + 812 return "" + 813 + 814 query = escape_string(query) + 815 + 816 # noqa todo 'TMChecklistItem.title' + 817 columns = ["TASK.title", "TASK.notes", "AREA.title"] + 818 + 819 sub_searches = (f"{column} LIKE '%{query}%'" for column in columns) + 820 + 821 return f"AND ({' OR '.join(sub_searches)})" + 822 + 823 + 824def make_thingsdate_filter(date_column: str, value) -> str: + 825 """ + 826 Return a SQL filter for "Things date" columns. + 827 + 828 Parameters + 829 ---------- + 830 date_column : str + 831 Name of the column that has date information on a task + 832 stored as an INTEGER in "Things date" format. + 833 See `convert_isodate_sql_expression_to_thingsdate` for details + 834 on Things dates. + 835 + 836 value : bool, 'future', 'past', ISO 8601 date str, or None + 837 `True` or `False` indicates whether a date is set or not. + 838 `'future'` or `'past'` indicates a date in the future or past. + 839 ISO 8601 date str is in the format "YYYY-MM-DD", possibly + 840 prefixed with an operator such as ">YYYY-MM-DD", + 841 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. + 842 `None` indicates any value. 843 - 844 Examples - 845 -------- - 846 >>> make_thingsdate_filter('startDate', True) - 847 'AND startDate IS NOT NULL' - 848 - 849 >>> make_thingsdate_filter('startDate', False) - 850 'AND startDate IS NULL' - 851 - 852 >>> make_thingsdate_filter('startDate', 'future') - 853 "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \ - 854 | (strftime('%m', date('now', 'localtime')) << 12) \ - 855 | (strftime('%d', date('now', 'localtime')) << 7))" - 856 - 857 >>> make_thingsdate_filter('deadline', '2021-03-28') - 858 'AND deadline == 132464128' - 859 - 860 >>> make_thingsdate_filter('deadline', '=2021-03-28') - 861 'AND deadline = 132464128' + 844 Returns + 845 ------- + 846 str + 847 A date filter for the SQL query. If `value == None`, then + 848 return the empty string. + 849 + 850 Examples + 851 -------- + 852 >>> make_thingsdate_filter('startDate', True) + 853 'AND startDate IS NOT NULL' + 854 + 855 >>> make_thingsdate_filter('startDate', False) + 856 'AND startDate IS NULL' + 857 + 858 >>> make_thingsdate_filter('startDate', 'future') + 859 "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \ + 860 | (strftime('%m', date('now', 'localtime')) << 12) \ + 861 | (strftime('%d', date('now', 'localtime')) << 7))" 862 - 863 >>> make_thingsdate_filter('deadline', '<=2021-03-28') - 864 'AND deadline <= 132464128' + 863 >>> make_thingsdate_filter('deadline', '2021-03-28') + 864 'AND deadline == 132464128' 865 - 866 >>> make_thingsdate_filter('deadline', None) - 867 '' + 866 >>> make_thingsdate_filter('deadline', '=2021-03-28') + 867 'AND deadline = 132464128' 868 - 869 """ - 870 if value is None: - 871 return "" - 872 - 873 if isinstance(value, bool): - 874 return make_filter(date_column, value) - 875 - 876 # Check for ISO 8601 date str + optional operator - 877 match = match_date(value) - 878 if match: - 879 comparator, isodate = match.groups() - 880 if not comparator: - 881 comparator = "==" - 882 thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate) - 883 threshold = str(thingsdate) - 884 else: - 885 # "future" or "past" - 886 validate("value", value, ["future", "past"]) - 887 threshold = convert_isodate_sql_expression_to_thingsdate( - 888 "date('now', 'localtime')", null_possible=False - 889 ) - 890 comparator = ">" if value == "future" else "<=" - 891 - 892 return f"AND {date_column} {comparator} {threshold}" - 893 - 894 - 895def make_truthy_filter(column: str, value) -> str: - 896 """ - 897 Return a SQL filter that matches if a column is truthy or falsy. - 898 - 899 Truthy means TRUE. Falsy means FALSE or NULL. This is akin - 900 to how Python defines it natively. - 901 - 902 Passing in `value == None` returns the empty string. - 903 - 904 Examples - 905 -------- - 906 >>> make_truthy_filter('PROJECT.trashed', True) - 907 'AND PROJECT.trashed' - 908 - 909 >>> make_truthy_filter('PROJECT.trashed', False) - 910 'AND NOT IFNULL(PROJECT.trashed, 0)' - 911 - 912 >>> make_truthy_filter('PROJECT.trashed', None) - 913 '' - 914 """ - 915 if value is None: - 916 return "" + 869 >>> make_thingsdate_filter('deadline', '<=2021-03-28') + 870 'AND deadline <= 132464128' + 871 + 872 >>> make_thingsdate_filter('deadline', None) + 873 '' + 874 + 875 """ + 876 if value is None: + 877 return "" + 878 + 879 if isinstance(value, bool): + 880 return make_filter(date_column, value) + 881 + 882 # Check for ISO 8601 date str + optional operator + 883 match = match_date(value) + 884 if match: + 885 comparator, isodate = match.groups() + 886 if not comparator: + 887 comparator = "==" + 888 thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate) + 889 threshold = str(thingsdate) + 890 else: + 891 # "future" or "past" + 892 validate("value", value, ["future", "past"]) + 893 threshold = convert_isodate_sql_expression_to_thingsdate( + 894 "date('now', 'localtime')", null_possible=False + 895 ) + 896 comparator = ">" if value == "future" else "<=" + 897 + 898 return f"AND {date_column} {comparator} {threshold}" + 899 + 900 + 901def make_truthy_filter(column: str, value) -> str: + 902 """ + 903 Return a SQL filter that matches if a column is truthy or falsy. + 904 + 905 Truthy means TRUE. Falsy means FALSE or NULL. This is akin + 906 to how Python defines it natively. + 907 + 908 Passing in `value == None` returns the empty string. + 909 + 910 Examples + 911 -------- + 912 >>> make_truthy_filter('PROJECT.trashed', True) + 913 'AND PROJECT.trashed' + 914 + 915 >>> make_truthy_filter('PROJECT.trashed', False) + 916 'AND NOT IFNULL(PROJECT.trashed, 0)' 917 - 918 if value: - 919 return f"AND {column}" - 920 - 921 return f"AND NOT IFNULL({column}, 0)" - 922 + 918 >>> make_truthy_filter('PROJECT.trashed', None) + 919 '' + 920 """ + 921 if value is None: + 922 return "" 923 - 924def make_unixtime_filter(date_column: str, value) -> str: - 925 """ - 926 Return a SQL filter for UNIX time columns. - 927 - 928 Parameters - 929 ---------- - 930 date_column : str - 931 Name of the column that has datetime information on a task - 932 stored in UNIX time, that is, number of seconds since - 933 1970-01-01 00:00 UTC. - 934 - 935 value : bool, 'future', 'past', ISO 8601 date str, or None - 936 `True` or `False` indicates whether a date is set or not. - 937 `'future'` or `'past'` indicates a date in the future or past. - 938 ISO 8601 date str is in the format "YYYY-MM-DD", possibly - 939 prefixed with an operator such as ">YYYY-MM-DD", - 940 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. - 941 `None` indicates any value. - 942 - 943 Returns - 944 ------- - 945 str - 946 A date filter for the SQL query. If `value == None`, then - 947 return the empty string. + 924 if value: + 925 return f"AND {column}" + 926 + 927 return f"AND NOT IFNULL({column}, 0)" + 928 + 929 + 930def make_unixtime_filter(date_column: str, value) -> str: + 931 """ + 932 Return a SQL filter for UNIX time columns. + 933 + 934 Parameters + 935 ---------- + 936 date_column : str + 937 Name of the column that has datetime information on a task + 938 stored in UNIX time, that is, number of seconds since + 939 1970-01-01 00:00 UTC. + 940 + 941 value : bool, 'future', 'past', ISO 8601 date str, or None + 942 `True` or `False` indicates whether a date is set or not. + 943 `'future'` or `'past'` indicates a date in the future or past. + 944 ISO 8601 date str is in the format "YYYY-MM-DD", possibly + 945 prefixed with an operator such as ">YYYY-MM-DD", + 946 "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. + 947 `None` indicates any value. 948 - 949 Examples - 950 -------- - 951 >>> make_unixtime_filter('stopDate', True) - 952 'AND stopDate IS NOT NULL' - 953 - 954 >>> make_unixtime_filter('stopDate', False) - 955 'AND stopDate IS NULL' - 956 - 957 >>> make_unixtime_filter('stopDate', 'future') - 958 "AND date(stopDate, 'unixepoch') > date('now', 'localtime')" + 949 Returns + 950 ------- + 951 str + 952 A date filter for the SQL query. If `value == None`, then + 953 return the empty string. + 954 + 955 Examples + 956 -------- + 957 >>> make_unixtime_filter('stopDate', True) + 958 'AND stopDate IS NOT NULL' 959 - 960 >>> make_unixtime_filter('creationDate', '2021-03-28') - 961 "AND date(creationDate, 'unixepoch') == date('2021-03-28')" + 960 >>> make_unixtime_filter('stopDate', False) + 961 'AND stopDate IS NULL' 962 - 963 >>> make_unixtime_filter('creationDate', '=2021-03-28') - 964 "AND date(creationDate, 'unixepoch') = date('2021-03-28')" + 963 >>> make_unixtime_filter('stopDate', 'future') + 964 "AND date(stopDate, 'unixepoch') > date('now', 'localtime')" 965 - 966 >>> make_unixtime_filter('creationDate', '<=2021-03-28') - 967 "AND date(creationDate, 'unixepoch') <= date('2021-03-28')" + 966 >>> make_unixtime_filter('creationDate', '2021-03-28') + 967 "AND date(creationDate, 'unixepoch') == date('2021-03-28')" 968 - 969 >>> make_unixtime_filter('creationDate', None) - 970 '' + 969 >>> make_unixtime_filter('creationDate', '=2021-03-28') + 970 "AND date(creationDate, 'unixepoch') = date('2021-03-28')" 971 - 972 """ - 973 if value is None: - 974 return "" - 975 - 976 if isinstance(value, bool): - 977 return make_filter(date_column, value) - 978 - 979 # Check for ISO 8601 date str + optional operator - 980 match = match_date(value) - 981 if match: - 982 comparator, isodate = match.groups() - 983 if not comparator: - 984 comparator = "==" - 985 threshold = f"date('{isodate}')" - 986 else: - 987 # "future" or "past" - 988 validate("value", value, ["future", "past"]) - 989 threshold = "date('now', 'localtime')" - 990 comparator = ">" if value == "future" else "<=" - 991 - 992 date = f"date({date_column}, 'unixepoch')" - 993 - 994 return f"AND {date} {comparator} {threshold}" - 995 - 996 - 997def make_unixtime_range_filter(date_column: str, offset) -> str: - 998 """ - 999 Return a SQL filter to limit a Unix time to last X days, weeks, or years. -1000 -1001 Parameters -1002 ---------- -1003 date_column : str -1004 Name of the column that has datetime information on a task -1005 stored in UNIX time, that is, number of seconds since -1006 1970-01-01 00:00 UTC. -1007 -1008 offset : str or None -1009 A string comprised of an integer and a single character that can -1010 be 'd', 'w', or 'y' that determines whether to return all tasks -1011 for the past X days, weeks, or years. -1012 -1013 Returns -1014 ------- -1015 str -1016 A date filter for the SQL query. If `offset == None`, then -1017 return the empty string. + 972 >>> make_unixtime_filter('creationDate', '<=2021-03-28') + 973 "AND date(creationDate, 'unixepoch') <= date('2021-03-28')" + 974 + 975 >>> make_unixtime_filter('creationDate', None) + 976 '' + 977 + 978 """ + 979 if value is None: + 980 return "" + 981 + 982 if isinstance(value, bool): + 983 return make_filter(date_column, value) + 984 + 985 # Check for ISO 8601 date str + optional operator + 986 match = match_date(value) + 987 if match: + 988 comparator, isodate = match.groups() + 989 if not comparator: + 990 comparator = "==" + 991 threshold = f"date('{isodate}')" + 992 else: + 993 # "future" or "past" + 994 validate("value", value, ["future", "past"]) + 995 threshold = "date('now', 'localtime')" + 996 comparator = ">" if value == "future" else "<=" + 997 + 998 date = f"date({date_column}, 'unixepoch')" + 999 +1000 return f"AND {date} {comparator} {threshold}" +1001 +1002 +1003def make_unixtime_range_filter(date_column: str, offset) -> str: +1004 """ +1005 Return a SQL filter to limit a Unix time to last X days, weeks, or years. +1006 +1007 Parameters +1008 ---------- +1009 date_column : str +1010 Name of the column that has datetime information on a task +1011 stored in UNIX time, that is, number of seconds since +1012 1970-01-01 00:00 UTC. +1013 +1014 offset : str or None +1015 A string comprised of an integer and a single character that can +1016 be 'd', 'w', or 'y' that determines whether to return all tasks +1017 for the past X days, weeks, or years. 1018 -1019 Examples -1020 -------- -1021 >>> make_unixtime_range_filter('creationDate', '3d') -1022 "AND datetime(creationDate, 'unixepoch') > datetime('now', '-3 days')" -1023 -1024 >>> make_unixtime_range_filter('creationDate', None) -1025 '' -1026 """ -1027 if offset is None: -1028 return "" +1019 Returns +1020 ------- +1021 str +1022 A date filter for the SQL query. If `offset == None`, then +1023 return the empty string. +1024 +1025 Examples +1026 -------- +1027 >>> make_unixtime_range_filter('creationDate', '3d') +1028 "AND datetime(creationDate, 'unixepoch') > datetime('now', '-3 days')" 1029 -1030 validate_offset("offset", offset) -1031 number, suffix = int(offset[:-1]), offset[-1] -1032 -1033 if suffix == "d": -1034 modifier = f"-{number} days" -1035 elif suffix == "w": -1036 modifier = f"-{number * 7} days" -1037 elif suffix == "y": -1038 modifier = f"-{number} years" -1039 -1040 column_datetime = f"datetime({date_column}, 'unixepoch')" -1041 offset_datetime = f"datetime('now', '{modifier}')" # type: ignore -1042 -1043 return f"AND {column_datetime} > {offset_datetime}" -1044 +1030 >>> make_unixtime_range_filter('creationDate', None) +1031 '' +1032 """ +1033 if offset is None: +1034 return "" +1035 +1036 validate_offset("offset", offset) +1037 number, suffix = int(offset[:-1]), offset[-1] +1038 +1039 if suffix == "d": +1040 modifier = f"-{number} days" +1041 elif suffix == "w": +1042 modifier = f"-{number * 7} days" +1043 elif suffix == "y": +1044 modifier = f"-{number} years" 1045 -1046def match_date(value): -1047 """Return a match object if value is an ISO 8601 date str.""" -1048 return re.fullmatch(r"(=|==|<|<=|>|>=)?(\d{4}-\d{2}-\d{2})", value) -1049 +1046 column_datetime = f"datetime({date_column}, 'unixepoch')" +1047 offset_datetime = f"datetime('now', '{modifier}')" # type: ignore +1048 +1049 return f"AND {column_datetime} > {offset_datetime}" 1050 -1051def prettify_sql(sql_query): -1052 """Make a SQL query easier to read for humans.""" -1053 # remove indentation and leading and trailing whitespace -1054 result = dedent(sql_query).strip() -1055 # remove empty lines -1056 return re.sub(r"^$\n", "", result, flags=re.MULTILINE) -1057 -1058 -1059def remove_prefix(text, prefix): -1060 """Remove prefix from text (as removeprefix() is 3.9+ only).""" -1061 return text[text.startswith(prefix) and len(prefix) :] -1062 +1051 +1052def match_date(value): +1053 """Return a match object if value is an ISO 8601 date str.""" +1054 return re.fullmatch(r"(=|==|<|<=|>|>=)?(\d{4}-\d{2}-\d{2})", value) +1055 +1056 +1057def prettify_sql(sql_query): +1058 """Make a SQL query easier to read for humans.""" +1059 # remove indentation and leading and trailing whitespace +1060 result = dedent(sql_query).strip() +1061 # remove empty lines +1062 return re.sub(r"^$\n", "", result, flags=re.MULTILINE) 1063 -1064def validate(parameter, argument, valid_arguments): -1065 """ -1066 For a given parameter, check if its argument type is valid. -1067 -1068 If not, then raise `ValueError`. +1064 +1065def remove_prefix(text, prefix): +1066 """Remove prefix from text (as removeprefix() is 3.9+ only).""" +1067 return text[text.startswith(prefix) and len(prefix) :] +1068 1069 -1070 Examples -1071 -------- -1072 >>> validate( -1073 ... parameter='status', -1074 ... argument='completed', -1075 ... valid_arguments=['incomplete', 'completed'] -1076 ... ) -1077 ... -1078 -1079 >>> validate( -1080 ... parameter='status', -1081 ... argument='XYZXZY', -1082 ... valid_arguments=['incomplete', 'completed'] -1083 ... ) -1084 Traceback (most recent call last): -1085 ... -1086 ValueError: Unrecognized status type: 'XYZXZY' -1087 Valid status types are ['incomplete', 'completed'] -1088 """ -1089 if argument in valid_arguments: -1090 return -1091 message = f"Unrecognized {parameter} type: {argument!r}" -1092 message += f"\nValid {parameter} types are {valid_arguments}" -1093 raise ValueError(message) -1094 -1095 -1096def validate_date(parameter, argument): -1097 """ -1098 For a given date parameter, check if its argument is valid. -1099 -1100 If not, then raise `ValueError`. +1070def validate(parameter, argument, valid_arguments): +1071 """ +1072 For a given parameter, check if its argument type is valid. +1073 +1074 If not, then raise `ValueError`. +1075 +1076 Examples +1077 -------- +1078 >>> validate( +1079 ... parameter='status', +1080 ... argument='completed', +1081 ... valid_arguments=['incomplete', 'completed'] +1082 ... ) +1083 ... +1084 +1085 >>> validate( +1086 ... parameter='status', +1087 ... argument='XYZXZY', +1088 ... valid_arguments=['incomplete', 'completed'] +1089 ... ) +1090 Traceback (most recent call last): +1091 ... +1092 ValueError: Unrecognized status type: 'XYZXZY' +1093 Valid status types are ['incomplete', 'completed'] +1094 """ +1095 if argument in valid_arguments: +1096 return +1097 message = f"Unrecognized {parameter} type: {argument!r}" +1098 message += f"\nValid {parameter} types are {valid_arguments}" +1099 raise ValueError(message) +1100 1101 -1102 Examples -1103 -------- -1104 >>> validate_date(parameter='startDate', argument=None) -1105 >>> validate_date(parameter='startDate', argument='future') -1106 >>> validate_date(parameter='startDate', argument='2020-01-01') -1107 >>> validate_date(parameter='startDate', argument='<=2020-01-01') -1108 -1109 >>> validate_date(parameter='stopDate', argument='=2020-01-01') -1110 -1111 >>> validate_date(parameter='deadline', argument='XYZ') -1112 Traceback (most recent call last): -1113 ... -1114 ValueError: Invalid deadline argument: 'XYZ' -1115 Please see the documentation for `deadline` in `things.tasks`. -1116 """ -1117 if argument is None: -1118 return -1119 -1120 if argument in list(DATES): -1121 return -1122 -1123 if not isinstance(argument, str): -1124 raise ValueError( -1125 f"Invalid {parameter} argument: {argument!r}\n" -1126 f"Please specify a string or None." -1127 ) +1102def validate_date(parameter, argument): +1103 """ +1104 For a given date parameter, check if its argument is valid. +1105 +1106 If not, then raise `ValueError`. +1107 +1108 Examples +1109 -------- +1110 >>> validate_date(parameter='startDate', argument=None) +1111 >>> validate_date(parameter='startDate', argument='future') +1112 >>> validate_date(parameter='startDate', argument='2020-01-01') +1113 >>> validate_date(parameter='startDate', argument='<=2020-01-01') +1114 +1115 >>> validate_date(parameter='stopDate', argument='=2020-01-01') +1116 +1117 >>> validate_date(parameter='deadline', argument='XYZ') +1118 Traceback (most recent call last): +1119 ... +1120 ValueError: Invalid deadline argument: 'XYZ' +1121 Please see the documentation for `deadline` in `things.tasks`. +1122 """ +1123 if argument is None: +1124 return +1125 +1126 if argument in list(DATES): +1127 return 1128 -1129 match = match_date(argument) -1130 if not match: -1131 raise ValueError( -1132 f"Invalid {parameter} argument: {argument!r}\n" -1133 f"Please see the documentation for `{parameter}` in `things.tasks`." -1134 ) -1135 -1136 _, isodate = match.groups() -1137 try: -1138 datetime.date.fromisoformat(isodate) -1139 except ValueError as error: -1140 raise ValueError( -1141 f"Invalid {parameter} argument: {argument!r}\n{error}" -1142 ) from error -1143 -1144 -1145def validate_offset(parameter, argument): -1146 """ -1147 For a given offset parameter, check if its argument is valid. -1148 -1149 If not, then raise `ValueError`. +1129 if not isinstance(argument, str): +1130 raise ValueError( +1131 f"Invalid {parameter} argument: {argument!r}\n" +1132 f"Please specify a string or None." +1133 ) +1134 +1135 match = match_date(argument) +1136 if not match: +1137 raise ValueError( +1138 f"Invalid {parameter} argument: {argument!r}\n" +1139 f"Please see the documentation for `{parameter}` in `things.tasks`." +1140 ) +1141 +1142 _, isodate = match.groups() +1143 try: +1144 datetime.date.fromisoformat(isodate) +1145 except ValueError as error: +1146 raise ValueError( +1147 f"Invalid {parameter} argument: {argument!r}\n{error}" +1148 ) from error +1149 1150 -1151 Examples -1152 -------- -1153 >>> validate_offset(parameter='last', argument='3d') +1151def validate_offset(parameter, argument): +1152 """ +1153 For a given offset parameter, check if its argument is valid. 1154 -1155 >>> validate_offset(parameter='last', argument='XYZ') -1156 Traceback (most recent call last): -1157 ... -1158 ValueError: Invalid last argument: 'XYZ' -1159 Please specify a string of the format 'X[d/w/y]' where X is ... -1160 """ -1161 if argument is None: -1162 return -1163 -1164 if not isinstance(argument, str): -1165 raise ValueError( -1166 f"Invalid {parameter} argument: {argument!r}\n" -1167 f"Please specify a string or None." -1168 ) +1155 If not, then raise `ValueError`. +1156 +1157 Examples +1158 -------- +1159 >>> validate_offset(parameter='last', argument='3d') +1160 +1161 >>> validate_offset(parameter='last', argument='XYZ') +1162 Traceback (most recent call last): +1163 ... +1164 ValueError: Invalid last argument: 'XYZ' +1165 Please specify a string of the format 'X[d/w/y]' where X is ... +1166 """ +1167 if argument is None: +1168 return 1169 -1170 suffix = argument[-1:] # slicing here to handle empty strings -1171 if suffix not in ("d", "w", "y"): -1172 raise ValueError( -1173 f"Invalid {parameter} argument: {argument!r}\n" -1174 f"Please specify a string of the format 'X[d/w/y]' " -1175 "where X is a non-negative integer followed by 'd', 'w', or 'y' " -1176 "that indicates days, weeks, or years." -1177 ) +1170 if not isinstance(argument, str): +1171 raise ValueError( +1172 f"Invalid {parameter} argument: {argument!r}\n" +1173 f"Please specify a string or None." +1174 ) +1175 +1176 suffix = argument[-1:] # slicing here to handle empty strings +1177 if suffix not in ("d", "w", "y"): +1178 raise ValueError( +1179 f"Invalid {parameter} argument: {argument!r}\n" +1180 f"Please specify a string of the format 'X[d/w/y]' " +1181 "where X is a non-negative integer followed by 'd', 'w', or 'y' " +1182 "that indicates days, weeks, or years." +1183 ) @@ -1347,362 +1353,362 @@

-
146class Database:
-147    """
-148    Access Things SQL database.
-149
-150    Parameters
-151    ----------
-152    filepath : str, optional
-153        Any valid path of a SQLite database file generated by the Things app.
-154        If the environment variable `THINGSDB` is set, then use that path.
-155        Otherwise, access the default database path.
-156
-157    print_sql : bool, default False
-158        Print every SQL query performed. Some may contain '?' and ':'
-159        characters which correspond to SQLite parameter tokens.
-160        See https://www.sqlite.org/lang_expr.html#varparam
+            
151class Database:
+152    """
+153    Access Things SQL database.
+154
+155    Parameters
+156    ----------
+157    filepath : str, optional
+158        Any valid path of a SQLite database file generated by the Things app.
+159        If the environment variable `THINGSDB` is set, then use that path.
+160        Otherwise, access the default database path.
 161
-162    :raises AssertionError: If the database version is too old.
-163    """
-164
-165    debug = False
+162    print_sql : bool, default False
+163        Print every SQL query performed. Some may contain '?' and ':'
+164        characters which correspond to SQLite parameter tokens.
+165        See https://www.sqlite.org/lang_expr.html#varparam
 166
-167    # pylint: disable=R0913
-168    def __init__(self, filepath=None, print_sql=False):
-169        """Set up the database."""
-170        self.filepath = (
-171            filepath
-172            or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH)
-173            or DEFAULT_FILEPATH
-174        )
-175        self.print_sql = print_sql
-176        if self.print_sql:
-177            self.execute_query_count = 0
-178
-179        # Test for migrated database in Things 3.15.16+
-180        # --------------------------------
-181        assert self.get_version() > 21, (
-182            "Your database is in an older format. "
-183            "Run 'pip install things.py==0.0.14' to downgrade to an older "
-184            "version of this library."
-185        )
-186
-187        # Automated migration to new database location in Things 3.12.6/3.13.1
-188        # --------------------------------
-189        try:
-190            with open(self.filepath, encoding="utf-8") as file:
-191                if "Your database file has been moved there" in file.readline():
-192                    self.filepath = DEFAULT_FILEPATH
-193        except (UnicodeDecodeError, FileNotFoundError, PermissionError):
-194            pass  # binary file (old database) or doesn't exist
-195        # --------------------------------
-196
-197    # Core methods
-198
-199    def get_tasks(  # pylint: disable=R0914
-200        self,
-201        uuid=None,
-202        type=None,  # pylint: disable=W0622
-203        status=None,
-204        start=None,
-205        area=None,
-206        project=None,
-207        heading=None,
-208        tag=None,
-209        start_date=None,
-210        stop_date=None,
-211        deadline=None,
-212        deadline_suppressed=None,
-213        trashed=False,
-214        context_trashed=False,
-215        last=None,
-216        search_query=None,
-217        index="index",
-218        count_only=False,
-219    ):
-220        """Get tasks. See `things.api.tasks` for details on parameters."""
-221        if uuid:
-222            return self.get_task_by_uuid(uuid, count_only=count_only)
-223
-224        # Overwrites
-225        start = start and start.title()
-226
-227        # Validation
-228        validate_date("deadline", deadline)
-229        validate("deadline_suppressed", deadline_suppressed, [None, True, False])
-230        validate("start", start, [None] + list(START_TO_FILTER))
-231        validate_date("start_date", start_date)
-232        validate_date("stop_date", stop_date)
-233        validate("status", status, [None] + list(STATUS_TO_FILTER))
-234        validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER))
-235        validate("type", type, [None] + list(TYPE_TO_FILTER))
-236        validate("context_trashed", context_trashed, [None, True, False])
-237        validate("index", index, list(INDICES))
-238        validate_offset("last", last)
-239
-240        if tag is not None:
-241            valid_tags = self.get_tags(titles_only=True)
-242            validate("tag", tag, [None] + list(valid_tags))
-243
-244        # Query
-245        # TK: might consider executing SQL with parameters instead.
-246        # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute
-247
-248        start_filter: str = START_TO_FILTER.get(start, "")  # type: ignore
-249        status_filter: str = STATUS_TO_FILTER.get(status, "")  # type: ignore
-250        trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "")  # type: ignore
-251        type_filter: str = TYPE_TO_FILTER.get(type, "")  # type: ignore
+167    :raises AssertionError: If the database version is too old.
+168    """
+169
+170    debug = False
+171
+172    # pylint: disable=R0913
+173    def __init__(self, filepath=None, print_sql=False):
+174        """Set up the database."""
+175        self.filepath = (
+176            filepath
+177            or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH)
+178            or DEFAULT_FILEPATH
+179        )
+180        self.print_sql = print_sql
+181        if self.print_sql:
+182            self.execute_query_count = 0
+183
+184        # Test for migrated database in Things 3.15.16+
+185        # --------------------------------
+186        assert self.get_version() > 21, (
+187            "Your database is in an older format. "
+188            "Run 'pip install things.py==0.0.14' to downgrade to an older "
+189            "version of this library."
+190        )
+191
+192        # Automated migration to new database location in Things 3.12.6/3.13.1
+193        # --------------------------------
+194        try:
+195            with open(self.filepath, encoding="utf-8") as file:
+196                if "Your database file has been moved there" in file.readline():
+197                    self.filepath = DEFAULT_FILEPATH
+198        except (UnicodeDecodeError, FileNotFoundError, PermissionError):
+199            pass  # binary file (old database) or doesn't exist
+200        # --------------------------------
+201
+202    # Core methods
+203
+204    def get_tasks(  # pylint: disable=R0914
+205        self,
+206        uuid: Optional[str] = None,
+207        type: Optional[str] = None,  # pylint: disable=W0622
+208        status: Optional[str] = None,
+209        start: Optional[str] = None,
+210        area: Optional[Union[str, bool]] = None,
+211        project: Optional[Union[str, bool]] = None,
+212        heading: Optional[str] = None,
+213        tag: Optional[Union[str, bool]] = None,
+214        start_date: Optional[Union[str, bool]] = None,
+215        stop_date: Optional[Union[str, bool]] = None,
+216        deadline: Optional[Union[str, bool]] = None,
+217        deadline_suppressed: Optional[bool] = None,
+218        trashed: Optional[bool] = False,
+219        context_trashed: Optional[bool] = False,
+220        last: Optional[str] = None,
+221        search_query: Optional[str] = None,
+222        index: str = "index",
+223        count_only: bool = False,
+224    ):
+225        """Get tasks. See `things.api.tasks` for details on parameters."""
+226        if uuid:
+227            return self.get_task_by_uuid(uuid, count_only=count_only)
+228
+229        # Overwrites
+230        start = start and start.title()
+231
+232        # Validation
+233        validate_date("deadline", deadline)
+234        validate("deadline_suppressed", deadline_suppressed, [None, True, False])
+235        validate("start", start, [None] + list(START_TO_FILTER))
+236        validate_date("start_date", start_date)
+237        validate_date("stop_date", stop_date)
+238        validate("status", status, [None] + list(STATUS_TO_FILTER))
+239        validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER))
+240        validate("type", type, [None] + list(TYPE_TO_FILTER))
+241        validate("context_trashed", context_trashed, [None, True, False])
+242        validate("index", index, list(INDICES))
+243        validate_offset("last", last)
+244
+245        if tag is not None:
+246            valid_tags = self.get_tags(titles_only=True)
+247            validate("tag", tag, [None] + list(valid_tags))
+248
+249        # Query
+250        # TK: might consider executing SQL with parameters instead.
+251        # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute
 252
-253        # Sometimes a task is _not_ set to trashed, but its context
-254        # (project or heading it is contained within) is set to trashed.
-255        # In those cases, the task wouldn't show up in any app view
-256        # except for "Trash".
-257        project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed)
-258        project_of_heading_trashed_filter = make_truthy_filter(
-259            "PROJECT_OF_HEADING.trashed", context_trashed
-260        )
-261
-262        # As a task assigned to a heading is not directly assigned to a project anymore,
-263        # we need to check if the heading is assigned to a project.
-264        # See, e.g. https://github.com/thingsapi/things.py/issues/94
-265        project_filter = make_or_filter(
-266            make_filter("TASK.project", project),
-267            make_filter("PROJECT_OF_HEADING.uuid", project),
-268        )
-269
-270        where_predicate = f"""
-271            TASK.{IS_NOT_RECURRING}
-272            {trashed_filter and f"AND TASK.{trashed_filter}"}
-273            {project_trashed_filter}
-274            {project_of_heading_trashed_filter}
-275            {type_filter and f"AND TASK.{type_filter}"}
-276            {start_filter and f"AND TASK.{start_filter}"}
-277            {status_filter and f"AND TASK.{status_filter}"}
-278            {make_filter('TASK.uuid', uuid)}
-279            {make_filter("TASK.area", area)}
-280            {project_filter}
-281            {make_filter("TASK.heading", heading)}
-282            {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)}
-283            {make_filter("TAG.title", tag)}
-284            {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)}
-285            {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)}
-286            {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)}
-287            {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)}
-288            {make_search_filter(search_query)}
-289            """
-290        order_predicate = f'TASK."{index}"'
-291
-292        sql_query = make_tasks_sql_query(where_predicate, order_predicate)
-293
-294        if count_only:
-295            return self.get_count(sql_query)
+253        start_filter: str = START_TO_FILTER.get(start, "")  # type: ignore
+254        status_filter: str = STATUS_TO_FILTER.get(status, "")  # type: ignore
+255        trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "")  # type: ignore
+256        type_filter: str = TYPE_TO_FILTER.get(type, "")  # type: ignore
+257
+258        # Sometimes a task is _not_ set to trashed, but its context
+259        # (project or heading it is contained within) is set to trashed.
+260        # In those cases, the task wouldn't show up in any app view
+261        # except for "Trash".
+262        project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed)
+263        project_of_heading_trashed_filter = make_truthy_filter(
+264            "PROJECT_OF_HEADING.trashed", context_trashed
+265        )
+266
+267        # As a task assigned to a heading is not directly assigned to a project anymore,
+268        # we need to check if the heading is assigned to a project.
+269        # See, e.g. https://github.com/thingsapi/things.py/issues/94
+270        project_filter = make_or_filter(
+271            make_filter("TASK.project", project),
+272            make_filter("PROJECT_OF_HEADING.uuid", project),
+273        )
+274
+275        where_predicate = f"""
+276            TASK.{IS_NOT_RECURRING}
+277            {trashed_filter and f"AND TASK.{trashed_filter}"}
+278            {project_trashed_filter}
+279            {project_of_heading_trashed_filter}
+280            {type_filter and f"AND TASK.{type_filter}"}
+281            {start_filter and f"AND TASK.{start_filter}"}
+282            {status_filter and f"AND TASK.{status_filter}"}
+283            {make_filter('TASK.uuid', uuid)}
+284            {make_filter("TASK.area", area)}
+285            {project_filter}
+286            {make_filter("TASK.heading", heading)}
+287            {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)}
+288            {make_filter("TAG.title", tag)}
+289            {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)}
+290            {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)}
+291            {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)}
+292            {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)}
+293            {make_search_filter(search_query)}
+294            """
+295        order_predicate = f'TASK."{index}"'
 296
-297        return self.execute_query(sql_query)
+297        sql_query = make_tasks_sql_query(where_predicate, order_predicate)
 298
-299    def get_task_by_uuid(self, uuid, count_only=False):
-300        """Get a task by uuid. Raise `ValueError` if not found."""
-301        where_predicate = "TASK.uuid = ?"
-302        sql_query = make_tasks_sql_query(where_predicate)
-303        parameters = (uuid,)
-304
-305        if count_only:
-306            return self.get_count(sql_query, parameters)
-307
-308        result = self.execute_query(sql_query, parameters)
-309        if not result:
-310            raise ValueError(f"No such task uuid found: {uuid!r}")
-311
-312        return result
-313
-314    def get_areas(self, uuid=None, tag=None, count_only=False):
-315        """Get areas. See `api.areas` for details on parameters."""
-316        # Validation
-317        if tag is not None:
-318            valid_tags = self.get_tags(titles_only=True)
-319            validate("tag", tag, [None] + list(valid_tags))
-320
-321        if (
-322            uuid
-323            and count_only is False
-324            and not self.get_areas(uuid=uuid, count_only=True)
-325        ):
-326            raise ValueError(f"No such area uuid found: {uuid!r}")
-327
-328        # Query
-329        sql_query = f"""
-330            SELECT DISTINCT
-331                AREA.uuid,
-332                'area' as type,
-333                AREA.title,
-334                CASE
-335                    WHEN AREA_TAG.areas IS NOT NULL THEN 1
-336                END AS tags
-337            FROM
-338                {TABLE_AREA} AS AREA
-339            LEFT OUTER JOIN
-340                {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid
-341            LEFT OUTER JOIN
-342                {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags
-343            WHERE
-344                TRUE
-345                {make_filter('TAG.title', tag)}
-346                {make_filter('AREA.uuid', uuid)}
-347            ORDER BY AREA."index"
-348            """
-349
-350        if count_only:
-351            return self.get_count(sql_query)
-352
-353        return self.execute_query(sql_query)
+299        if count_only:
+300            return self.get_count(sql_query)
+301
+302        return self.execute_query(sql_query)
+303
+304    def get_task_by_uuid(self, uuid, count_only=False):
+305        """Get a task by uuid. Raise `ValueError` if not found."""
+306        where_predicate = "TASK.uuid = ?"
+307        sql_query = make_tasks_sql_query(where_predicate)
+308        parameters = (uuid,)
+309
+310        if count_only:
+311            return self.get_count(sql_query, parameters)
+312
+313        result = self.execute_query(sql_query, parameters)
+314        if not result:
+315            raise ValueError(f"No such task uuid found: {uuid!r}")
+316
+317        return result
+318
+319    def get_areas(self, uuid=None, tag=None, count_only=False):
+320        """Get areas. See `api.areas` for details on parameters."""
+321        # Validation
+322        if tag is not None:
+323            valid_tags = self.get_tags(titles_only=True)
+324            validate("tag", tag, [None] + list(valid_tags))
+325
+326        if (
+327            uuid
+328            and count_only is False
+329            and not self.get_areas(uuid=uuid, count_only=True)
+330        ):
+331            raise ValueError(f"No such area uuid found: {uuid!r}")
+332
+333        # Query
+334        sql_query = f"""
+335            SELECT DISTINCT
+336                AREA.uuid,
+337                'area' as type,
+338                AREA.title,
+339                CASE
+340                    WHEN AREA_TAG.areas IS NOT NULL THEN 1
+341                END AS tags
+342            FROM
+343                {TABLE_AREA} AS AREA
+344            LEFT OUTER JOIN
+345                {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid
+346            LEFT OUTER JOIN
+347                {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags
+348            WHERE
+349                TRUE
+350                {make_filter('TAG.title', tag)}
+351                {make_filter('AREA.uuid', uuid)}
+352            ORDER BY AREA."index"
+353            """
 354
-355    def get_checklist_items(self, todo_uuid=None):
-356        """Get checklist items."""
-357        sql_query = f"""
-358            SELECT
-359                CHECKLIST_ITEM.title,
-360                CASE
-361                    WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete'
-362                    WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled'
-363                    WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed'
-364                END AS status,
-365                date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date,
-366                'checklist-item' as type,
-367                CHECKLIST_ITEM.uuid,
-368                datetime(
-369                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
-370                ) AS created,
-371                datetime(
-372                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
-373                ) AS modified
-374            FROM
-375                {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM
-376            WHERE
-377                CHECKLIST_ITEM.task = ?
-378            ORDER BY CHECKLIST_ITEM."index"
-379            """
-380        return self.execute_query(sql_query, (todo_uuid,))
-381
-382    def get_tags(self, title=None, area=None, task=None, titles_only=False):
-383        """Get tags. See `api.tags` for details on parameters."""
-384        # Validation
-385        if title is not None:
-386            valid_titles = self.get_tags(titles_only=True)
-387            validate("title", title, [None] + list(valid_titles))
-388
-389        # Query
-390        if task:
-391            return self.get_tags_of_task(task)
-392        if area:
-393            return self.get_tags_of_area(area)
-394
-395        if titles_only:
-396            sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"'
-397            return self.execute_query(sql_query, row_factory=list_factory)
-398
-399        sql_query = f"""
-400            SELECT
-401                uuid, 'tag' AS type, title, shortcut
-402            FROM
-403                {TABLE_TAG}
-404            WHERE
-405                TRUE
-406                {make_filter('title', title)}
-407            ORDER BY "index"
-408            """
-409
-410        return self.execute_query(sql_query)
-411
-412    def get_tags_of_task(self, task_uuid):
-413        """Get tag titles of task."""
-414        sql_query = f"""
-415            SELECT
-416                TAG.title
-417            FROM
-418                {TABLE_TASKTAG} AS TASK_TAG
-419            LEFT OUTER JOIN
-420                {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags
-421            WHERE
-422                TASK_TAG.tasks = ?
-423            ORDER BY TAG."index"
-424            """
-425        return self.execute_query(
-426            sql_query, parameters=(task_uuid,), row_factory=list_factory
-427        )
-428
-429    def get_tags_of_area(self, area_uuid):
-430        """Get tag titles for area."""
-431        sql_query = f"""
-432            SELECT
-433                AREA.title
-434            FROM
-435                {TABLE_AREATAG} AS AREA_TAG
-436            LEFT OUTER JOIN
-437                {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags
-438            WHERE
-439                AREA_TAG.areas = ?
-440            ORDER BY AREA."index"
-441            """
-442        return self.execute_query(
-443            sql_query, parameters=(area_uuid,), row_factory=list_factory
-444        )
-445
-446    def get_version(self):
-447        """Get Things Database version."""
-448        sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'"
-449        result = self.execute_query(sql_query, row_factory=list_factory)
-450        plist_bytes = result[0].encode()
-451        return plistlib.loads(plist_bytes)
-452
-453    # pylint: disable=R1710
-454    def get_url_scheme_auth_token(self):
-455        """Get the Things URL scheme authentication token."""
-456        sql_query = f"""
-457            SELECT
-458                uriSchemeAuthenticationToken
-459            FROM
-460                {TABLE_SETTINGS}
-461            WHERE
-462                uuid = 'RhAzEf6qDxCD5PmnZVtBZR'
-463            """
-464        rows = self.execute_query(sql_query, row_factory=list_factory)
-465        return rows[0]
-466
-467    def get_count(self, sql_query, parameters=()):
-468        """Count number of results."""
-469        count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)"""
-470        rows = self.execute_query(
-471            count_sql_query, row_factory=list_factory, parameters=parameters
-472        )
-473        return rows[0]
-474
-475    # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?)
-476    def execute_query(self, sql_query, parameters=(), row_factory=None):
-477        """Run the actual SQL query."""
-478        if self.print_sql or self.debug:
-479            if not hasattr(self, "execute_query_count"):
-480                # This is needed for historical `self.debug`.
-481                # TK: might consider removing `debug` flag.
-482                self.execute_query_count = 0
-483            self.execute_query_count += 1
-484            if self.debug:
-485                print(f"/* Filepath {self.filepath!r} */")
-486            print(f"/* Query {self.execute_query_count} */")
-487            if parameters:
-488                print(f"/* Parameters: {parameters!r} */")
-489            print()
-490            print(prettify_sql(sql_query))
-491            print()
-492
-493        # "ro" means read-only
-494        # See: https://sqlite.org/uri.html#recognized_query_parameters
-495        uri = f"file:{self.filepath}?mode=ro"
-496        connection = sqlite3.connect(uri, uri=True)  # pylint: disable=E1101
-497        connection.row_factory = row_factory or dict_factory
-498        cursor = connection.cursor()
-499        cursor.execute(sql_query, parameters)
-500
-501        return cursor.fetchall()
+355        if count_only:
+356            return self.get_count(sql_query)
+357
+358        return self.execute_query(sql_query)
+359
+360    def get_checklist_items(self, todo_uuid=None):
+361        """Get checklist items."""
+362        sql_query = f"""
+363            SELECT
+364                CHECKLIST_ITEM.title,
+365                CASE
+366                    WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete'
+367                    WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled'
+368                    WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed'
+369                END AS status,
+370                date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date,
+371                'checklist-item' as type,
+372                CHECKLIST_ITEM.uuid,
+373                datetime(
+374                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
+375                ) AS created,
+376                datetime(
+377                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
+378                ) AS modified
+379            FROM
+380                {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM
+381            WHERE
+382                CHECKLIST_ITEM.task = ?
+383            ORDER BY CHECKLIST_ITEM."index"
+384            """
+385        return self.execute_query(sql_query, (todo_uuid,))
+386
+387    def get_tags(self, title=None, area=None, task=None, titles_only=False):
+388        """Get tags. See `api.tags` for details on parameters."""
+389        # Validation
+390        if title is not None:
+391            valid_titles = self.get_tags(titles_only=True)
+392            validate("title", title, [None] + list(valid_titles))
+393
+394        # Query
+395        if task:
+396            return self.get_tags_of_task(task)
+397        if area:
+398            return self.get_tags_of_area(area)
+399
+400        if titles_only:
+401            sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"'
+402            return self.execute_query(sql_query, row_factory=list_factory)
+403
+404        sql_query = f"""
+405            SELECT
+406                uuid, 'tag' AS type, title, shortcut
+407            FROM
+408                {TABLE_TAG}
+409            WHERE
+410                TRUE
+411                {make_filter('title', title)}
+412            ORDER BY "index"
+413            """
+414
+415        return self.execute_query(sql_query)
+416
+417    def get_tags_of_task(self, task_uuid):
+418        """Get tag titles of task."""
+419        sql_query = f"""
+420            SELECT
+421                TAG.title
+422            FROM
+423                {TABLE_TASKTAG} AS TASK_TAG
+424            LEFT OUTER JOIN
+425                {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags
+426            WHERE
+427                TASK_TAG.tasks = ?
+428            ORDER BY TAG."index"
+429            """
+430        return self.execute_query(
+431            sql_query, parameters=(task_uuid,), row_factory=list_factory
+432        )
+433
+434    def get_tags_of_area(self, area_uuid):
+435        """Get tag titles for area."""
+436        sql_query = f"""
+437            SELECT
+438                AREA.title
+439            FROM
+440                {TABLE_AREATAG} AS AREA_TAG
+441            LEFT OUTER JOIN
+442                {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags
+443            WHERE
+444                AREA_TAG.areas = ?
+445            ORDER BY AREA."index"
+446            """
+447        return self.execute_query(
+448            sql_query, parameters=(area_uuid,), row_factory=list_factory
+449        )
+450
+451    def get_version(self):
+452        """Get Things Database version."""
+453        sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'"
+454        result = self.execute_query(sql_query, row_factory=list_factory)
+455        plist_bytes = result[0].encode()
+456        return plistlib.loads(plist_bytes)
+457
+458    # pylint: disable=R1710
+459    def get_url_scheme_auth_token(self):
+460        """Get the Things URL scheme authentication token."""
+461        sql_query = f"""
+462            SELECT
+463                uriSchemeAuthenticationToken
+464            FROM
+465                {TABLE_SETTINGS}
+466            WHERE
+467                uuid = 'RhAzEf6qDxCD5PmnZVtBZR'
+468            """
+469        rows = self.execute_query(sql_query, row_factory=list_factory)
+470        return rows[0]
+471
+472    def get_count(self, sql_query, parameters=()):
+473        """Count number of results."""
+474        count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)"""
+475        rows = self.execute_query(
+476            count_sql_query, row_factory=list_factory, parameters=parameters
+477        )
+478        return rows[0]
+479
+480    # noqa todo: add type hinting for resutl (List[Tuple[str, Any]]?)
+481    def execute_query(self, sql_query, parameters=(), row_factory=None):
+482        """Run the actual SQL query."""
+483        if self.print_sql or self.debug:
+484            if not hasattr(self, "execute_query_count"):
+485                # This is needed for historical `self.debug`.
+486                # TK: might consider removing `debug` flag.
+487                self.execute_query_count = 0
+488            self.execute_query_count += 1
+489            if self.debug:
+490                print(f"/* Filepath {self.filepath!r} */")
+491            print(f"/* Query {self.execute_query_count} */")
+492            if parameters:
+493                print(f"/* Parameters: {parameters!r} */")
+494            print()
+495            print(prettify_sql(sql_query))
+496            print()
+497
+498        # "ro" means read-only
+499        # See: https://sqlite.org/uri.html#recognized_query_parameters
+500        uri = f"file:{self.filepath}?mode=ro"
+501        connection = sqlite3.connect(uri, uri=True)  # pylint: disable=E1101
+502        connection.row_factory = row_factory or dict_factory
+503        cursor = connection.cursor()
+504        cursor.execute(sql_query, parameters)
+505
+506        return cursor.fetchall()
 
@@ -1734,34 +1740,34 @@
Parameters
-
168    def __init__(self, filepath=None, print_sql=False):
-169        """Set up the database."""
-170        self.filepath = (
-171            filepath
-172            or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH)
-173            or DEFAULT_FILEPATH
-174        )
-175        self.print_sql = print_sql
-176        if self.print_sql:
-177            self.execute_query_count = 0
-178
-179        # Test for migrated database in Things 3.15.16+
-180        # --------------------------------
-181        assert self.get_version() > 21, (
-182            "Your database is in an older format. "
-183            "Run 'pip install things.py==0.0.14' to downgrade to an older "
-184            "version of this library."
-185        )
-186
-187        # Automated migration to new database location in Things 3.12.6/3.13.1
-188        # --------------------------------
-189        try:
-190            with open(self.filepath, encoding="utf-8") as file:
-191                if "Your database file has been moved there" in file.readline():
-192                    self.filepath = DEFAULT_FILEPATH
-193        except (UnicodeDecodeError, FileNotFoundError, PermissionError):
-194            pass  # binary file (old database) or doesn't exist
-195        # --------------------------------
+            
173    def __init__(self, filepath=None, print_sql=False):
+174        """Set up the database."""
+175        self.filepath = (
+176            filepath
+177            or os.getenv(ENVIRONMENT_VARIABLE_WITH_FILEPATH)
+178            or DEFAULT_FILEPATH
+179        )
+180        self.print_sql = print_sql
+181        if self.print_sql:
+182            self.execute_query_count = 0
+183
+184        # Test for migrated database in Things 3.15.16+
+185        # --------------------------------
+186        assert self.get_version() > 21, (
+187            "Your database is in an older format. "
+188            "Run 'pip install things.py==0.0.14' to downgrade to an older "
+189            "version of this library."
+190        )
+191
+192        # Automated migration to new database location in Things 3.12.6/3.13.1
+193        # --------------------------------
+194        try:
+195            with open(self.filepath, encoding="utf-8") as file:
+196                if "Your database file has been moved there" in file.readline():
+197                    self.filepath = DEFAULT_FILEPATH
+198        except (UnicodeDecodeError, FileNotFoundError, PermissionError):
+199            pass  # binary file (old database) or doesn't exist
+200        # --------------------------------
 
@@ -1775,111 +1781,111 @@
Parameters
def - get_tasks( self, uuid=None, type=None, status=None, start=None, area=None, project=None, heading=None, tag=None, start_date=None, stop_date=None, deadline=None, deadline_suppressed=None, trashed=False, context_trashed=False, last=None, search_query=None, index='index', count_only=False): + get_tasks( self, uuid: Optional[str] = None, type: Optional[str] = None, status: Optional[str] = None, start: Optional[str] = None, area: Union[str, bool, NoneType] = None, project: Union[str, bool, NoneType] = None, heading: Optional[str] = None, tag: Union[str, bool, NoneType] = None, start_date: Union[str, bool, NoneType] = None, stop_date: Union[str, bool, NoneType] = None, deadline: Union[str, bool, NoneType] = None, deadline_suppressed: Optional[bool] = None, trashed: Optional[bool] = False, context_trashed: Optional[bool] = False, last: Optional[str] = None, search_query: Optional[str] = None, index: str = 'index', count_only: bool = False):
-
199    def get_tasks(  # pylint: disable=R0914
-200        self,
-201        uuid=None,
-202        type=None,  # pylint: disable=W0622
-203        status=None,
-204        start=None,
-205        area=None,
-206        project=None,
-207        heading=None,
-208        tag=None,
-209        start_date=None,
-210        stop_date=None,
-211        deadline=None,
-212        deadline_suppressed=None,
-213        trashed=False,
-214        context_trashed=False,
-215        last=None,
-216        search_query=None,
-217        index="index",
-218        count_only=False,
-219    ):
-220        """Get tasks. See `things.api.tasks` for details on parameters."""
-221        if uuid:
-222            return self.get_task_by_uuid(uuid, count_only=count_only)
-223
-224        # Overwrites
-225        start = start and start.title()
-226
-227        # Validation
-228        validate_date("deadline", deadline)
-229        validate("deadline_suppressed", deadline_suppressed, [None, True, False])
-230        validate("start", start, [None] + list(START_TO_FILTER))
-231        validate_date("start_date", start_date)
-232        validate_date("stop_date", stop_date)
-233        validate("status", status, [None] + list(STATUS_TO_FILTER))
-234        validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER))
-235        validate("type", type, [None] + list(TYPE_TO_FILTER))
-236        validate("context_trashed", context_trashed, [None, True, False])
-237        validate("index", index, list(INDICES))
-238        validate_offset("last", last)
-239
-240        if tag is not None:
-241            valid_tags = self.get_tags(titles_only=True)
-242            validate("tag", tag, [None] + list(valid_tags))
-243
-244        # Query
-245        # TK: might consider executing SQL with parameters instead.
-246        # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute
-247
-248        start_filter: str = START_TO_FILTER.get(start, "")  # type: ignore
-249        status_filter: str = STATUS_TO_FILTER.get(status, "")  # type: ignore
-250        trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "")  # type: ignore
-251        type_filter: str = TYPE_TO_FILTER.get(type, "")  # type: ignore
+            
204    def get_tasks(  # pylint: disable=R0914
+205        self,
+206        uuid: Optional[str] = None,
+207        type: Optional[str] = None,  # pylint: disable=W0622
+208        status: Optional[str] = None,
+209        start: Optional[str] = None,
+210        area: Optional[Union[str, bool]] = None,
+211        project: Optional[Union[str, bool]] = None,
+212        heading: Optional[str] = None,
+213        tag: Optional[Union[str, bool]] = None,
+214        start_date: Optional[Union[str, bool]] = None,
+215        stop_date: Optional[Union[str, bool]] = None,
+216        deadline: Optional[Union[str, bool]] = None,
+217        deadline_suppressed: Optional[bool] = None,
+218        trashed: Optional[bool] = False,
+219        context_trashed: Optional[bool] = False,
+220        last: Optional[str] = None,
+221        search_query: Optional[str] = None,
+222        index: str = "index",
+223        count_only: bool = False,
+224    ):
+225        """Get tasks. See `things.api.tasks` for details on parameters."""
+226        if uuid:
+227            return self.get_task_by_uuid(uuid, count_only=count_only)
+228
+229        # Overwrites
+230        start = start and start.title()
+231
+232        # Validation
+233        validate_date("deadline", deadline)
+234        validate("deadline_suppressed", deadline_suppressed, [None, True, False])
+235        validate("start", start, [None] + list(START_TO_FILTER))
+236        validate_date("start_date", start_date)
+237        validate_date("stop_date", stop_date)
+238        validate("status", status, [None] + list(STATUS_TO_FILTER))
+239        validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER))
+240        validate("type", type, [None] + list(TYPE_TO_FILTER))
+241        validate("context_trashed", context_trashed, [None, True, False])
+242        validate("index", index, list(INDICES))
+243        validate_offset("last", last)
+244
+245        if tag is not None:
+246            valid_tags = self.get_tags(titles_only=True)
+247            validate("tag", tag, [None] + list(valid_tags))
+248
+249        # Query
+250        # TK: might consider executing SQL with parameters instead.
+251        # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute
 252
-253        # Sometimes a task is _not_ set to trashed, but its context
-254        # (project or heading it is contained within) is set to trashed.
-255        # In those cases, the task wouldn't show up in any app view
-256        # except for "Trash".
-257        project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed)
-258        project_of_heading_trashed_filter = make_truthy_filter(
-259            "PROJECT_OF_HEADING.trashed", context_trashed
-260        )
-261
-262        # As a task assigned to a heading is not directly assigned to a project anymore,
-263        # we need to check if the heading is assigned to a project.
-264        # See, e.g. https://github.com/thingsapi/things.py/issues/94
-265        project_filter = make_or_filter(
-266            make_filter("TASK.project", project),
-267            make_filter("PROJECT_OF_HEADING.uuid", project),
-268        )
-269
-270        where_predicate = f"""
-271            TASK.{IS_NOT_RECURRING}
-272            {trashed_filter and f"AND TASK.{trashed_filter}"}
-273            {project_trashed_filter}
-274            {project_of_heading_trashed_filter}
-275            {type_filter and f"AND TASK.{type_filter}"}
-276            {start_filter and f"AND TASK.{start_filter}"}
-277            {status_filter and f"AND TASK.{status_filter}"}
-278            {make_filter('TASK.uuid', uuid)}
-279            {make_filter("TASK.area", area)}
-280            {project_filter}
-281            {make_filter("TASK.heading", heading)}
-282            {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)}
-283            {make_filter("TAG.title", tag)}
-284            {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)}
-285            {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)}
-286            {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)}
-287            {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)}
-288            {make_search_filter(search_query)}
-289            """
-290        order_predicate = f'TASK."{index}"'
-291
-292        sql_query = make_tasks_sql_query(where_predicate, order_predicate)
-293
-294        if count_only:
-295            return self.get_count(sql_query)
+253        start_filter: str = START_TO_FILTER.get(start, "")  # type: ignore
+254        status_filter: str = STATUS_TO_FILTER.get(status, "")  # type: ignore
+255        trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "")  # type: ignore
+256        type_filter: str = TYPE_TO_FILTER.get(type, "")  # type: ignore
+257
+258        # Sometimes a task is _not_ set to trashed, but its context
+259        # (project or heading it is contained within) is set to trashed.
+260        # In those cases, the task wouldn't show up in any app view
+261        # except for "Trash".
+262        project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed)
+263        project_of_heading_trashed_filter = make_truthy_filter(
+264            "PROJECT_OF_HEADING.trashed", context_trashed
+265        )
+266
+267        # As a task assigned to a heading is not directly assigned to a project anymore,
+268        # we need to check if the heading is assigned to a project.
+269        # See, e.g. https://github.com/thingsapi/things.py/issues/94
+270        project_filter = make_or_filter(
+271            make_filter("TASK.project", project),
+272            make_filter("PROJECT_OF_HEADING.uuid", project),
+273        )
+274
+275        where_predicate = f"""
+276            TASK.{IS_NOT_RECURRING}
+277            {trashed_filter and f"AND TASK.{trashed_filter}"}
+278            {project_trashed_filter}
+279            {project_of_heading_trashed_filter}
+280            {type_filter and f"AND TASK.{type_filter}"}
+281            {start_filter and f"AND TASK.{start_filter}"}
+282            {status_filter and f"AND TASK.{status_filter}"}
+283            {make_filter('TASK.uuid', uuid)}
+284            {make_filter("TASK.area", area)}
+285            {project_filter}
+286            {make_filter("TASK.heading", heading)}
+287            {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)}
+288            {make_filter("TAG.title", tag)}
+289            {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)}
+290            {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)}
+291            {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)}
+292            {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)}
+293            {make_search_filter(search_query)}
+294            """
+295        order_predicate = f'TASK."{index}"'
 296
-297        return self.execute_query(sql_query)
+297        sql_query = make_tasks_sql_query(where_predicate, order_predicate)
+298
+299        if count_only:
+300            return self.get_count(sql_query)
+301
+302        return self.execute_query(sql_query)
 
@@ -1899,20 +1905,20 @@
Parameters
-
299    def get_task_by_uuid(self, uuid, count_only=False):
-300        """Get a task by uuid. Raise `ValueError` if not found."""
-301        where_predicate = "TASK.uuid = ?"
-302        sql_query = make_tasks_sql_query(where_predicate)
-303        parameters = (uuid,)
-304
-305        if count_only:
-306            return self.get_count(sql_query, parameters)
-307
-308        result = self.execute_query(sql_query, parameters)
-309        if not result:
-310            raise ValueError(f"No such task uuid found: {uuid!r}")
-311
-312        return result
+            
304    def get_task_by_uuid(self, uuid, count_only=False):
+305        """Get a task by uuid. Raise `ValueError` if not found."""
+306        where_predicate = "TASK.uuid = ?"
+307        sql_query = make_tasks_sql_query(where_predicate)
+308        parameters = (uuid,)
+309
+310        if count_only:
+311            return self.get_count(sql_query, parameters)
+312
+313        result = self.execute_query(sql_query, parameters)
+314        if not result:
+315            raise ValueError(f"No such task uuid found: {uuid!r}")
+316
+317        return result
 
@@ -1932,46 +1938,46 @@
Parameters
-
314    def get_areas(self, uuid=None, tag=None, count_only=False):
-315        """Get areas. See `api.areas` for details on parameters."""
-316        # Validation
-317        if tag is not None:
-318            valid_tags = self.get_tags(titles_only=True)
-319            validate("tag", tag, [None] + list(valid_tags))
-320
-321        if (
-322            uuid
-323            and count_only is False
-324            and not self.get_areas(uuid=uuid, count_only=True)
-325        ):
-326            raise ValueError(f"No such area uuid found: {uuid!r}")
-327
-328        # Query
-329        sql_query = f"""
-330            SELECT DISTINCT
-331                AREA.uuid,
-332                'area' as type,
-333                AREA.title,
-334                CASE
-335                    WHEN AREA_TAG.areas IS NOT NULL THEN 1
-336                END AS tags
-337            FROM
-338                {TABLE_AREA} AS AREA
-339            LEFT OUTER JOIN
-340                {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid
-341            LEFT OUTER JOIN
-342                {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags
-343            WHERE
-344                TRUE
-345                {make_filter('TAG.title', tag)}
-346                {make_filter('AREA.uuid', uuid)}
-347            ORDER BY AREA."index"
-348            """
-349
-350        if count_only:
-351            return self.get_count(sql_query)
-352
-353        return self.execute_query(sql_query)
+            
319    def get_areas(self, uuid=None, tag=None, count_only=False):
+320        """Get areas. See `api.areas` for details on parameters."""
+321        # Validation
+322        if tag is not None:
+323            valid_tags = self.get_tags(titles_only=True)
+324            validate("tag", tag, [None] + list(valid_tags))
+325
+326        if (
+327            uuid
+328            and count_only is False
+329            and not self.get_areas(uuid=uuid, count_only=True)
+330        ):
+331            raise ValueError(f"No such area uuid found: {uuid!r}")
+332
+333        # Query
+334        sql_query = f"""
+335            SELECT DISTINCT
+336                AREA.uuid,
+337                'area' as type,
+338                AREA.title,
+339                CASE
+340                    WHEN AREA_TAG.areas IS NOT NULL THEN 1
+341                END AS tags
+342            FROM
+343                {TABLE_AREA} AS AREA
+344            LEFT OUTER JOIN
+345                {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid
+346            LEFT OUTER JOIN
+347                {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags
+348            WHERE
+349                TRUE
+350                {make_filter('TAG.title', tag)}
+351                {make_filter('AREA.uuid', uuid)}
+352            ORDER BY AREA."index"
+353            """
+354
+355        if count_only:
+356            return self.get_count(sql_query)
+357
+358        return self.execute_query(sql_query)
 
@@ -1991,32 +1997,32 @@
Parameters
-
355    def get_checklist_items(self, todo_uuid=None):
-356        """Get checklist items."""
-357        sql_query = f"""
-358            SELECT
-359                CHECKLIST_ITEM.title,
-360                CASE
-361                    WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete'
-362                    WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled'
-363                    WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed'
-364                END AS status,
-365                date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date,
-366                'checklist-item' as type,
-367                CHECKLIST_ITEM.uuid,
-368                datetime(
-369                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
-370                ) AS created,
-371                datetime(
-372                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
-373                ) AS modified
-374            FROM
-375                {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM
-376            WHERE
-377                CHECKLIST_ITEM.task = ?
-378            ORDER BY CHECKLIST_ITEM."index"
-379            """
-380        return self.execute_query(sql_query, (todo_uuid,))
+            
360    def get_checklist_items(self, todo_uuid=None):
+361        """Get checklist items."""
+362        sql_query = f"""
+363            SELECT
+364                CHECKLIST_ITEM.title,
+365                CASE
+366                    WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete'
+367                    WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled'
+368                    WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed'
+369                END AS status,
+370                date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date,
+371                'checklist-item' as type,
+372                CHECKLIST_ITEM.uuid,
+373                datetime(
+374                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
+375                ) AS created,
+376                datetime(
+377                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
+378                ) AS modified
+379            FROM
+380                {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM
+381            WHERE
+382                CHECKLIST_ITEM.task = ?
+383            ORDER BY CHECKLIST_ITEM."index"
+384            """
+385        return self.execute_query(sql_query, (todo_uuid,))
 
@@ -2036,35 +2042,35 @@
Parameters
-
382    def get_tags(self, title=None, area=None, task=None, titles_only=False):
-383        """Get tags. See `api.tags` for details on parameters."""
-384        # Validation
-385        if title is not None:
-386            valid_titles = self.get_tags(titles_only=True)
-387            validate("title", title, [None] + list(valid_titles))
-388
-389        # Query
-390        if task:
-391            return self.get_tags_of_task(task)
-392        if area:
-393            return self.get_tags_of_area(area)
-394
-395        if titles_only:
-396            sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"'
-397            return self.execute_query(sql_query, row_factory=list_factory)
-398
-399        sql_query = f"""
-400            SELECT
-401                uuid, 'tag' AS type, title, shortcut
-402            FROM
-403                {TABLE_TAG}
-404            WHERE
-405                TRUE
-406                {make_filter('title', title)}
-407            ORDER BY "index"
-408            """
-409
-410        return self.execute_query(sql_query)
+            
387    def get_tags(self, title=None, area=None, task=None, titles_only=False):
+388        """Get tags. See `api.tags` for details on parameters."""
+389        # Validation
+390        if title is not None:
+391            valid_titles = self.get_tags(titles_only=True)
+392            validate("title", title, [None] + list(valid_titles))
+393
+394        # Query
+395        if task:
+396            return self.get_tags_of_task(task)
+397        if area:
+398            return self.get_tags_of_area(area)
+399
+400        if titles_only:
+401            sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"'
+402            return self.execute_query(sql_query, row_factory=list_factory)
+403
+404        sql_query = f"""
+405            SELECT
+406                uuid, 'tag' AS type, title, shortcut
+407            FROM
+408                {TABLE_TAG}
+409            WHERE
+410                TRUE
+411                {make_filter('title', title)}
+412            ORDER BY "index"
+413            """
+414
+415        return self.execute_query(sql_query)
 
@@ -2084,22 +2090,22 @@
Parameters
-
412    def get_tags_of_task(self, task_uuid):
-413        """Get tag titles of task."""
-414        sql_query = f"""
-415            SELECT
-416                TAG.title
-417            FROM
-418                {TABLE_TASKTAG} AS TASK_TAG
-419            LEFT OUTER JOIN
-420                {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags
-421            WHERE
-422                TASK_TAG.tasks = ?
-423            ORDER BY TAG."index"
-424            """
-425        return self.execute_query(
-426            sql_query, parameters=(task_uuid,), row_factory=list_factory
-427        )
+            
417    def get_tags_of_task(self, task_uuid):
+418        """Get tag titles of task."""
+419        sql_query = f"""
+420            SELECT
+421                TAG.title
+422            FROM
+423                {TABLE_TASKTAG} AS TASK_TAG
+424            LEFT OUTER JOIN
+425                {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags
+426            WHERE
+427                TASK_TAG.tasks = ?
+428            ORDER BY TAG."index"
+429            """
+430        return self.execute_query(
+431            sql_query, parameters=(task_uuid,), row_factory=list_factory
+432        )
 
@@ -2119,22 +2125,22 @@
Parameters
-
429    def get_tags_of_area(self, area_uuid):
-430        """Get tag titles for area."""
-431        sql_query = f"""
-432            SELECT
-433                AREA.title
-434            FROM
-435                {TABLE_AREATAG} AS AREA_TAG
-436            LEFT OUTER JOIN
-437                {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags
-438            WHERE
-439                AREA_TAG.areas = ?
-440            ORDER BY AREA."index"
-441            """
-442        return self.execute_query(
-443            sql_query, parameters=(area_uuid,), row_factory=list_factory
-444        )
+            
434    def get_tags_of_area(self, area_uuid):
+435        """Get tag titles for area."""
+436        sql_query = f"""
+437            SELECT
+438                AREA.title
+439            FROM
+440                {TABLE_AREATAG} AS AREA_TAG
+441            LEFT OUTER JOIN
+442                {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags
+443            WHERE
+444                AREA_TAG.areas = ?
+445            ORDER BY AREA."index"
+446            """
+447        return self.execute_query(
+448            sql_query, parameters=(area_uuid,), row_factory=list_factory
+449        )
 
@@ -2154,12 +2160,12 @@
Parameters
-
446    def get_version(self):
-447        """Get Things Database version."""
-448        sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'"
-449        result = self.execute_query(sql_query, row_factory=list_factory)
-450        plist_bytes = result[0].encode()
-451        return plistlib.loads(plist_bytes)
+            
451    def get_version(self):
+452        """Get Things Database version."""
+453        sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'"
+454        result = self.execute_query(sql_query, row_factory=list_factory)
+455        plist_bytes = result[0].encode()
+456        return plistlib.loads(plist_bytes)
 
@@ -2179,18 +2185,18 @@
Parameters
-
454    def get_url_scheme_auth_token(self):
-455        """Get the Things URL scheme authentication token."""
-456        sql_query = f"""
-457            SELECT
-458                uriSchemeAuthenticationToken
-459            FROM
-460                {TABLE_SETTINGS}
-461            WHERE
-462                uuid = 'RhAzEf6qDxCD5PmnZVtBZR'
-463            """
-464        rows = self.execute_query(sql_query, row_factory=list_factory)
-465        return rows[0]
+            
459    def get_url_scheme_auth_token(self):
+460        """Get the Things URL scheme authentication token."""
+461        sql_query = f"""
+462            SELECT
+463                uriSchemeAuthenticationToken
+464            FROM
+465                {TABLE_SETTINGS}
+466            WHERE
+467                uuid = 'RhAzEf6qDxCD5PmnZVtBZR'
+468            """
+469        rows = self.execute_query(sql_query, row_factory=list_factory)
+470        return rows[0]
 
@@ -2210,13 +2216,13 @@
Parameters
-
467    def get_count(self, sql_query, parameters=()):
-468        """Count number of results."""
-469        count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)"""
-470        rows = self.execute_query(
-471            count_sql_query, row_factory=list_factory, parameters=parameters
-472        )
-473        return rows[0]
+            
472    def get_count(self, sql_query, parameters=()):
+473        """Count number of results."""
+474        count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)"""
+475        rows = self.execute_query(
+476            count_sql_query, row_factory=list_factory, parameters=parameters
+477        )
+478        return rows[0]
 
@@ -2236,32 +2242,32 @@
Parameters
-
476    def execute_query(self, sql_query, parameters=(), row_factory=None):
-477        """Run the actual SQL query."""
-478        if self.print_sql or self.debug:
-479            if not hasattr(self, "execute_query_count"):
-480                # This is needed for historical `self.debug`.
-481                # TK: might consider removing `debug` flag.
-482                self.execute_query_count = 0
-483            self.execute_query_count += 1
-484            if self.debug:
-485                print(f"/* Filepath {self.filepath!r} */")
-486            print(f"/* Query {self.execute_query_count} */")
-487            if parameters:
-488                print(f"/* Parameters: {parameters!r} */")
-489            print()
-490            print(prettify_sql(sql_query))
-491            print()
-492
-493        # "ro" means read-only
-494        # See: https://sqlite.org/uri.html#recognized_query_parameters
-495        uri = f"file:{self.filepath}?mode=ro"
-496        connection = sqlite3.connect(uri, uri=True)  # pylint: disable=E1101
-497        connection.row_factory = row_factory or dict_factory
-498        cursor = connection.cursor()
-499        cursor.execute(sql_query, parameters)
-500
-501        return cursor.fetchall()
+            
481    def execute_query(self, sql_query, parameters=(), row_factory=None):
+482        """Run the actual SQL query."""
+483        if self.print_sql or self.debug:
+484            if not hasattr(self, "execute_query_count"):
+485                # This is needed for historical `self.debug`.
+486                # TK: might consider removing `debug` flag.
+487                self.execute_query_count = 0
+488            self.execute_query_count += 1
+489            if self.debug:
+490                print(f"/* Filepath {self.filepath!r} */")
+491            print(f"/* Query {self.execute_query_count} */")
+492            if parameters:
+493                print(f"/* Parameters: {parameters!r} */")
+494            print()
+495            print(prettify_sql(sql_query))
+496            print()
+497
+498        # "ro" means read-only
+499        # See: https://sqlite.org/uri.html#recognized_query_parameters
+500        uri = f"file:{self.filepath}?mode=ro"
+501        connection = sqlite3.connect(uri, uri=True)  # pylint: disable=E1101
+502        connection.row_factory = row_factory or dict_factory
+503        cursor = connection.cursor()
+504        cursor.execute(sql_query, parameters)
+505
+506        return cursor.fetchall()
 
@@ -2282,95 +2288,95 @@
Parameters
-
507def make_tasks_sql_query(where_predicate=None, order_predicate=None):
-508    """Make SQL query for Task table."""
-509    where_predicate = where_predicate or "TRUE"
-510    order_predicate = order_predicate or 'TASK."index"'
-511
-512    start_date_expression = convert_thingsdate_sql_expression_to_isodate(
-513        f"TASK.{DATE_START}"
-514    )
-515    deadline_expression = convert_thingsdate_sql_expression_to_isodate(
-516        f"TASK.{DATE_DEADLINE}"
-517    )
-518
-519    return f"""
-520            SELECT DISTINCT
-521                TASK.uuid,
-522                CASE
-523                    WHEN TASK.{IS_TODO} THEN 'to-do'
-524                    WHEN TASK.{IS_PROJECT} THEN 'project'
-525                    WHEN TASK.{IS_HEADING} THEN 'heading'
-526                END AS type,
+            
512def make_tasks_sql_query(where_predicate=None, order_predicate=None):
+513    """Make SQL query for Task table."""
+514    where_predicate = where_predicate or "TRUE"
+515    order_predicate = order_predicate or 'TASK."index"'
+516
+517    start_date_expression = convert_thingsdate_sql_expression_to_isodate(
+518        f"TASK.{DATE_START}"
+519    )
+520    deadline_expression = convert_thingsdate_sql_expression_to_isodate(
+521        f"TASK.{DATE_DEADLINE}"
+522    )
+523
+524    return f"""
+525            SELECT DISTINCT
+526                TASK.uuid,
 527                CASE
-528                    WHEN TASK.{IS_TRASHED} THEN 1
-529                END AS trashed,
-530                TASK.title,
-531                CASE
-532                    WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete'
-533                    WHEN TASK.{IS_CANCELED} THEN 'canceled'
-534                    WHEN TASK.{IS_COMPLETED} THEN 'completed'
-535                END AS status,
+528                    WHEN TASK.{IS_TODO} THEN 'to-do'
+529                    WHEN TASK.{IS_PROJECT} THEN 'project'
+530                    WHEN TASK.{IS_HEADING} THEN 'heading'
+531                END AS type,
+532                CASE
+533                    WHEN TASK.{IS_TRASHED} THEN 1
+534                END AS trashed,
+535                TASK.title,
 536                CASE
-537                    WHEN AREA.uuid IS NOT NULL THEN AREA.uuid
-538                END AS area,
-539                CASE
-540                    WHEN AREA.uuid IS NOT NULL THEN AREA.title
-541                END AS area_title,
-542                CASE
-543                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid
-544                END AS project,
-545                CASE
-546                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.title
-547                END AS project_title,
-548                CASE
-549                    WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid
-550                END AS heading,
-551                CASE
-552                    WHEN HEADING.uuid IS NOT NULL THEN HEADING.title
-553                END AS heading_title,
-554                TASK.notes,
-555                CASE
-556                    WHEN TAG.uuid IS NOT NULL THEN 1
-557                END AS tags,
-558                CASE
-559                    WHEN TASK.{IS_INBOX} THEN 'Inbox'
-560                    WHEN TASK.{IS_ANYTIME} THEN 'Anytime'
-561                    WHEN TASK.{IS_SOMEDAY} THEN 'Someday'
-562                END AS start,
+537                    WHEN TASK.{IS_INCOMPLETE} THEN 'incomplete'
+538                    WHEN TASK.{IS_CANCELED} THEN 'canceled'
+539                    WHEN TASK.{IS_COMPLETED} THEN 'completed'
+540                END AS status,
+541                CASE
+542                    WHEN AREA.uuid IS NOT NULL THEN AREA.uuid
+543                END AS area,
+544                CASE
+545                    WHEN AREA.uuid IS NOT NULL THEN AREA.title
+546                END AS area_title,
+547                CASE
+548                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.uuid
+549                END AS project,
+550                CASE
+551                    WHEN PROJECT.uuid IS NOT NULL THEN PROJECT.title
+552                END AS project_title,
+553                CASE
+554                    WHEN HEADING.uuid IS NOT NULL THEN HEADING.uuid
+555                END AS heading,
+556                CASE
+557                    WHEN HEADING.uuid IS NOT NULL THEN HEADING.title
+558                END AS heading_title,
+559                TASK.notes,
+560                CASE
+561                    WHEN TAG.uuid IS NOT NULL THEN 1
+562                END AS tags,
 563                CASE
-564                    WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1
-565                END AS checklist,
-566                date({start_date_expression}) AS start_date,
-567                date({deadline_expression}) AS deadline,
-568                datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date",
-569                datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created,
-570                datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified,
-571                TASK.'index',
-572                TASK.todayIndex AS today_index
-573            FROM
-574                {TABLE_TASK} AS TASK
-575            LEFT OUTER JOIN
-576                {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid
-577            LEFT OUTER JOIN
-578                {TABLE_AREA} AREA ON TASK.area = AREA.uuid
-579            LEFT OUTER JOIN
-580                {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid
-581            LEFT OUTER JOIN
-582                {TABLE_TASK} PROJECT_OF_HEADING
-583                ON HEADING.project = PROJECT_OF_HEADING.uuid
+564                    WHEN TASK.{IS_INBOX} THEN 'Inbox'
+565                    WHEN TASK.{IS_ANYTIME} THEN 'Anytime'
+566                    WHEN TASK.{IS_SOMEDAY} THEN 'Someday'
+567                END AS start,
+568                CASE
+569                    WHEN CHECKLIST_ITEM.uuid IS NOT NULL THEN 1
+570                END AS checklist,
+571                date({start_date_expression}) AS start_date,
+572                date({deadline_expression}) AS deadline,
+573                datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date",
+574                datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created,
+575                datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified,
+576                TASK.'index',
+577                TASK.todayIndex AS today_index
+578            FROM
+579                {TABLE_TASK} AS TASK
+580            LEFT OUTER JOIN
+581                {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid
+582            LEFT OUTER JOIN
+583                {TABLE_AREA} AREA ON TASK.area = AREA.uuid
 584            LEFT OUTER JOIN
-585                {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks
+585                {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid
 586            LEFT OUTER JOIN
-587                {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid
-588            LEFT OUTER JOIN
-589                {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM
-590                ON TASK.uuid = CHECKLIST_ITEM.task
-591            WHERE
-592                {where_predicate}
-593            ORDER BY
-594                {order_predicate}
-595            """
+587                {TABLE_TASK} PROJECT_OF_HEADING
+588                ON HEADING.project = PROJECT_OF_HEADING.uuid
+589            LEFT OUTER JOIN
+590                {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks
+591            LEFT OUTER JOIN
+592                {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid
+593            LEFT OUTER JOIN
+594                {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM
+595                ON TASK.uuid = CHECKLIST_ITEM.task
+596            WHERE
+597                {where_predicate}
+598            ORDER BY
+599                {order_predicate}
+600            """
 
@@ -2390,60 +2396,60 @@
Parameters
-
601def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True):
-602    """
-603    Return a SQL expression of an isodate converted into a "Things date".
-604
-605    A _Things date_ is an integer where the binary digits are
-606    YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day.
-607
-608    For example, the ISO 8601 date '2021-03-28' corresponds to the Things
-609    date 132464128 as integer; in binary that is:
-610        111111001010011111000000000
-611        YYYYYYYYYYYMMMMDDDDD0000000
-612               2021   3   28
-613
-614    Parameters
-615    ----------
-616    sql_expression : str
-617        A sql expression evaluating to an ISO 8601 date str.
+            
606def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True):
+607    """
+608    Return a SQL expression of an isodate converted into a "Things date".
+609
+610    A _Things date_ is an integer where the binary digits are
+611    YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day.
+612
+613    For example, the ISO 8601 date '2021-03-28' corresponds to the Things
+614    date 132464128 as integer; in binary that is:
+615        111111001010011111000000000
+616        YYYYYYYYYYYMMMMDDDDD0000000
+617               2021   3   28
 618
-619    null_possible : bool
-620        Can the input `sql_expression` evaluate to NULL?
-621
-622    Returns
-623    -------
-624    str
-625        A sql expression representing a "Things date" as integer.
+619    Parameters
+620    ----------
+621    sql_expression : str
+622        A sql expression evaluating to an ISO 8601 date str.
+623
+624    null_possible : bool
+625        Can the input `sql_expression` evaluate to NULL?
 626
-627    Example
+627    Returns
 628    -------
-629    >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')")
-630    "(CASE WHEN date('now', 'localtime') THEN \
-631    ((strftime('%Y', date('now', 'localtime')) << 16) \
-632    | (strftime('%m', date('now', 'localtime')) << 12) \
-633    | (strftime('%d', date('now', 'localtime')) << 7)) \
-634    ELSE date('now', 'localtime') END)"
-635    >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'")
-636    "(CASE WHEN '2023-05-22' THEN \
-637    ((strftime('%Y', '2023-05-22') << 16) \
-638    | (strftime('%m', '2023-05-22') << 12) \
-639    | (strftime('%d', '2023-05-22') << 7)) \
-640    ELSE '2023-05-22' END)"
-641    """
-642    isodate = sql_expression
-643
-644    year = f"strftime('%Y', {isodate}) << 16"
-645    month = f"strftime('%m', {isodate}) << 12"
-646    day = f"strftime('%d', {isodate}) << 7"
-647
-648    thingsdate = f"(({year}) | ({month}) | ({day}))"
-649
-650    if null_possible:
-651        # when isodate is NULL, return isodate as-is
-652        return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)"
-653
-654    return thingsdate
+629    str
+630        A sql expression representing a "Things date" as integer.
+631
+632    Example
+633    -------
+634    >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')")
+635    "(CASE WHEN date('now', 'localtime') THEN \
+636    ((strftime('%Y', date('now', 'localtime')) << 16) \
+637    | (strftime('%m', date('now', 'localtime')) << 12) \
+638    | (strftime('%d', date('now', 'localtime')) << 7)) \
+639    ELSE date('now', 'localtime') END)"
+640    >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'")
+641    "(CASE WHEN '2023-05-22' THEN \
+642    ((strftime('%Y', '2023-05-22') << 16) \
+643    | (strftime('%m', '2023-05-22') << 12) \
+644    | (strftime('%d', '2023-05-22') << 7)) \
+645    ELSE '2023-05-22' END)"
+646    """
+647    isodate = sql_expression
+648
+649    year = f"strftime('%Y', {isodate}) << 16"
+650    month = f"strftime('%m', {isodate}) << 12"
+651    day = f"strftime('%d', {isodate}) << 7"
+652
+653    thingsdate = f"(({year}) | ({month}) | ({day}))"
+654
+655    if null_possible:
+656        # when isodate is NULL, return isodate as-is
+657        return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)"
+658
+659    return thingsdate
 
@@ -2497,40 +2503,40 @@
Example
-
657def convert_thingsdate_sql_expression_to_isodate(sql_expression):
-658    """
-659    Return SQL expression as string.
-660
-661    Parameters
-662    ----------
-663    sql_expression : str
-664        A sql expression pointing to a "Things date" integer in
-665        format YYYYYYYYYYYMMMMDDDDD0000000, in binary.
-666        See: `convert_isodate_sql_expression_to_thingsdate` for details.
-667
-668    Example
-669    -------
-670    >>> convert_thingsdate_sql_expression_to_isodate('132464128')
-671    "CASE WHEN 132464128 THEN \
-672    printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \
-673    (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END"
-674    >>> convert_thingsdate_sql_expression_to_isodate('startDate')
-675    "CASE WHEN startDate THEN \
-676    printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \
-677    (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END"
-678    """
-679    y_mask = 0b111111111110000000000000000
-680    m_mask = 0b000000000001111000000000000
-681    d_mask = 0b000000000000000111110000000
-682
-683    thingsdate = sql_expression
-684    year = f"({thingsdate} & {y_mask}) >> 16"
-685    month = f"({thingsdate} & {m_mask}) >> 12"
-686    day = f"({thingsdate} & {d_mask}) >> 7"
+            
662def convert_thingsdate_sql_expression_to_isodate(sql_expression):
+663    """
+664    Return SQL expression as string.
+665
+666    Parameters
+667    ----------
+668    sql_expression : str
+669        A sql expression pointing to a "Things date" integer in
+670        format YYYYYYYYYYYMMMMDDDDD0000000, in binary.
+671        See: `convert_isodate_sql_expression_to_thingsdate` for details.
+672
+673    Example
+674    -------
+675    >>> convert_thingsdate_sql_expression_to_isodate('132464128')
+676    "CASE WHEN 132464128 THEN \
+677    printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \
+678    (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END"
+679    >>> convert_thingsdate_sql_expression_to_isodate('startDate')
+680    "CASE WHEN startDate THEN \
+681    printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \
+682    (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END"
+683    """
+684    y_mask = 0b111111111110000000000000000
+685    m_mask = 0b000000000001111000000000000
+686    d_mask = 0b000000000000000111110000000
 687
-688    isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})"
-689    # when thingsdate is NULL, return thingsdate as-is
-690    return f"CASE WHEN {thingsdate} THEN {isodate} ELSE {thingsdate} END"
+688    thingsdate = sql_expression
+689    year = f"({thingsdate} & {y_mask}) >> 16"
+690    month = f"({thingsdate} & {m_mask}) >> 12"
+691    day = f"({thingsdate} & {d_mask}) >> 7"
+692
+693    isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})"
+694    # when thingsdate is NULL, return thingsdate as-is
+695    return f"CASE WHEN {thingsdate} THEN {isodate} ELSE {thingsdate} END"
 
@@ -2569,22 +2575,22 @@
Example
-
693def dict_factory(cursor, row):
-694    """
-695    Convert SQL result into a dictionary.
-696
-697    See also:
-698    https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory
-699    """
-700    result = {}
-701    for index, column in enumerate(cursor.description):
-702        key, value = column[0], row[index]
-703        if value is None and key in COLUMNS_TO_OMIT_IF_NONE:
-704            continue
-705        if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL:
-706            value = bool(value)
-707        result[key] = value
-708    return result
+            
698def dict_factory(cursor, row):
+699    """
+700    Convert SQL result into a dictionary.
+701
+702    See also:
+703    https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory
+704    """
+705    result = {}
+706    for index, column in enumerate(cursor.description):
+707        key, value = column[0], row[index]
+708        if value is None and key in COLUMNS_TO_OMIT_IF_NONE:
+709            continue
+710        if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL:
+711            value = bool(value)
+712        result[key] = value
+713    return result
 
@@ -2607,28 +2613,28 @@
Example
-
711def escape_string(string):
-712    r"""
-713    Escape SQLite string literal.
-714
-715    Three notes:
-716
-717    1. A single quote within a SQLite string can be encoded by putting
-718    two single quotes in a row. Escapes using the backslash character
-719    are not supported in SQLite.
-720
-721    2. Null characters '\0' within strings can lead to surprising
-722    behavior. However, `cursor.execute` will already throw a `ValueError`
-723    if it finds a null character in the query, so we let it handle
-724    this case for us.
+            
716def escape_string(string):
+717    r"""
+718    Escape SQLite string literal.
+719
+720    Three notes:
+721
+722    1. A single quote within a SQLite string can be encoded by putting
+723    two single quotes in a row. Escapes using the backslash character
+724    are not supported in SQLite.
 725
-726    3. Eventually we might want to make use of parameters instead of
-727    manually escaping. Since this will require some refactoring,
-728    we are going with the easiest solution for now.
-729
-730    See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
-731    """
-732    return string.replace("'", "''")
+726    2. Null characters '\0' within strings can lead to surprising
+727    behavior. However, `cursor.execute` will already throw a `ValueError`
+728    if it finds a null character in the query, so we let it handle
+729    this case for us.
+730
+731    3. Eventually we might want to make use of parameters instead of
+732    manually escaping. Since this will require some refactoring,
+733    we are going with the easiest solution for now.
+734
+735    See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
+736    """
+737    return string.replace("'", "''")
 
@@ -2665,25 +2671,25 @@
Example
-
735def isodate_to_yyyyyyyyyyymmmmddddd(value: str):
-736    """
-737    Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000.
-738
-739    Y is year, M is month, D is day as binary.
-740    See also `convert_isodate_sql_expression_to_thingsdate`.
-741
-742    Parameters
-743    ----------
-744    value : str
-745        ISO 8601 date str
+            
740def isodate_to_yyyyyyyyyyymmmmddddd(value: str):
+741    """
+742    Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000.
+743
+744    Y is year, M is month, D is day as binary.
+745    See also `convert_isodate_sql_expression_to_thingsdate`.
 746
-747    Example
-748    -------
-749    >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28')
-750    132464128
-751    """
-752    year, month, day = map(int, value.split("-"))
-753    return year << 16 | month << 12 | day << 7
+747    Parameters
+748    ----------
+749    value : str
+750        ISO 8601 date str
+751
+752    Example
+753    -------
+754    >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28')
+755    132464128
+756    """
+757    year, month, day = map(int, value.split("-"))
+758    return year << 16 | month << 12 | day << 7
 
@@ -2721,9 +2727,9 @@
Example
-
756def list_factory(_cursor, row):
-757    """Convert SQL selects of one column into a list."""
-758    return row[0]
+            
761def list_factory(_cursor, row):
+762    """Convert SQL selects of one column into a list."""
+763    return row[0]
 
@@ -2743,32 +2749,32 @@
Example
-
761def make_filter(column, value):
-762    """
-763    Return SQL filter 'AND {column} = "{value}"'.
-764
-765    Special handling if `value` is `bool` or `None`.
-766
-767    Examples
-768    --------
-769    >>> make_filter('title', 'Important')
-770    "AND title = 'Important'"
+            
766def make_filter(column, value):
+767    """
+768    Return SQL filter 'AND {column} = "{value}"'.
+769
+770    Special handling if `value` is `bool` or `None`.
 771
-772    >>> make_filter('startDate', True)
-773    'AND startDate IS NOT NULL'
-774
-775    >>> make_filter('startDate', False)
-776    'AND startDate IS NULL'
-777
-778    >>> make_filter('title', None)
-779    ''
-780    """
-781    default = f"AND {column} = '{escape_string(str(value))}'"
-782    return {
-783        None: "",
-784        False: f"AND {column} IS NULL",
-785        True: f"AND {column} IS NOT NULL",
-786    }.get(value, default)
+772    Examples
+773    --------
+774    >>> make_filter('title', 'Important')
+775    "AND title = 'Important'"
+776
+777    >>> make_filter('startDate', True)
+778    'AND startDate IS NOT NULL'
+779
+780    >>> make_filter('startDate', False)
+781    'AND startDate IS NULL'
+782
+783    >>> make_filter('title', None)
+784    ''
+785    """
+786    default = f"AND {column} = '{escape_string(str(value))}'"
+787    return {
+788        None: "",
+789        False: f"AND {column} IS NULL",
+790        True: f"AND {column} IS NOT NULL",
+791    }.get(value, default)
 
@@ -2816,12 +2822,12 @@
Examples
-
789def make_or_filter(*filters):
-790    """Join filters with OR."""
-791    filters = filter(None, filters)  # type: ignore
-792    filters = [remove_prefix(filter, "AND ") for filter in filters]  # type: ignore
-793    filters = " OR ".join(filters)  # type: ignore
-794    return f"AND ({filters})" if filters else ""
+            
794def make_or_filter(*filters):
+795    """Join filters with OR."""
+796    filters = filter(None, filters)  # type: ignore
+797    filters = [remove_prefix(filter, "AND ") for filter in filters]  # type: ignore
+798    filters = " OR ".join(filters)  # type: ignore
+799    return f"AND ({filters})" if filters else ""
 
@@ -2841,26 +2847,27 @@
Examples
-
797def make_search_filter(query: Optional[str]) -> str:
-798    """
-799    Return a SQL filter to search tasks by a string query.
-800
-801    Example:
-802    --------
-803    >>> make_search_filter('dinner') #doctest: +REPORT_NDIFF
-804    "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR AREA.title LIKE '%dinner%')"
-805    """
-806    if not query:
-807        return ""
-808
-809    query = escape_string(query)
-810
-811    # noqa todo 'TMChecklistItem.title'
-812    columns = ["TASK.title", "TASK.notes", "AREA.title"]
-813
-814    sub_searches = (f"{column} LIKE '%{query}%'" for column in columns)
-815
-816    return f"AND ({' OR '.join(sub_searches)})"
+            
802def make_search_filter(query: Optional[str]) -> str:
+803    """
+804    Return a SQL filter to search tasks by a string query.
+805
+806    Example:
+807    --------
+808    >>> make_search_filter('dinner')
+809    "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR \
+810    AREA.title LIKE '%dinner%')"
+811    """
+812    if not query:
+813        return ""
+814
+815    query = escape_string(query)
+816
+817    # noqa todo 'TMChecklistItem.title'
+818    columns = ["TASK.title", "TASK.notes", "AREA.title"]
+819
+820    sub_searches = (f"{column} LIKE '%{query}%'" for column in columns)
+821
+822    return f"AND ({' OR '.join(sub_searches)})"
 
@@ -2869,8 +2876,8 @@
Examples

Example:

-
>>> make_search_filter('dinner') #doctest: +REPORT_NDIFF
-"AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR AREA.title LIKE '%dinner%')"
+
>>> make_search_filter('dinner')
+"AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR     AREA.title LIKE '%dinner%')"
 
@@ -2888,81 +2895,81 @@

Example:

-
819def make_thingsdate_filter(date_column: str, value) -> str:
-820    """
-821    Return a SQL filter for "Things date" columns.
-822
-823    Parameters
-824    ----------
-825    date_column : str
-826        Name of the column that has date information on a task
-827        stored as an INTEGER in "Things date" format.
-828        See `convert_isodate_sql_expression_to_thingsdate` for details
-829        on Things dates.
-830
-831    value : bool, 'future', 'past', ISO 8601 date str, or None
-832        `True` or `False` indicates whether a date is set or not.
-833        `'future'` or `'past'` indicates a date in the future or past.
-834        ISO 8601 date str is in the format "YYYY-MM-DD", possibly
-835        prefixed with an operator such as ">YYYY-MM-DD",
-836        "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.
-837        `None` indicates any value.
-838
-839    Returns
-840    -------
-841    str
-842        A date filter for the SQL query. If `value == None`, then
-843        return the empty string.
+            
825def make_thingsdate_filter(date_column: str, value) -> str:
+826    """
+827    Return a SQL filter for "Things date" columns.
+828
+829    Parameters
+830    ----------
+831    date_column : str
+832        Name of the column that has date information on a task
+833        stored as an INTEGER in "Things date" format.
+834        See `convert_isodate_sql_expression_to_thingsdate` for details
+835        on Things dates.
+836
+837    value : bool, 'future', 'past', ISO 8601 date str, or None
+838        `True` or `False` indicates whether a date is set or not.
+839        `'future'` or `'past'` indicates a date in the future or past.
+840        ISO 8601 date str is in the format "YYYY-MM-DD", possibly
+841        prefixed with an operator such as ">YYYY-MM-DD",
+842        "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.
+843        `None` indicates any value.
 844
-845    Examples
-846    --------
-847    >>> make_thingsdate_filter('startDate', True)
-848    'AND startDate IS NOT NULL'
-849
-850    >>> make_thingsdate_filter('startDate', False)
-851    'AND startDate IS NULL'
-852
-853    >>> make_thingsdate_filter('startDate', 'future')
-854    "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \
-855    | (strftime('%m', date('now', 'localtime')) << 12) \
-856    | (strftime('%d', date('now', 'localtime')) << 7))"
-857
-858    >>> make_thingsdate_filter('deadline', '2021-03-28')
-859    'AND deadline == 132464128'
-860
-861    >>> make_thingsdate_filter('deadline', '=2021-03-28')
-862    'AND deadline = 132464128'
+845    Returns
+846    -------
+847    str
+848        A date filter for the SQL query. If `value == None`, then
+849        return the empty string.
+850
+851    Examples
+852    --------
+853    >>> make_thingsdate_filter('startDate', True)
+854    'AND startDate IS NOT NULL'
+855
+856    >>> make_thingsdate_filter('startDate', False)
+857    'AND startDate IS NULL'
+858
+859    >>> make_thingsdate_filter('startDate', 'future')
+860    "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \
+861    | (strftime('%m', date('now', 'localtime')) << 12) \
+862    | (strftime('%d', date('now', 'localtime')) << 7))"
 863
-864    >>> make_thingsdate_filter('deadline', '<=2021-03-28')
-865    'AND deadline <= 132464128'
+864    >>> make_thingsdate_filter('deadline', '2021-03-28')
+865    'AND deadline == 132464128'
 866
-867    >>> make_thingsdate_filter('deadline', None)
-868    ''
+867    >>> make_thingsdate_filter('deadline', '=2021-03-28')
+868    'AND deadline = 132464128'
 869
-870    """
-871    if value is None:
-872        return ""
-873
-874    if isinstance(value, bool):
-875        return make_filter(date_column, value)
-876
-877    # Check for ISO 8601 date str + optional operator
-878    match = match_date(value)
-879    if match:
-880        comparator, isodate = match.groups()
-881        if not comparator:
-882            comparator = "=="
-883        thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate)
-884        threshold = str(thingsdate)
-885    else:
-886        # "future" or "past"
-887        validate("value", value, ["future", "past"])
-888        threshold = convert_isodate_sql_expression_to_thingsdate(
-889            "date('now', 'localtime')", null_possible=False
-890        )
-891        comparator = ">" if value == "future" else "<="
-892
-893    return f"AND {date_column} {comparator} {threshold}"
+870    >>> make_thingsdate_filter('deadline', '<=2021-03-28')
+871    'AND deadline <= 132464128'
+872
+873    >>> make_thingsdate_filter('deadline', None)
+874    ''
+875
+876    """
+877    if value is None:
+878        return ""
+879
+880    if isinstance(value, bool):
+881        return make_filter(date_column, value)
+882
+883    # Check for ISO 8601 date str + optional operator
+884    match = match_date(value)
+885    if match:
+886        comparator, isodate = match.groups()
+887        if not comparator:
+888            comparator = "=="
+889        thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate)
+890        threshold = str(thingsdate)
+891    else:
+892        # "future" or "past"
+893        validate("value", value, ["future", "past"])
+894        threshold = convert_isodate_sql_expression_to_thingsdate(
+895            "date('now', 'localtime')", null_possible=False
+896        )
+897        comparator = ">" if value == "future" else "<="
+898
+899    return f"AND {date_column} {comparator} {threshold}"
 
@@ -3050,33 +3057,33 @@
Examples
-
896def make_truthy_filter(column: str, value) -> str:
-897    """
-898    Return a SQL filter that matches if a column is truthy or falsy.
-899
-900    Truthy means TRUE. Falsy means FALSE or NULL. This is akin
-901    to how Python defines it natively.
-902
-903    Passing in `value == None` returns the empty string.
-904
-905    Examples
-906    --------
-907    >>> make_truthy_filter('PROJECT.trashed', True)
-908    'AND PROJECT.trashed'
-909
-910    >>> make_truthy_filter('PROJECT.trashed', False)
-911    'AND NOT IFNULL(PROJECT.trashed, 0)'
-912
-913    >>> make_truthy_filter('PROJECT.trashed', None)
-914    ''
-915    """
-916    if value is None:
-917        return ""
+            
902def make_truthy_filter(column: str, value) -> str:
+903    """
+904    Return a SQL filter that matches if a column is truthy or falsy.
+905
+906    Truthy means TRUE. Falsy means FALSE or NULL. This is akin
+907    to how Python defines it natively.
+908
+909    Passing in `value == None` returns the empty string.
+910
+911    Examples
+912    --------
+913    >>> make_truthy_filter('PROJECT.trashed', True)
+914    'AND PROJECT.trashed'
+915
+916    >>> make_truthy_filter('PROJECT.trashed', False)
+917    'AND NOT IFNULL(PROJECT.trashed, 0)'
 918
-919    if value:
-920        return f"AND {column}"
-921
-922    return f"AND NOT IFNULL({column}, 0)"
+919    >>> make_truthy_filter('PROJECT.trashed', None)
+920    ''
+921    """
+922    if value is None:
+923        return ""
+924
+925    if value:
+926        return f"AND {column}"
+927
+928    return f"AND NOT IFNULL({column}, 0)"
 
@@ -3121,77 +3128,77 @@
Examples
-
925def make_unixtime_filter(date_column: str, value) -> str:
-926    """
-927    Return a SQL filter for UNIX time columns.
-928
-929    Parameters
-930    ----------
-931    date_column : str
-932        Name of the column that has datetime information on a task
-933        stored in UNIX time, that is, number of seconds since
-934        1970-01-01 00:00 UTC.
-935
-936    value : bool, 'future', 'past', ISO 8601 date str, or None
-937        `True` or `False` indicates whether a date is set or not.
-938        `'future'` or `'past'` indicates a date in the future or past.
-939        ISO 8601 date str is in the format "YYYY-MM-DD", possibly
-940        prefixed with an operator such as ">YYYY-MM-DD",
-941        "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.
-942        `None` indicates any value.
-943
-944    Returns
-945    -------
-946    str
-947        A date filter for the SQL query. If `value == None`, then
-948        return the empty string.
-949
-950    Examples
-951    --------
-952    >>> make_unixtime_filter('stopDate', True)
-953    'AND stopDate IS NOT NULL'
-954
-955    >>> make_unixtime_filter('stopDate', False)
-956    'AND stopDate IS NULL'
-957
-958    >>> make_unixtime_filter('stopDate', 'future')
-959    "AND date(stopDate, 'unixepoch') > date('now', 'localtime')"
-960
-961    >>> make_unixtime_filter('creationDate', '2021-03-28')
-962    "AND date(creationDate, 'unixepoch') == date('2021-03-28')"
-963
-964    >>> make_unixtime_filter('creationDate', '=2021-03-28')
-965    "AND date(creationDate, 'unixepoch') = date('2021-03-28')"
-966
-967    >>> make_unixtime_filter('creationDate', '<=2021-03-28')
-968    "AND date(creationDate, 'unixepoch') <= date('2021-03-28')"
-969
-970    >>> make_unixtime_filter('creationDate', None)
-971    ''
-972
-973    """
-974    if value is None:
-975        return ""
-976
-977    if isinstance(value, bool):
-978        return make_filter(date_column, value)
-979
-980    # Check for ISO 8601 date str + optional operator
-981    match = match_date(value)
-982    if match:
-983        comparator, isodate = match.groups()
-984        if not comparator:
-985            comparator = "=="
-986        threshold = f"date('{isodate}')"
-987    else:
-988        # "future" or "past"
-989        validate("value", value, ["future", "past"])
-990        threshold = "date('now', 'localtime')"
-991        comparator = ">" if value == "future" else "<="
-992
-993    date = f"date({date_column}, 'unixepoch')"
-994
-995    return f"AND {date} {comparator} {threshold}"
+            
 931def make_unixtime_filter(date_column: str, value) -> str:
+ 932    """
+ 933    Return a SQL filter for UNIX time columns.
+ 934
+ 935    Parameters
+ 936    ----------
+ 937    date_column : str
+ 938        Name of the column that has datetime information on a task
+ 939        stored in UNIX time, that is, number of seconds since
+ 940        1970-01-01 00:00 UTC.
+ 941
+ 942    value : bool, 'future', 'past', ISO 8601 date str, or None
+ 943        `True` or `False` indicates whether a date is set or not.
+ 944        `'future'` or `'past'` indicates a date in the future or past.
+ 945        ISO 8601 date str is in the format "YYYY-MM-DD", possibly
+ 946        prefixed with an operator such as ">YYYY-MM-DD",
+ 947        "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.
+ 948        `None` indicates any value.
+ 949
+ 950    Returns
+ 951    -------
+ 952    str
+ 953        A date filter for the SQL query. If `value == None`, then
+ 954        return the empty string.
+ 955
+ 956    Examples
+ 957    --------
+ 958    >>> make_unixtime_filter('stopDate', True)
+ 959    'AND stopDate IS NOT NULL'
+ 960
+ 961    >>> make_unixtime_filter('stopDate', False)
+ 962    'AND stopDate IS NULL'
+ 963
+ 964    >>> make_unixtime_filter('stopDate', 'future')
+ 965    "AND date(stopDate, 'unixepoch') > date('now', 'localtime')"
+ 966
+ 967    >>> make_unixtime_filter('creationDate', '2021-03-28')
+ 968    "AND date(creationDate, 'unixepoch') == date('2021-03-28')"
+ 969
+ 970    >>> make_unixtime_filter('creationDate', '=2021-03-28')
+ 971    "AND date(creationDate, 'unixepoch') = date('2021-03-28')"
+ 972
+ 973    >>> make_unixtime_filter('creationDate', '<=2021-03-28')
+ 974    "AND date(creationDate, 'unixepoch') <= date('2021-03-28')"
+ 975
+ 976    >>> make_unixtime_filter('creationDate', None)
+ 977    ''
+ 978
+ 979    """
+ 980    if value is None:
+ 981        return ""
+ 982
+ 983    if isinstance(value, bool):
+ 984        return make_filter(date_column, value)
+ 985
+ 986    # Check for ISO 8601 date str + optional operator
+ 987    match = match_date(value)
+ 988    if match:
+ 989        comparator, isodate = match.groups()
+ 990        if not comparator:
+ 991            comparator = "=="
+ 992        threshold = f"date('{isodate}')"
+ 993    else:
+ 994        # "future" or "past"
+ 995        validate("value", value, ["future", "past"])
+ 996        threshold = "date('now', 'localtime')"
+ 997        comparator = ">" if value == "future" else "<="
+ 998
+ 999    date = f"date({date_column}, 'unixepoch')"
+1000
+1001    return f"AND {date} {comparator} {threshold}"
 
@@ -3278,53 +3285,53 @@
Examples
-
 998def make_unixtime_range_filter(date_column: str, offset) -> str:
- 999    """
-1000    Return a SQL filter to limit a Unix time to last X days, weeks, or years.
-1001
-1002    Parameters
-1003    ----------
-1004    date_column : str
-1005        Name of the column that has datetime information on a task
-1006        stored in UNIX time, that is, number of seconds since
-1007        1970-01-01 00:00 UTC.
-1008
-1009    offset : str or None
-1010        A string comprised of an integer and a single character that can
-1011        be 'd', 'w', or 'y' that determines whether to return all tasks
-1012        for the past X days, weeks, or years.
-1013
-1014    Returns
-1015    -------
-1016    str
-1017        A date filter for the SQL query. If `offset == None`, then
-1018        return the empty string.
+            
1004def make_unixtime_range_filter(date_column: str, offset) -> str:
+1005    """
+1006    Return a SQL filter to limit a Unix time to last X days, weeks, or years.
+1007
+1008    Parameters
+1009    ----------
+1010    date_column : str
+1011        Name of the column that has datetime information on a task
+1012        stored in UNIX time, that is, number of seconds since
+1013        1970-01-01 00:00 UTC.
+1014
+1015    offset : str or None
+1016        A string comprised of an integer and a single character that can
+1017        be 'd', 'w', or 'y' that determines whether to return all tasks
+1018        for the past X days, weeks, or years.
 1019
-1020    Examples
-1021    --------
-1022    >>> make_unixtime_range_filter('creationDate', '3d')
-1023    "AND datetime(creationDate, 'unixepoch') > datetime('now', '-3 days')"
-1024
-1025    >>> make_unixtime_range_filter('creationDate', None)
-1026    ''
-1027    """
-1028    if offset is None:
-1029        return ""
+1020    Returns
+1021    -------
+1022    str
+1023        A date filter for the SQL query. If `offset == None`, then
+1024        return the empty string.
+1025
+1026    Examples
+1027    --------
+1028    >>> make_unixtime_range_filter('creationDate', '3d')
+1029    "AND datetime(creationDate, 'unixepoch') > datetime('now', '-3 days')"
 1030
-1031    validate_offset("offset", offset)
-1032    number, suffix = int(offset[:-1]), offset[-1]
-1033
-1034    if suffix == "d":
-1035        modifier = f"-{number} days"
-1036    elif suffix == "w":
-1037        modifier = f"-{number * 7} days"
-1038    elif suffix == "y":
-1039        modifier = f"-{number} years"
-1040
-1041    column_datetime = f"datetime({date_column}, 'unixepoch')"
-1042    offset_datetime = f"datetime('now', '{modifier}')"  # type: ignore
-1043
-1044    return f"AND {column_datetime} > {offset_datetime}"
+1031    >>> make_unixtime_range_filter('creationDate', None)
+1032    ''
+1033    """
+1034    if offset is None:
+1035        return ""
+1036
+1037    validate_offset("offset", offset)
+1038    number, suffix = int(offset[:-1]), offset[-1]
+1039
+1040    if suffix == "d":
+1041        modifier = f"-{number} days"
+1042    elif suffix == "w":
+1043        modifier = f"-{number * 7} days"
+1044    elif suffix == "y":
+1045        modifier = f"-{number} years"
+1046
+1047    column_datetime = f"datetime({date_column}, 'unixepoch')"
+1048    offset_datetime = f"datetime('now', '{modifier}')"  # type: ignore
+1049
+1050    return f"AND {column_datetime} > {offset_datetime}"
 
@@ -3378,9 +3385,9 @@
Examples
-
1047def match_date(value):
-1048    """Return a match object if value is an ISO 8601 date str."""
-1049    return re.fullmatch(r"(=|==|<|<=|>|>=)?(\d{4}-\d{2}-\d{2})", value)
+            
1053def match_date(value):
+1054    """Return a match object if value is an ISO 8601 date str."""
+1055    return re.fullmatch(r"(=|==|<|<=|>|>=)?(\d{4}-\d{2}-\d{2})", value)
 
@@ -3400,12 +3407,12 @@
Examples
-
1052def prettify_sql(sql_query):
-1053    """Make a SQL query easier to read for humans."""
-1054    # remove indentation and leading and trailing whitespace
-1055    result = dedent(sql_query).strip()
-1056    # remove empty lines
-1057    return re.sub(r"^$\n", "", result, flags=re.MULTILINE)
+            
1058def prettify_sql(sql_query):
+1059    """Make a SQL query easier to read for humans."""
+1060    # remove indentation and leading and trailing whitespace
+1061    result = dedent(sql_query).strip()
+1062    # remove empty lines
+1063    return re.sub(r"^$\n", "", result, flags=re.MULTILINE)
 
@@ -3425,9 +3432,9 @@
Examples
-
1060def remove_prefix(text, prefix):
-1061    """Remove prefix from text (as removeprefix() is 3.9+ only)."""
-1062    return text[text.startswith(prefix) and len(prefix) :]
+            
1066def remove_prefix(text, prefix):
+1067    """Remove prefix from text (as removeprefix() is 3.9+ only)."""
+1068    return text[text.startswith(prefix) and len(prefix) :]
 
@@ -3447,36 +3454,36 @@
Examples
-
1065def validate(parameter, argument, valid_arguments):
-1066    """
-1067    For a given parameter, check if its argument type is valid.
-1068
-1069    If not, then raise `ValueError`.
-1070
-1071    Examples
-1072    --------
-1073    >>> validate(
-1074    ...     parameter='status',
-1075    ...     argument='completed',
-1076    ...     valid_arguments=['incomplete', 'completed']
-1077    ... )
-1078    ...
-1079
-1080    >>> validate(
-1081    ...     parameter='status',
-1082    ...     argument='XYZXZY',
-1083    ...     valid_arguments=['incomplete', 'completed']
-1084    ... )
-1085    Traceback (most recent call last):
-1086    ...
-1087    ValueError: Unrecognized status type: 'XYZXZY'
-1088    Valid status types are ['incomplete', 'completed']
-1089    """
-1090    if argument in valid_arguments:
-1091        return
-1092    message = f"Unrecognized {parameter} type: {argument!r}"
-1093    message += f"\nValid {parameter} types are {valid_arguments}"
-1094    raise ValueError(message)
+            
1071def validate(parameter, argument, valid_arguments):
+1072    """
+1073    For a given parameter, check if its argument type is valid.
+1074
+1075    If not, then raise `ValueError`.
+1076
+1077    Examples
+1078    --------
+1079    >>> validate(
+1080    ...     parameter='status',
+1081    ...     argument='completed',
+1082    ...     valid_arguments=['incomplete', 'completed']
+1083    ... )
+1084    ...
+1085
+1086    >>> validate(
+1087    ...     parameter='status',
+1088    ...     argument='XYZXZY',
+1089    ...     valid_arguments=['incomplete', 'completed']
+1090    ... )
+1091    Traceback (most recent call last):
+1092    ...
+1093    ValueError: Unrecognized status type: 'XYZXZY'
+1094    Valid status types are ['incomplete', 'completed']
+1095    """
+1096    if argument in valid_arguments:
+1097        return
+1098    message = f"Unrecognized {parameter} type: {argument!r}"
+1099    message += f"\nValid {parameter} types are {valid_arguments}"
+1100    raise ValueError(message)
 
@@ -3523,53 +3530,53 @@
Examples
-
1097def validate_date(parameter, argument):
-1098    """
-1099    For a given date parameter, check if its argument is valid.
-1100
-1101    If not, then raise `ValueError`.
-1102
-1103    Examples
-1104    --------
-1105    >>> validate_date(parameter='startDate', argument=None)
-1106    >>> validate_date(parameter='startDate', argument='future')
-1107    >>> validate_date(parameter='startDate', argument='2020-01-01')
-1108    >>> validate_date(parameter='startDate', argument='<=2020-01-01')
-1109
-1110    >>> validate_date(parameter='stopDate', argument='=2020-01-01')
-1111
-1112    >>> validate_date(parameter='deadline', argument='XYZ')
-1113    Traceback (most recent call last):
-1114    ...
-1115    ValueError: Invalid deadline argument: 'XYZ'
-1116    Please see the documentation for `deadline` in `things.tasks`.
-1117    """
-1118    if argument is None:
-1119        return
-1120
-1121    if argument in list(DATES):
-1122        return
-1123
-1124    if not isinstance(argument, str):
-1125        raise ValueError(
-1126            f"Invalid {parameter} argument: {argument!r}\n"
-1127            f"Please specify a string or None."
-1128        )
+            
1103def validate_date(parameter, argument):
+1104    """
+1105    For a given date parameter, check if its argument is valid.
+1106
+1107    If not, then raise `ValueError`.
+1108
+1109    Examples
+1110    --------
+1111    >>> validate_date(parameter='startDate', argument=None)
+1112    >>> validate_date(parameter='startDate', argument='future')
+1113    >>> validate_date(parameter='startDate', argument='2020-01-01')
+1114    >>> validate_date(parameter='startDate', argument='<=2020-01-01')
+1115
+1116    >>> validate_date(parameter='stopDate', argument='=2020-01-01')
+1117
+1118    >>> validate_date(parameter='deadline', argument='XYZ')
+1119    Traceback (most recent call last):
+1120    ...
+1121    ValueError: Invalid deadline argument: 'XYZ'
+1122    Please see the documentation for `deadline` in `things.tasks`.
+1123    """
+1124    if argument is None:
+1125        return
+1126
+1127    if argument in list(DATES):
+1128        return
 1129
-1130    match = match_date(argument)
-1131    if not match:
-1132        raise ValueError(
-1133            f"Invalid {parameter} argument: {argument!r}\n"
-1134            f"Please see the documentation for `{parameter}` in `things.tasks`."
-1135        )
-1136
-1137    _, isodate = match.groups()
-1138    try:
-1139        datetime.date.fromisoformat(isodate)
-1140    except ValueError as error:
-1141        raise ValueError(
-1142            f"Invalid {parameter} argument: {argument!r}\n{error}"
-1143        ) from error
+1130    if not isinstance(argument, str):
+1131        raise ValueError(
+1132            f"Invalid {parameter} argument: {argument!r}\n"
+1133            f"Please specify a string or None."
+1134        )
+1135
+1136    match = match_date(argument)
+1137    if not match:
+1138        raise ValueError(
+1139            f"Invalid {parameter} argument: {argument!r}\n"
+1140            f"Please see the documentation for `{parameter}` in `things.tasks`."
+1141        )
+1142
+1143    _, isodate = match.groups()
+1144    try:
+1145        datetime.date.fromisoformat(isodate)
+1146    except ValueError as error:
+1147        raise ValueError(
+1148            f"Invalid {parameter} argument: {argument!r}\n{error}"
+1149        ) from error
 
@@ -3615,39 +3622,39 @@
Examples
-
1146def validate_offset(parameter, argument):
-1147    """
-1148    For a given offset parameter, check if its argument is valid.
-1149
-1150    If not, then raise `ValueError`.
-1151
-1152    Examples
-1153    --------
-1154    >>> validate_offset(parameter='last', argument='3d')
+            
1152def validate_offset(parameter, argument):
+1153    """
+1154    For a given offset parameter, check if its argument is valid.
 1155
-1156    >>> validate_offset(parameter='last', argument='XYZ')
-1157    Traceback (most recent call last):
-1158    ...
-1159    ValueError: Invalid last argument: 'XYZ'
-1160    Please specify a string of the format 'X[d/w/y]' where X is ...
-1161    """
-1162    if argument is None:
-1163        return
-1164
-1165    if not isinstance(argument, str):
-1166        raise ValueError(
-1167            f"Invalid {parameter} argument: {argument!r}\n"
-1168            f"Please specify a string or None."
-1169        )
+1156    If not, then raise `ValueError`.
+1157
+1158    Examples
+1159    --------
+1160    >>> validate_offset(parameter='last', argument='3d')
+1161
+1162    >>> validate_offset(parameter='last', argument='XYZ')
+1163    Traceback (most recent call last):
+1164    ...
+1165    ValueError: Invalid last argument: 'XYZ'
+1166    Please specify a string of the format 'X[d/w/y]' where X is ...
+1167    """
+1168    if argument is None:
+1169        return
 1170
-1171    suffix = argument[-1:]  # slicing here to handle empty strings
-1172    if suffix not in ("d", "w", "y"):
-1173        raise ValueError(
-1174            f"Invalid {parameter} argument: {argument!r}\n"
-1175            f"Please specify a string of the format 'X[d/w/y]' "
-1176            "where X is a non-negative integer followed by 'd', 'w', or 'y' "
-1177            "that indicates days, weeks, or years."
-1178        )
+1171    if not isinstance(argument, str):
+1172        raise ValueError(
+1173            f"Invalid {parameter} argument: {argument!r}\n"
+1174            f"Please specify a string or None."
+1175        )
+1176
+1177    suffix = argument[-1:]  # slicing here to handle empty strings
+1178    if suffix not in ("d", "w", "y"):
+1179        raise ValueError(
+1180            f"Invalid {parameter} argument: {argument!r}\n"
+1181            f"Please specify a string of the format 'X[d/w/y]' "
+1182            "where X is a non-negative integer followed by 'd', 'w', or 'y' "
+1183            "that indicates days, weeks, or years."
+1184        )