things.database

Read from the Things SQLite database using SQL queries.

   1"""Read from the Things SQLite database using SQL queries."""
   2# pylint: disable=C0302
   3
   4import datetime
   5import glob
   6import os
   7import plistlib
   8import re
   9import sqlite3
  10from textwrap import dedent
  11from typing import Optional, Union
  12
  13
  14# --------------------------------------------------
  15# Core constants
  16# --------------------------------------------------
  17
  18
  19# Database filepath with glob pattern for version 3.15.16+
  20DEFAULT_FILEPATH_31616502 = (
  21    "~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac"
  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
  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# 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# Type
 114IS_TODO = TYPE_TO_FILTER["to-do"]
 115IS_PROJECT = TYPE_TO_FILTER["project"]
 116IS_HEADING = TYPE_TO_FILTER["heading"]
 117
 118# Status
 119IS_INCOMPLETE = STATUS_TO_FILTER["incomplete"]
 120IS_CANCELED = STATUS_TO_FILTER["canceled"]
 121IS_COMPLETED = STATUS_TO_FILTER["completed"]
 122
 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
 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    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    :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        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        sql_query = make_tasks_sql_query(where_predicate, order_predicate)
 297
 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        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        return cursor.fetchall()
 506
 507
 508# Helper functions
 509
 510
 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_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 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 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_TASK} HEADING ON TASK.heading = HEADING.uuid
 585            LEFT OUTER JOIN
 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
 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    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    Returns
 627    -------
 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
 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    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
 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
 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
 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    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
 734    See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
 735    """
 736    return string.replace("'", "''")
 737
 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    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 list_factory(_cursor, row):
 761    """Convert SQL selects of one column into a list."""
 762    return row[0]
 763
 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    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
 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    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'
 865
 866    >>> make_thingsdate_filter('deadline', '=2021-03-28')
 867    'AND deadline = 132464128'
 868
 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    >>> make_truthy_filter('PROJECT.trashed', None)
 919    ''
 920    """
 921    if value is None:
 922        return ""
 923
 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    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('stopDate', False)
 961    'AND stopDate IS NULL'
 962
 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')"
 968
 969    >>> make_unixtime_filter('creationDate', '=2021-03-28')
 970    "AND date(creationDate, 'unixepoch') = date('2021-03-28')"
 971
 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    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    >>> 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
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
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
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
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
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    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
1151def validate_offset(parameter, argument):
1152    """
1153    For a given offset parameter, check if its argument is valid.
1154
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    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        )
DEFAULT_FILEPATH_31616502 = '~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/ThingsData-*/Things Database.thingsdatabase/main.sqlite'
DEFAULT_FILEPATH_31516502 = '~/Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac/Things Database.thingsdatabase/main.sqlite'
ENVIRONMENT_VARIABLE_WITH_FILEPATH = 'THINGSDB'
START_TO_FILTER = {'Inbox': 'start = 0', 'Anytime': 'start = 1', 'Someday': 'start = 2'}
STATUS_TO_FILTER = {'incomplete': 'status = 0', 'canceled': 'status = 2', 'completed': 'status = 3'}
TRASHED_TO_FILTER = {True: 'trashed = 1', False: 'trashed = 0'}
TYPE_TO_FILTER = {'to-do': 'type = 0', 'project': 'type = 1', 'heading': 'type = 2'}
DATES = ('future', 'past', True, False)
INDICES = ('index', 'todayIndex')
COLUMNS_TO_OMIT_IF_NONE = ('area', 'area_title', 'checklist', 'heading', 'heading_title', 'project', 'project_title', 'trashed', 'tags')
COLUMNS_TO_TRANSFORM_TO_BOOL = ('checklist', 'tags', 'trashed')
TABLE_AREA = 'TMArea'
TABLE_AREATAG = 'TMAreaTag'
TABLE_CHECKLIST_ITEM = 'TMChecklistItem'
TABLE_META = 'Meta'
TABLE_TAG = 'TMTag'
TABLE_TASK = 'TMTask'
TABLE_TASKTAG = 'TMTaskTag'
TABLE_SETTINGS = 'TMSettings'
DATE_CREATED = 'creationDate'
DATE_MODIFIED = 'userModificationDate'
DATE_STOP = 'stopDate'
DATE_DEADLINE = 'deadline'
DATE_START = 'startDate'
IS_TODO = 'type = 0'
IS_PROJECT = 'type = 1'
IS_HEADING = 'type = 2'
IS_INCOMPLETE = 'status = 0'
IS_CANCELED = 'status = 2'
IS_COMPLETED = 'status = 3'
IS_INBOX = 'start = 0'
IS_ANYTIME = 'start = 1'
IS_SOMEDAY = 'start = 2'
IS_NOT_RECURRING = 'rt1_recurrenceRule IS NULL'
IS_TRASHED = 'trashed = 1'
class Database:
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    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    :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        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        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)
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        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()

Access Things SQL database.

Parameters
  • filepath (str, optional): Any valid path of a SQLite database file generated by the Things app. If the environment variable THINGSDB is set, then use that path. Otherwise, access the default database path.
  • print_sql (bool, default False): Print every SQL query performed. Some may contain '?' and ':' characters which correspond to SQLite parameter tokens. See https://www.sqlite.org/lang_expr.html#varparam
  • ###### Raises: - AssertionError: If the database version is too old.
Database(filepath=None, print_sql=False)
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        # --------------------------------

Set up the database.

debug = False
filepath
print_sql
def 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):
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        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        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)

Get tasks. See things.api.tasks for details on parameters.

def get_task_by_uuid(self, uuid, count_only=False):
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

Get a task by uuid. Raise ValueError if not found.

def get_areas(self, uuid=None, tag=None, count_only=False):
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)

Get areas. See api.areas for details on parameters.

def get_checklist_items(self, todo_uuid=None):
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,))

Get checklist items.

def get_tags(self, title=None, area=None, task=None, titles_only=False):
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)

Get tags. See api.tags for details on parameters.

def get_tags_of_task(self, task_uuid):
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        )

Get tag titles of task.

def get_tags_of_area(self, area_uuid):
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        )

Get tag titles for area.

def get_version(self):
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)

Get Things Database version.

def get_url_scheme_auth_token(self):
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]

Get the Things URL scheme authentication token.

def get_count(self, sql_query, parameters=()):
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]

Count number of results.

def execute_query(self, sql_query, parameters=(), row_factory=None):
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()

Run the actual SQL query.

def make_tasks_sql_query(where_predicate=None, order_predicate=None):
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_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 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 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_TASK} HEADING ON TASK.heading = HEADING.uuid
586            LEFT OUTER JOIN
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            """

Make SQL query for Task table.

def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True):
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    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    Returns
628    -------
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

Return a SQL expression of an isodate converted into a "Things date".

A _Things date_ is an integer where the binary digits are YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day.

For example, the ISO 8601 date '2021-03-28' corresponds to the Things date 132464128 as integer; in binary that is: 111111001010011111000000000 YYYYYYYYYYYMMMMDDDDD0000000 2021 3 28

Parameters
  • sql_expression (str): A sql expression evaluating to an ISO 8601 date str.
  • null_possible (bool): Can the input sql_expression evaluate to NULL?
Returns
  • str: A sql expression representing a "Things date" as integer.
Example
>>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')")
"(CASE WHEN date('now', 'localtime') THEN     ((strftime('%Y', date('now', 'localtime')) << 16)     | (strftime('%m', date('now', 'localtime')) << 12)     | (strftime('%d', date('now', 'localtime')) << 7))     ELSE date('now', 'localtime') END)"
>>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'")
"(CASE WHEN '2023-05-22' THEN     ((strftime('%Y', '2023-05-22') << 16)     | (strftime('%m', '2023-05-22') << 12)     | (strftime('%d', '2023-05-22') << 7))     ELSE '2023-05-22' END)"
def convert_thingsdate_sql_expression_to_isodate(sql_expression):
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    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"

Return SQL expression as string.

Parameters
Example
>>> convert_thingsdate_sql_expression_to_isodate('132464128')
"CASE WHEN 132464128 THEN     printf('%d-%02d-%02d', (132464128 & 134152192) >> 16,     (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END"
>>> convert_thingsdate_sql_expression_to_isodate('startDate')
"CASE WHEN startDate THEN     printf('%d-%02d-%02d', (startDate & 134152192) >> 16,     (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END"
def dict_factory(cursor, row):
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
def escape_string(string):
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    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("'", "''")

Escape SQLite string literal.

Three notes:

  1. A single quote within a SQLite string can be encoded by putting two single quotes in a row. Escapes using the backslash character are not supported in SQLite.

  2. Null characters '\0' within strings can lead to surprising behavior. However, cursor.execute will already throw a ValueError if it finds a null character in the query, so we let it handle this case for us.

  3. Eventually we might want to make use of parameters instead of manually escaping. Since this will require some refactoring, we are going with the easiest solution for now.

See: https://www.sqlite.org/lang_expr.html#literal_values_constants_

def isodate_to_yyyyyyyyyyymmmmddddd(value: 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    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

Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000.

Y is year, M is month, D is day as binary. See also convert_isodate_sql_expression_to_thingsdate.

Parameters
  • value (str): ISO 8601 date str
Example
>>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28')
132464128
def list_factory(_cursor, row):
761def list_factory(_cursor, row):
762    """Convert SQL selects of one column into a list."""
763    return row[0]

Convert SQL selects of one column into a list.

def make_filter(column, value):
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    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)

Return SQL filter 'AND {column} = "{value}"'.

Special handling if value is bool or None.

Examples
>>> make_filter('title', 'Important')
"AND title = 'Important'"
>>> make_filter('startDate', True)
'AND startDate IS NOT NULL'
>>> make_filter('startDate', False)
'AND startDate IS NULL'
>>> make_filter('title', None)
''
def make_or_filter(*filters):
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 ""

Join filters with OR.

def make_search_filter(query: Optional[str]) -> str:
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)})"

Return a SQL filter to search tasks by a string query.

Example:

>>> make_search_filter('dinner')
"AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR     AREA.title LIKE '%dinner%')"
def make_thingsdate_filter(date_column: str, value) -> str:
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    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'
866
867    >>> make_thingsdate_filter('deadline', '=2021-03-28')
868    'AND deadline = 132464128'
869
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}"

Return a SQL filter for "Things date" columns.

Parameters
  • date_column (str): Name of the column that has date information on a task stored as an INTEGER in "Things date" format. See convert_isodate_sql_expression_to_thingsdate for details on Things dates.
  • value (bool, 'future', 'past', ISO 8601 date str, or None): True or False indicates whether a date is set or not. 'future' or 'past' indicates a date in the future or past. ISO 8601 date str is in the format "YYYY-MM-DD", possibly prefixed with an operator such as ">YYYY-MM-DD", "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. None indicates any value.
Returns
  • str: A date filter for the SQL query. If value == None, then return the empty string.
Examples
>>> make_thingsdate_filter('startDate', True)
'AND startDate IS NOT NULL'
>>> make_thingsdate_filter('startDate', False)
'AND startDate IS NULL'
>>> make_thingsdate_filter('startDate', 'future')
"AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16)     | (strftime('%m', date('now', 'localtime')) << 12)     | (strftime('%d', date('now', 'localtime')) << 7))"
>>> make_thingsdate_filter('deadline', '2021-03-28')
'AND deadline == 132464128'
>>> make_thingsdate_filter('deadline', '=2021-03-28')
'AND deadline = 132464128'
>>> make_thingsdate_filter('deadline', '<=2021-03-28')
'AND deadline <= 132464128'
>>> make_thingsdate_filter('deadline', None)
''
def make_truthy_filter(column: str, value) -> str:
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    >>> 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)"

Return a SQL filter that matches if a column is truthy or falsy.

Truthy means TRUE. Falsy means FALSE or NULL. This is akin to how Python defines it natively.

Passing in value == None returns the empty string.

Examples
>>> make_truthy_filter('PROJECT.trashed', True)
'AND PROJECT.trashed'
>>> make_truthy_filter('PROJECT.trashed', False)
'AND NOT IFNULL(PROJECT.trashed, 0)'
>>> make_truthy_filter('PROJECT.trashed', None)
''
def make_unixtime_filter(date_column: str, value) -> str:
 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}"

Return a SQL filter for UNIX time columns.

Parameters
  • date_column (str): Name of the column that has datetime information on a task stored in UNIX time, that is, number of seconds since 1970-01-01 00:00 UTC.
  • value (bool, 'future', 'past', ISO 8601 date str, or None): True or False indicates whether a date is set or not. 'future' or 'past' indicates a date in the future or past. ISO 8601 date str is in the format "YYYY-MM-DD", possibly prefixed with an operator such as ">YYYY-MM-DD", "=YYYY-MM-DD", "<=YYYY-MM-DD", etc. None indicates any value.
Returns
  • str: A date filter for the SQL query. If value == None, then return the empty string.
Examples
>>> make_unixtime_filter('stopDate', True)
'AND stopDate IS NOT NULL'
>>> make_unixtime_filter('stopDate', False)
'AND stopDate IS NULL'
>>> make_unixtime_filter('stopDate', 'future')
"AND date(stopDate, 'unixepoch') > date('now', 'localtime')"
>>> make_unixtime_filter('creationDate', '2021-03-28')
"AND date(creationDate, 'unixepoch') == date('2021-03-28')"
>>> make_unixtime_filter('creationDate', '=2021-03-28')
"AND date(creationDate, 'unixepoch') = date('2021-03-28')"
>>> make_unixtime_filter('creationDate', '<=2021-03-28')
"AND date(creationDate, 'unixepoch') <= date('2021-03-28')"
>>> make_unixtime_filter('creationDate', None)
''
def make_unixtime_range_filter(date_column: str, offset) -> str:
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    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    >>> 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}"

Return a SQL filter to limit a Unix time to last X days, weeks, or years.

Parameters
  • date_column (str): Name of the column that has datetime information on a task stored in UNIX time, that is, number of seconds since 1970-01-01 00:00 UTC.
  • offset (str or None): A string comprised of an integer and a single character that can be 'd', 'w', or 'y' that determines whether to return all tasks for the past X days, weeks, or years.
Returns
  • str: A date filter for the SQL query. If offset == None, then return the empty string.
Examples
>>> make_unixtime_range_filter('creationDate', '3d')
"AND datetime(creationDate, 'unixepoch') > datetime('now', '-3 days')"
>>> make_unixtime_range_filter('creationDate', None)
''
def match_date(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)

Return a match object if value is an ISO 8601 date str.

def prettify_sql(sql_query):
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)

Make a SQL query easier to read for humans.

def remove_prefix(text, 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) :]

Remove prefix from text (as removeprefix() is 3.9+ only).

def validate(parameter, argument, valid_arguments):
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)

For a given parameter, check if its argument type is valid.

If not, then raise ValueError.

Examples
>>> validate(
...     parameter='status',
...     argument='completed',
...     valid_arguments=['incomplete', 'completed']
... )
...
>>> validate(
...     parameter='status',
...     argument='XYZXZY',
...     valid_arguments=['incomplete', 'completed']
... )
Traceback (most recent call last):
...
ValueError: Unrecognized status type: 'XYZXZY'
Valid status types are ['incomplete', 'completed']
def validate_date(parameter, argument):
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    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

For a given date parameter, check if its argument is valid.

If not, then raise ValueError.

Examples
>>> validate_date(parameter='startDate', argument=None)
>>> validate_date(parameter='startDate', argument='future')
>>> validate_date(parameter='startDate', argument='2020-01-01')
>>> validate_date(parameter='startDate', argument='<=2020-01-01')
>>> validate_date(parameter='stopDate', argument='=2020-01-01')
>>> validate_date(parameter='deadline', argument='XYZ')
Traceback (most recent call last):
...
ValueError: Invalid deadline argument: 'XYZ'
Please see the documentation for `deadline` in `things.tasks`.
def validate_offset(parameter, argument):
1152def validate_offset(parameter, argument):
1153    """
1154    For a given offset parameter, check if its argument is valid.
1155
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    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        )

For a given offset parameter, check if its argument is valid.

If not, then raise ValueError.

Examples
>>> validate_offset(parameter='last', argument='3d')
>>> validate_offset(parameter='last', argument='XYZ')
Traceback (most recent call last):
...
ValueError: Invalid last argument: 'XYZ'
Please specify a string of the format 'X[d/w/y]' where X is ...