things.database

Read from the Things SQLite database using SQL queries.

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

Set up the database.

debug = False
connection: sqlite3.Connection
filepath
print_sql
def get_tasks( self, uuid: str | None = None, type: str | None = None, status: str | None = None, start: str | None = None, area: str | bool | None = None, project: str | bool | None = None, heading: str | None = None, tag: str | bool | None = None, start_date: str | bool | None = None, stop_date: str | bool | None = None, deadline: str | bool | None = None, deadline_suppressed: bool | None = None, trashed: bool | None = False, context_trashed: bool | None = False, last: str | None = None, search_query: str | None = None, index: str = 'index', count_only: bool = False):
202    def get_tasks(  # pylint: disable=R0914,R0913,R0917
203        self,
204        uuid: str | None = None,
205        type: str | None = None,  # pylint: disable=W0622
206        status: str | None = None,
207        start: str | None = None,
208        area: str | bool | None = None,
209        project: str | bool | None = None,
210        heading: str | None = None,
211        tag: str | bool | None = None,
212        start_date: str | bool | None = None,
213        stop_date: str | bool | None = None,
214        deadline: str | bool | None = None,
215        deadline_suppressed: bool | None = None,
216        trashed: bool | None = False,
217        context_trashed: bool | None = False,
218        last: str | None = None,
219        search_query: str | None = None,
220        index: str = "index",
221        count_only: bool = False,
222    ):
223        """Get tasks. See `things.api.tasks` for details on parameters."""
224        if uuid:
225            return self.get_task_by_uuid(uuid, count_only=count_only)
226
227        # Overwrites
228        start = start and start.title()
229
230        # Validation
231        validate_date("deadline", deadline)
232        validate("deadline_suppressed", deadline_suppressed, [None, True, False])
233        validate("start", start, [None] + list(START_TO_FILTER))
234        validate_date("start_date", start_date)
235        validate_date("stop_date", stop_date)
236        validate("status", status, [None] + list(STATUS_TO_FILTER))
237        validate("trashed", trashed, [None] + list(TRASHED_TO_FILTER))
238        validate("type", type, [None] + list(TYPE_TO_FILTER))
239        validate("context_trashed", context_trashed, [None, True, False])
240        validate("index", index, list(INDICES))
241        validate_offset("last", last)
242
243        if tag is not None:
244            valid_tags = self.get_tags(titles_only=True)
245            validate("tag", tag, [None] + list(valid_tags))
246
247        # Query
248        # TK: might consider executing SQL with parameters instead.
249        # See: https://docs.python.org/3/library/sqlite3.html#sqlite3.Cursor.execute
250
251        start_filter: str = START_TO_FILTER.get(start, "")  # type: ignore
252        status_filter: str = STATUS_TO_FILTER.get(status, "")  # type: ignore
253        trashed_filter: str = TRASHED_TO_FILTER.get(trashed, "")  # type: ignore
254        type_filter: str = TYPE_TO_FILTER.get(type, "")  # type: ignore
255
256        # Sometimes a task is _not_ set to trashed, but its context
257        # (project or heading it is contained within) is set to trashed.
258        # In those cases, the task wouldn't show up in any app view
259        # except for "Trash".
260        project_trashed_filter = make_truthy_filter("PROJECT.trashed", context_trashed)
261        project_of_heading_trashed_filter = make_truthy_filter(
262            "PROJECT_OF_HEADING.trashed", context_trashed
263        )
264
265        # As a task assigned to a heading is not directly assigned to a project anymore,
266        # we need to check if the heading is assigned to a project.
267        # See, e.g. https://github.com/thingsapi/things.py/issues/94
268        project_filter = make_or_filter(
269            make_filter("TASK.project", project),
270            make_filter("PROJECT_OF_HEADING.uuid", project),
271        )
272
273        where_predicate = f"""
274            TASK.{IS_NOT_RECURRING}
275            {trashed_filter and f"AND TASK.{trashed_filter}"}
276            {project_trashed_filter}
277            {project_of_heading_trashed_filter}
278            {type_filter and f"AND TASK.{type_filter}"}
279            {start_filter and f"AND TASK.{start_filter}"}
280            {status_filter and f"AND TASK.{status_filter}"}
281            {make_filter("TASK.uuid", uuid)}
282            {make_filter("TASK.area", area)}
283            {project_filter}
284            {make_filter("TASK.heading", heading)}
285            {make_filter("TASK.deadlineSuppressionDate", deadline_suppressed)}
286            {make_filter("TAG.title", tag)}
287            {make_thingsdate_filter(f"TASK.{DATE_START}", start_date)}
288            {make_unixtime_filter(f"TASK.{DATE_STOP}", stop_date)}
289            {make_thingsdate_filter(f"TASK.{DATE_DEADLINE}", deadline)}
290            {make_unixtime_range_filter(f"TASK.{DATE_CREATED}", last)}
291            {make_search_filter(search_query)}
292            """
293        order_predicate = f'TASK."{index}"'
294
295        sql_query = make_tasks_sql_query(where_predicate, order_predicate)
296
297        if count_only:
298            return self.get_count(sql_query)
299
300        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):
302    def get_task_by_uuid(self, uuid, count_only=False):
303        """Get a task by uuid. Raise `ValueError` if not found."""
304        where_predicate = "TASK.uuid = ?"
305        sql_query = make_tasks_sql_query(where_predicate)
306        parameters = (uuid,)
307
308        if count_only:
309            return self.get_count(sql_query, parameters)
310
311        result = self.execute_query(sql_query, parameters)
312        if not result:
313            raise ValueError(f"No such task uuid found: {uuid!r}")
314
315        return result

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

def get_areas(self, uuid=None, tag=None, count_only=False):
317    def get_areas(self, uuid=None, tag=None, count_only=False):
318        """Get areas. See `api.areas` for details on parameters."""
319        # Validation
320        if tag is not None:
321            valid_tags = self.get_tags(titles_only=True)
322            validate("tag", tag, [None] + list(valid_tags))
323
324        if (
325            uuid
326            and count_only is False
327            and not self.get_areas(uuid=uuid, count_only=True)
328        ):
329            raise ValueError(f"No such area uuid found: {uuid!r}")
330
331        # Query
332        sql_query = f"""
333            SELECT DISTINCT
334                AREA.uuid,
335                'area' as type,
336                AREA.title,
337                CASE
338                    WHEN AREA_TAG.areas IS NOT NULL THEN 1
339                END AS tags
340            FROM
341                {TABLE_AREA} AS AREA
342            LEFT OUTER JOIN
343                {TABLE_AREATAG} AREA_TAG ON AREA_TAG.areas = AREA.uuid
344            LEFT OUTER JOIN
345                {TABLE_TAG} TAG ON TAG.uuid = AREA_TAG.tags
346            WHERE
347                TRUE
348                {make_filter("TAG.title", tag)}
349                {make_filter("AREA.uuid", uuid)}
350            ORDER BY AREA."index"
351            """
352
353        if count_only:
354            return self.get_count(sql_query)
355
356        return self.execute_query(sql_query)

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

def get_checklist_items(self, todo_uuid=None):
358    def get_checklist_items(self, todo_uuid=None):
359        """Get checklist items."""
360        sql_query = f"""
361            SELECT
362                CHECKLIST_ITEM.title,
363                CASE
364                    WHEN CHECKLIST_ITEM.{IS_INCOMPLETE} THEN 'incomplete'
365                    WHEN CHECKLIST_ITEM.{IS_CANCELED} THEN 'canceled'
366                    WHEN CHECKLIST_ITEM.{IS_COMPLETED} THEN 'completed'
367                END AS status,
368                date(CHECKLIST_ITEM.stopDate, "unixepoch", "localtime") AS stop_date,
369                'checklist-item' as type,
370                CHECKLIST_ITEM.uuid,
371                datetime(
372                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
373                ) AS created,
374                datetime(
375                    CHECKLIST_ITEM.{DATE_MODIFIED}, "unixepoch", "localtime"
376                ) AS modified
377            FROM
378                {TABLE_CHECKLIST_ITEM} AS CHECKLIST_ITEM
379            WHERE
380                CHECKLIST_ITEM.task = ?
381            ORDER BY CHECKLIST_ITEM."index"
382            """
383        return self.execute_query(sql_query, (todo_uuid,))

Get checklist items.

def get_tags(self, title=None, area=None, task=None, titles_only=False):
385    def get_tags(self, title=None, area=None, task=None, titles_only=False):
386        """Get tags. See `api.tags` for details on parameters."""
387        # Validation
388        if title is not None:
389            valid_titles = self.get_tags(titles_only=True)
390            validate("title", title, [None] + list(valid_titles))
391
392        # Query
393        if task:
394            return self.get_tags_of_task(task)
395        if area:
396            return self.get_tags_of_area(area)
397
398        if titles_only:
399            sql_query = f'SELECT title FROM {TABLE_TAG} ORDER BY "index"'
400            return self.execute_query(sql_query, row_factory=list_factory)
401
402        sql_query = f"""
403            SELECT
404                uuid, 'tag' AS type, title, shortcut
405            FROM
406                {TABLE_TAG}
407            WHERE
408                TRUE
409                {make_filter("title", title)}
410            ORDER BY "index"
411            """
412
413        return self.execute_query(sql_query)

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

def get_tags_of_task(self, task_uuid):
415    def get_tags_of_task(self, task_uuid):
416        """Get tag titles of task."""
417        sql_query = f"""
418            SELECT
419                TAG.title
420            FROM
421                {TABLE_TASKTAG} AS TASK_TAG
422            LEFT OUTER JOIN
423                {TABLE_TAG} TAG ON TAG.uuid = TASK_TAG.tags
424            WHERE
425                TASK_TAG.tasks = ?
426            ORDER BY TAG."index"
427            """
428        return self.execute_query(
429            sql_query, parameters=(task_uuid,), row_factory=list_factory
430        )

Get tag titles of task.

def get_tags_of_area(self, area_uuid):
432    def get_tags_of_area(self, area_uuid):
433        """Get tag titles for area."""
434        sql_query = f"""
435            SELECT
436                AREA.title
437            FROM
438                {TABLE_AREATAG} AS AREA_TAG
439            LEFT OUTER JOIN
440                {TABLE_TAG} AREA ON AREA.uuid = AREA_TAG.tags
441            WHERE
442                AREA_TAG.areas = ?
443            ORDER BY AREA."index"
444            """
445        return self.execute_query(
446            sql_query, parameters=(area_uuid,), row_factory=list_factory
447        )

Get tag titles for area.

def get_version(self):
449    def get_version(self):
450        """Get Things Database version."""
451        sql_query = f"SELECT value FROM {TABLE_META} WHERE key = 'databaseVersion'"
452        result = self.execute_query(sql_query, row_factory=list_factory)
453        plist_bytes = result[0].encode()
454        return plistlib.loads(plist_bytes)

Get Things Database version.

def get_url_scheme_auth_token(self):
457    def get_url_scheme_auth_token(self):
458        """Get the Things URL scheme authentication token."""
459        sql_query = f"""
460            SELECT
461                uriSchemeAuthenticationToken
462            FROM
463                {TABLE_SETTINGS}
464            WHERE
465                uuid = 'RhAzEf6qDxCD5PmnZVtBZR'
466            """
467        rows = self.execute_query(sql_query, row_factory=list_factory)
468        return rows[0]

Get the Things URL scheme authentication token.

def get_count(self, sql_query, parameters=()):
470    def get_count(self, sql_query, parameters=()):
471        """Count number of results."""
472        count_sql_query = f"""SELECT COUNT(uuid) FROM (\n{sql_query}\n)"""
473        rows = self.execute_query(
474            count_sql_query, row_factory=list_factory, parameters=parameters
475        )
476        return rows[0]

Count number of results.

def execute_query(self, sql_query, parameters=(), row_factory=None):
479    def execute_query(self, sql_query, parameters=(), row_factory=None):
480        """Run the actual SQL query."""
481        if self.print_sql or self.debug:
482            if not hasattr(self, "execute_query_count"):
483                # This is needed for historical `self.debug`.
484                # TK: might consider removing `debug` flag.
485                self.execute_query_count = 0
486            self.execute_query_count += 1
487            if self.debug:
488                print(f"/* Filepath {self.filepath!r} */")
489            print(f"/* Query {self.execute_query_count} */")
490            if parameters:
491                print(f"/* Parameters: {parameters!r} */")
492            print()
493            print(prettify_sql(sql_query))
494            print()
495
496        with self.connection:
497            # Using context manager to keep queries in separate transactions,
498            # see https://docs.python.org/3/library/sqlite3.html#sqlite3-connection-context-manager
499            self.connection.row_factory = row_factory or dict_factory
500            cursor = self.connection.cursor()
501            cursor.execute(sql_query, parameters)
502
503            return cursor.fetchall()

Run the actual SQL query.

def make_tasks_sql_query(where_predicate=None, order_predicate=None):
509def make_tasks_sql_query(where_predicate=None, order_predicate=None):
510    """Make SQL query for Task table."""
511    where_predicate = where_predicate or "TRUE"
512    order_predicate = order_predicate or 'TASK."index"'
513
514    start_date_expression = convert_thingsdate_sql_expression_to_isodate(
515        f"TASK.{DATE_START}"
516    )
517    deadline_expression = convert_thingsdate_sql_expression_to_isodate(
518        f"TASK.{DATE_DEADLINE}"
519    )
520    reminder_time_expression = convert_thingstime_sql_expression_to_isotime(
521        f"TASK.{REMINDER_TIME}"
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                {start_date_expression} AS start_date,
572                {deadline_expression} AS deadline,
573                {reminder_time_expression} AS "reminder_time",
574                datetime(TASK.{DATE_STOP}, "unixepoch", "localtime") AS "stop_date",
575                datetime(TASK.{DATE_CREATED}, "unixepoch", "localtime") AS created,
576                datetime(TASK.{DATE_MODIFIED}, "unixepoch", "localtime") AS modified,
577                TASK.'index',
578                TASK.todayIndex AS today_index
579            FROM
580                {TABLE_TASK} AS TASK
581            LEFT OUTER JOIN
582                {TABLE_TASK} PROJECT ON TASK.project = PROJECT.uuid
583            LEFT OUTER JOIN
584                {TABLE_AREA} AREA ON TASK.area = AREA.uuid
585            LEFT OUTER JOIN
586                {TABLE_TASK} HEADING ON TASK.heading = HEADING.uuid
587            LEFT OUTER JOIN
588                {TABLE_TASK} PROJECT_OF_HEADING
589                ON HEADING.project = PROJECT_OF_HEADING.uuid
590            LEFT OUTER JOIN
591                {TABLE_TASKTAG} TAGS ON TASK.uuid = TAGS.tasks
592            LEFT OUTER JOIN
593                {TABLE_TAG} TAG ON TAGS.tags = TAG.uuid
594            LEFT OUTER JOIN
595                {TABLE_CHECKLIST_ITEM} CHECKLIST_ITEM
596                ON TASK.uuid = CHECKLIST_ITEM.task
597            WHERE
598                {where_predicate}
599            ORDER BY
600                {order_predicate}
601            """

Make SQL query for Task table.

def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True):
607def convert_isodate_sql_expression_to_thingsdate(sql_expression, null_possible=True):
608    """
609    Return a SQL expression of an isodate converted into a "Things date".
610
611    A _Things date_ is an integer where the binary digits are
612    YYYYYYYYYYYMMMMDDDDD0000000; Y is year, M is month, and D is day.
613
614    For example, the ISO 8601 date '2021-03-28' corresponds to the Things
615    date 132464128 as integer; in binary that is:
616        111111001010011111000000000
617        YYYYYYYYYYYMMMMDDDDD0000000
618               2021   3   28
619
620    Parameters
621    ----------
622    sql_expression : str
623        A sql expression evaluating to an ISO 8601 date str.
624
625    null_possible : bool
626        Can the input `sql_expression` evaluate to NULL?
627
628    Returns
629    -------
630    str
631        A sql expression representing a "Things date" as integer.
632
633    Example
634    -------
635    >>> convert_isodate_sql_expression_to_thingsdate("date('now', 'localtime')")
636    "(CASE WHEN date('now', 'localtime') THEN \
637    ((strftime('%Y', date('now', 'localtime')) << 16) \
638    | (strftime('%m', date('now', 'localtime')) << 12) \
639    | (strftime('%d', date('now', 'localtime')) << 7)) \
640    ELSE date('now', 'localtime') END)"
641    >>> convert_isodate_sql_expression_to_thingsdate("'2023-05-22'")
642    "(CASE WHEN '2023-05-22' THEN \
643    ((strftime('%Y', '2023-05-22') << 16) \
644    | (strftime('%m', '2023-05-22') << 12) \
645    | (strftime('%d', '2023-05-22') << 7)) \
646    ELSE '2023-05-22' END)"
647    """
648    isodate = sql_expression
649
650    year = f"strftime('%Y', {isodate}) << 16"
651    month = f"strftime('%m', {isodate}) << 12"
652    day = f"strftime('%d', {isodate}) << 7"
653
654    thingsdate = f"(({year}) | ({month}) | ({day}))"
655
656    if null_possible:
657        # when isodate is NULL, return isodate as-is
658        return f"(CASE WHEN {isodate} THEN {thingsdate} ELSE {isodate} END)"
659
660    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):
663def convert_thingsdate_sql_expression_to_isodate(sql_expression):
664    """
665    Return SQL expression as string.
666
667    Parameters
668    ----------
669    sql_expression : str
670        A sql expression pointing to a "Things date" integer in
671        format YYYYYYYYYYYMMMMDDDDD0000000, in binary.
672        See: `convert_isodate_sql_expression_to_thingsdate` for details.
673
674    Example
675    -------
676    >>> convert_thingsdate_sql_expression_to_isodate('132464128')
677    "CASE WHEN 132464128 THEN \
678    printf('%d-%02d-%02d', (132464128 & 134152192) >> 16, \
679    (132464128 & 61440) >> 12, (132464128 & 3968) >> 7) ELSE 132464128 END"
680    >>> convert_thingsdate_sql_expression_to_isodate('startDate')
681    "CASE WHEN startDate THEN \
682    printf('%d-%02d-%02d', (startDate & 134152192) >> 16, \
683    (startDate & 61440) >> 12, (startDate & 3968) >> 7) ELSE startDate END"
684    """
685    y_mask = 0b111111111110000000000000000
686    m_mask = 0b000000000001111000000000000
687    d_mask = 0b000000000000000111110000000
688
689    thingsdate = sql_expression
690    year = f"({thingsdate} & {y_mask}) >> 16"
691    month = f"({thingsdate} & {m_mask}) >> 12"
692    day = f"({thingsdate} & {d_mask}) >> 7"
693
694    isodate = f"printf('%d-%02d-%02d', {year}, {month}, {day})"
695    # when thingsdate is NULL, return thingsdate as-is
696    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 convert_thingstime_sql_expression_to_isotime(sql_expression: str) -> str:
699def convert_thingstime_sql_expression_to_isotime(sql_expression: str) -> str:
700    """
701    Return SQL Expression that decodes a Things time as a string.
702
703    A _Things time_ is an integer where the binary digits are
704    hhhhhmmmmmm00000000000000000000; h is hours, m is minutes.
705    Seconds are not encoded in a Things time.
706
707    For example, the ISO 8601 time '12:34:00' corresponds to the Things
708    time 840957952 as integer; in binary that is:
709        0110010001000000000000000000000
710        hhhhhmmmmmm00000000000000000000
711           12    34                  00
712
713    Parameters
714    ----------
715    sql_expression : str
716        A sql expression pointing to a "Things time" integer
717        in format hhhhhmmmmmm00000000000000000000, in binary.
718
719    Example
720    -------
721    >>> convert_thingstime_sql_expression_to_isotime('840957952')
722    "CASE WHEN 840957952 THEN \
723    printf('%02d:%02d', (840957952 & 2080374784) >> 26, \
724    (840957952 & 66060288) >> 20) ELSE 840957952 END"
725    >>> convert_thingstime_sql_expression_to_isotime('reminderTime')
726    "CASE WHEN reminderTime THEN \
727    printf('%02d:%02d', (reminderTime & 2080374784) >> 26, \
728    (reminderTime & 66060288) >> 20) ELSE reminderTime END"
729    """
730    h_mask = 0b1111100000000000000000000000000
731    m_mask = 0b0000011111100000000000000000000
732
733    thingstime = sql_expression
734    hours = f"({thingstime} & {h_mask}) >> 26"
735    minutes = f"({thingstime} & {m_mask}) >> 20"
736
737    isotime = f"printf('%02d:%02d', {hours}, {minutes})"
738    # when thingstime is NULL, return thingstime as-is
739    return f"CASE WHEN {thingstime} THEN {isotime} ELSE {thingstime} END"

Return SQL Expression that decodes a Things time as a string.

A _Things time_ is an integer where the binary digits are hhhhhmmmmmm00000000000000000000; h is hours, m is minutes. Seconds are not encoded in a Things time.

For example, the ISO 8601 time '12:34:00' corresponds to the Things time 840957952 as integer; in binary that is: 0110010001000000000000000000000 hhhhhmmmmmm00000000000000000000 12 34 00

Parameters
  • sql_expression (str): A sql expression pointing to a "Things time" integer in format hhhhhmmmmmm00000000000000000000, in binary.
Example
>>> convert_thingstime_sql_expression_to_isotime('840957952')
"CASE WHEN 840957952 THEN     printf('%02d:%02d', (840957952 & 2080374784) >> 26,     (840957952 & 66060288) >> 20) ELSE 840957952 END"
>>> convert_thingstime_sql_expression_to_isotime('reminderTime')
"CASE WHEN reminderTime THEN     printf('%02d:%02d', (reminderTime & 2080374784) >> 26,     (reminderTime & 66060288) >> 20) ELSE reminderTime END"
def dict_factory(cursor, row):
742def dict_factory(cursor, row):
743    """
744    Convert SQL result into a dictionary.
745
746    See also:
747    https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.row_factory
748    """
749    result = {}
750    for index, column in enumerate(cursor.description):
751        key, value = column[0], row[index]
752        if value is None and key in COLUMNS_TO_OMIT_IF_NONE:
753            continue
754        if value and key in COLUMNS_TO_TRANSFORM_TO_BOOL:
755            value = bool(value)
756        result[key] = value
757    return result
def escape_string(string):
760def escape_string(string):
761    r"""
762    Escape SQLite string literal.
763
764    Three notes:
765
766    1. A single quote within a SQLite string can be encoded by putting
767    two single quotes in a row. Escapes using the backslash character
768    are not supported in SQLite.
769
770    2. Null characters '\0' within strings can lead to surprising
771    behavior. However, `cursor.execute` will already throw a `ValueError`
772    if it finds a null character in the query, so we let it handle
773    this case for us.
774
775    3. Eventually we might want to make use of parameters instead of
776    manually escaping. Since this will require some refactoring,
777    we are going with the easiest solution for now.
778
779    See: https://www.sqlite.org/lang_expr.html#literal_values_constants_
780    """
781    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):
784def isodate_to_yyyyyyyyyyymmmmddddd(value: str):
785    """
786    Return integer, in binary YYYYYYYYYYYMMMMDDDDD0000000.
787
788    Y is year, M is month, D is day as binary.
789    See also `convert_isodate_sql_expression_to_thingsdate`.
790
791    Parameters
792    ----------
793    value : str
794        ISO 8601 date str
795
796    Example
797    -------
798    >>> isodate_to_yyyyyyyyyyymmmmddddd('2021-03-28')
799    132464128
800    """
801    year, month, day = map(int, value.split("-"))
802    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 get_default_database_filepath(raise_on_error=False):
805def get_default_database_filepath(raise_on_error=False):
806    """Return Things 3.17.4+ path else the 3.15 path.
807
808    Optionally, raise an error if the Things 3.17.4+ path isn't found
809    and macOS can't list the files in the `Group Containers` folder.
810    """
811    filepath = next(glob.iglob(DEFAULT_FILEPATH_31704015), None)
812    if filepath:
813        return filepath
814
815    if raise_on_error:
816        # glob hides PermissionError; this raises it
817        os.listdir(THINGS_GROUP_CONTAINER)
818    # fallback to an older Things path to try
819    return DEFAULT_FILEPATH_31516502

Return Things 3.17.4+ path else the 3.15 path.

Optionally, raise an error if the Things 3.17.4+ path isn't found and macOS can't list the files in the Group Containers folder.

def list_factory(_cursor, row):
822def list_factory(_cursor, row):
823    """Convert SQL selects of one column into a list."""
824    return row[0]

Convert SQL selects of one column into a list.

def make_filter(column, value):
827def make_filter(column, value):
828    """
829    Return SQL filter 'AND {column} = "{value}"'.
830
831    Special handling if `value` is `bool` or `None`.
832
833    Examples
834    --------
835    >>> make_filter('title', 'Important')
836    "AND title = 'Important'"
837
838    >>> make_filter('startDate', True)
839    'AND startDate IS NOT NULL'
840
841    >>> make_filter('startDate', False)
842    'AND startDate IS NULL'
843
844    >>> make_filter('title', None)
845    ''
846    """
847    default = f"AND {column} = '{escape_string(str(value))}'"
848    return {
849        None: "",
850        False: f"AND {column} IS NULL",
851        True: f"AND {column} IS NOT NULL",
852    }.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):
855def make_or_filter(*filters):
856    """Join filters with OR."""
857    filters = filter(None, filters)  # type: ignore
858    filters = [remove_prefix(filter, "AND ") for filter in filters]  # type: ignore
859    filters = " OR ".join(filters)  # type: ignore
860    return f"AND ({filters})" if filters else ""

Join filters with OR.

def make_search_filter(query: str | None) -> str:
863def make_search_filter(query: str | None) -> str:
864    """
865    Return a SQL filter to search tasks by a string query.
866
867    Example:
868    --------
869    >>> make_search_filter('dinner')
870    "AND (TASK.title LIKE '%dinner%' OR TASK.notes LIKE '%dinner%' OR \
871    AREA.title LIKE '%dinner%')"
872    """
873    if not query:
874        return ""
875
876    query = escape_string(query)
877
878    # TODO: 'TMChecklistItem.title'  # pylint: disable=fixme
879    columns = ["TASK.title", "TASK.notes", "AREA.title"]
880
881    sub_searches = (f"{column} LIKE '%{query}%'" for column in columns)
882
883    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:
886def make_thingsdate_filter(date_column: str, value) -> str:
887    """
888    Return a SQL filter for "Things date" columns.
889
890    Parameters
891    ----------
892    date_column : str
893        Name of the column that has date information on a task
894        stored as an INTEGER in "Things date" format.
895        See `convert_isodate_sql_expression_to_thingsdate` for details
896        on Things dates.
897
898    value : bool, 'future', 'past', ISO 8601 date str, or None
899        `True` or `False` indicates whether a date is set or not.
900        `'future'` or `'past'` indicates a date in the future or past.
901        ISO 8601 date str is in the format "YYYY-MM-DD", possibly
902        prefixed with an operator such as ">YYYY-MM-DD",
903        "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.
904        `None` indicates any value.
905
906    Returns
907    -------
908    str
909        A date filter for the SQL query. If `value == None`, then
910        return the empty string.
911
912    Examples
913    --------
914    >>> make_thingsdate_filter('startDate', True)
915    'AND startDate IS NOT NULL'
916
917    >>> make_thingsdate_filter('startDate', False)
918    'AND startDate IS NULL'
919
920    >>> make_thingsdate_filter('startDate', 'future')
921    "AND startDate > ((strftime('%Y', date('now', 'localtime')) << 16) \
922    | (strftime('%m', date('now', 'localtime')) << 12) \
923    | (strftime('%d', date('now', 'localtime')) << 7))"
924
925    >>> make_thingsdate_filter('deadline', '2021-03-28')
926    'AND deadline == 132464128'
927
928    >>> make_thingsdate_filter('deadline', '=2021-03-28')
929    'AND deadline = 132464128'
930
931    >>> make_thingsdate_filter('deadline', '<=2021-03-28')
932    'AND deadline <= 132464128'
933
934    >>> make_thingsdate_filter('deadline', None)
935    ''
936
937    """
938    if value is None:
939        return ""
940
941    if isinstance(value, bool):
942        return make_filter(date_column, value)
943
944    # Check for ISO 8601 date str + optional operator
945    match = match_date(value)
946    if match:
947        comparator, isodate = match.groups()
948        if not comparator:
949            comparator = "=="
950        thingsdate = isodate_to_yyyyyyyyyyymmmmddddd(isodate)
951        threshold = str(thingsdate)
952    else:
953        # "future" or "past"
954        validate("value", value, ["future", "past"])
955        threshold = convert_isodate_sql_expression_to_thingsdate(
956            "date('now', 'localtime')", null_possible=False
957        )
958        comparator = ">" if value == "future" else "<="
959
960    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:
963def make_truthy_filter(column: str, value) -> str:
964    """
965    Return a SQL filter that matches if a column is truthy or falsy.
966
967    Truthy means TRUE. Falsy means FALSE or NULL. This is akin
968    to how Python defines it natively.
969
970    Passing in `value == None` returns the empty string.
971
972    Examples
973    --------
974    >>> make_truthy_filter('PROJECT.trashed', True)
975    'AND PROJECT.trashed'
976
977    >>> make_truthy_filter('PROJECT.trashed', False)
978    'AND NOT IFNULL(PROJECT.trashed, 0)'
979
980    >>> make_truthy_filter('PROJECT.trashed', None)
981    ''
982    """
983    if value is None:
984        return ""
985
986    if value:
987        return f"AND {column}"
988
989    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:
 992def make_unixtime_filter(date_column: str, value) -> str:
 993    """
 994    Return a SQL filter for UNIX time columns.
 995
 996    Parameters
 997    ----------
 998    date_column : str
 999        Name of the column that has datetime information on a task
1000        stored in UNIX time, that is, number of seconds since
1001        1970-01-01 00:00 UTC.
1002
1003    value : bool, 'future', 'past', ISO 8601 date str, or None
1004        `True` or `False` indicates whether a date is set or not.
1005        `'future'` or `'past'` indicates a date in the future or past.
1006        ISO 8601 date str is in the format "YYYY-MM-DD", possibly
1007        prefixed with an operator such as ">YYYY-MM-DD",
1008        "=YYYY-MM-DD", "<=YYYY-MM-DD", etc.
1009        `None` indicates any value.
1010
1011    Returns
1012    -------
1013    str
1014        A date filter for the SQL query. If `value == None`, then
1015        return the empty string.
1016
1017    Examples
1018    --------
1019    >>> make_unixtime_filter('stopDate', True)
1020    'AND stopDate IS NOT NULL'
1021
1022    >>> make_unixtime_filter('stopDate', False)
1023    'AND stopDate IS NULL'
1024
1025    >>> make_unixtime_filter('stopDate', 'future')
1026    "AND date(stopDate, 'unixepoch', 'localtime') > date('now', 'localtime')"
1027
1028    >>> make_unixtime_filter('creationDate', '2021-03-28')
1029    "AND date(creationDate, 'unixepoch', 'localtime') == date('2021-03-28')"
1030
1031    >>> make_unixtime_filter('creationDate', '=2021-03-28')
1032    "AND date(creationDate, 'unixepoch', 'localtime') = date('2021-03-28')"
1033
1034    >>> make_unixtime_filter('creationDate', '<=2021-03-28')
1035    "AND date(creationDate, 'unixepoch', 'localtime') <= date('2021-03-28')"
1036
1037    >>> make_unixtime_filter('creationDate', None)
1038    ''
1039
1040    """
1041    if value is None:
1042        return ""
1043
1044    if isinstance(value, bool):
1045        return make_filter(date_column, value)
1046
1047    # Check for ISO 8601 date str + optional operator
1048    match = match_date(value)
1049    if match:
1050        comparator, isodate = match.groups()
1051        if not comparator:
1052            comparator = "=="
1053        threshold = f"date('{isodate}')"
1054    else:
1055        # "future" or "past"
1056        validate("value", value, ["future", "past"])
1057        threshold = "date('now', 'localtime')"
1058        comparator = ">" if value == "future" else "<="
1059
1060    date = f"date({date_column}, 'unixepoch', 'localtime')"
1061
1062    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', 'localtime') > date('now', 'localtime')"
>>> make_unixtime_filter('creationDate', '2021-03-28')
"AND date(creationDate, 'unixepoch', 'localtime') == date('2021-03-28')"
>>> make_unixtime_filter('creationDate', '=2021-03-28')
"AND date(creationDate, 'unixepoch', 'localtime') = date('2021-03-28')"
>>> make_unixtime_filter('creationDate', '<=2021-03-28')
"AND date(creationDate, 'unixepoch', 'localtime') <= date('2021-03-28')"
>>> make_unixtime_filter('creationDate', None)
''
def make_unixtime_range_filter(date_column: str, offset) -> str:
1065def make_unixtime_range_filter(date_column: str, offset) -> str:
1066    """
1067    Return a SQL filter to limit a Unix time to last X days, weeks, or years.
1068
1069    Parameters
1070    ----------
1071    date_column : str
1072        Name of the column that has datetime information on a task
1073        stored in UNIX time, that is, number of seconds since
1074        1970-01-01 00:00 UTC.
1075
1076    offset : str or None
1077        A string comprised of an integer and a single character that can
1078        be 'd', 'w', or 'y' that determines whether to return all tasks
1079        for the past X days, weeks, or years.
1080
1081    Returns
1082    -------
1083    str
1084        A date filter for the SQL query. If `offset == None`, then
1085        return the empty string.
1086
1087    Examples
1088    --------
1089    >>> make_unixtime_range_filter('creationDate', '3d')
1090    "AND datetime(creationDate, 'unixepoch', 'localtime') > datetime('now', '-3 days')"
1091
1092    >>> make_unixtime_range_filter('creationDate', None)
1093    ''
1094    """
1095    if offset is None:
1096        return ""
1097
1098    validate_offset("offset", offset)
1099    number, suffix = int(offset[:-1]), offset[-1]
1100
1101    if suffix == "d":
1102        modifier = f"-{number} days"
1103    elif suffix == "w":
1104        modifier = f"-{number * 7} days"
1105    elif suffix == "y":
1106        modifier = f"-{number} years"
1107    else:
1108        # Use `typing.assert_never(suffix)` from Python 3.11 onwards.
1109        raise AssertionError("line should be unreachable.")  # for static code analyzers
1110
1111    column_datetime = f"datetime({date_column}, 'unixepoch', 'localtime')"
1112    offset_datetime = f"datetime('now', '{modifier}')"
1113
1114    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', 'localtime') > datetime('now', '-3 days')"
>>> make_unixtime_range_filter('creationDate', None)
''
def match_date(value):
1117def match_date(value):
1118    """Return a match object if value is an ISO 8601 date str."""
1119    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):
1122def prettify_sql(sql_query):
1123    """Make a SQL query easier to read for humans."""
1124    # remove indentation and leading and trailing whitespace
1125    result = dedent(sql_query).strip()
1126    # remove empty lines
1127    return re.sub(r"^$\n", "", result, flags=re.MULTILINE)

Make a SQL query easier to read for humans.

def remove_prefix(text, prefix):
1130def remove_prefix(text, prefix):
1131    """Remove prefix from text (as removeprefix() is 3.9+ only)."""
1132    return text[text.startswith(prefix) and len(prefix) :]

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

def validate(parameter, argument, valid_arguments):
1135def validate(parameter, argument, valid_arguments):
1136    """
1137    For a given parameter, check if its argument type is valid.
1138
1139    If not, then raise `ValueError`.
1140
1141    Examples
1142    --------
1143    >>> validate(
1144    ...     parameter='status',
1145    ...     argument='completed',
1146    ...     valid_arguments=['incomplete', 'completed']
1147    ... )
1148    ...
1149
1150    >>> validate(
1151    ...     parameter='status',
1152    ...     argument='XYZXZY',
1153    ...     valid_arguments=['incomplete', 'completed']
1154    ... )
1155    Traceback (most recent call last):
1156    ...
1157    ValueError: Unrecognized status type: 'XYZXZY'
1158    Valid status types are ['incomplete', 'completed']
1159    """
1160    if argument in valid_arguments:
1161        return
1162    message = f"Unrecognized {parameter} type: {argument!r}"
1163    message += f"\nValid {parameter} types are {valid_arguments}"
1164    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):
1167def validate_date(parameter, argument):
1168    """
1169    For a given date parameter, check if its argument is valid.
1170
1171    If not, then raise `ValueError`.
1172
1173    Examples
1174    --------
1175    >>> validate_date(parameter='startDate', argument=None)
1176    >>> validate_date(parameter='startDate', argument='future')
1177    >>> validate_date(parameter='startDate', argument='2020-01-01')
1178    >>> validate_date(parameter='startDate', argument='<=2020-01-01')
1179
1180    >>> validate_date(parameter='stopDate', argument='=2020-01-01')
1181
1182    >>> validate_date(parameter='deadline', argument='XYZ')
1183    Traceback (most recent call last):
1184    ...
1185    ValueError: Invalid deadline argument: 'XYZ'
1186    Please see the documentation for `deadline` in `things.tasks`.
1187    """
1188    if argument is None:
1189        return
1190
1191    if argument in list(DATES):
1192        return
1193
1194    if not isinstance(argument, str):
1195        raise ValueError(
1196            f"Invalid {parameter} argument: {argument!r}\n"
1197            f"Please specify a string or None."
1198        )
1199
1200    match = match_date(argument)
1201    if not match:
1202        raise ValueError(
1203            f"Invalid {parameter} argument: {argument!r}\n"
1204            f"Please see the documentation for `{parameter}` in `things.tasks`."
1205        )
1206
1207    _, isodate = match.groups()
1208    try:
1209        datetime.date.fromisoformat(isodate)
1210    except ValueError as error:
1211        raise ValueError(
1212            f"Invalid {parameter} argument: {argument!r}\n{error}"
1213        ) 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):
1216def validate_offset(parameter, argument):
1217    """
1218    For a given offset parameter, check if its argument is valid.
1219
1220    If not, then raise `ValueError`.
1221
1222    Examples
1223    --------
1224    >>> validate_offset(parameter='last', argument='3d')
1225
1226    >>> validate_offset(parameter='last', argument='XYZ')
1227    Traceback (most recent call last):
1228    ...
1229    ValueError: Invalid last argument: 'XYZ'
1230    Please specify a string of the format 'X[d/w/y]' where X is ...
1231    """
1232    if argument is None:
1233        return
1234
1235    if not isinstance(argument, str):
1236        raise ValueError(
1237            f"Invalid {parameter} argument: {argument!r}\n"
1238            f"Please specify a string or None."
1239        )
1240
1241    suffix = argument[-1:]  # slicing here to handle empty strings
1242    if suffix not in ("d", "w", "y"):
1243        raise ValueError(
1244            f"Invalid {parameter} argument: {argument!r}\n"
1245            f"Please specify a string of the format 'X[d/w/y]' "
1246            "where X is a non-negative integer followed by 'd', 'w', or 'y' "
1247            "that indicates days, weeks, or years."
1248        )

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 ...