From c3ae54aea27ff9f93fbea69a8a2bd1ed9305b9cf Mon Sep 17 00:00:00 2001 From: Brett Porter <49108+brettporter@users.noreply.github.com> Date: Sun, 24 May 2026 00:36:05 +1000 Subject: [PATCH] fix: handle missing columns in older OmniFocus database schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmniFocus adds new columns in database schema upgrades. Users on older macOS versions that cannot run the latest OmniFocus (e.g. OmniFocus 4.3.3 on an older OS) have a database schema that is missing columns introduced in later versions, causing IndexError crashes in _map_task_row, _map_project_row, _map_tag_row, and _build_repetition_rule. Affected columns (absent in older schemas): Task: datePlanned, effectiveDatePlanned, repetitionScheduleTypeString, catchUpAutomatically, repetitionAnchorDateKey Context: allowsNextAction, childrenAreMutuallyExclusive, parent Fix: introduce two small helpers in hybrid.py: - _get_table_columns(conn, table): introspects PRAGMA table_info to detect which columns exist (available for future use) - _row_get(row, key, default=None): safe sqlite3.Row accessor that returns a default instead of raising IndexError when a column is absent Replace all direct row[key] accesses for the affected columns with _row_get() calls, with appropriate defaults: - datePlanned / effectiveDatePlanned → None - repetitionScheduleTypeString → None - catchUpAutomatically → False - repetitionAnchorDateKey → None (→ 'due_date' via _ANCHOR_DATE_MAP) - allowsNextAction → True (permissive default) - childrenAreMutuallyExclusive → False Add TestOlderSchemaCompatibility with three tests that create an in-memory SQLite DB omitting the newer columns and assert that list_tasks, list_tags, and get_all all succeed and return sensible values (planned_date=None, children_are_mutually_exclusive=False). --- .../repository/hybrid/hybrid.py | 53 +++++-- tests/test_hybrid_repository.py | 149 ++++++++++++++++++ 2 files changed, 192 insertions(+), 10 deletions(-) diff --git a/src/omnifocus_operator/repository/hybrid/hybrid.py b/src/omnifocus_operator/repository/hybrid/hybrid.py index 10b1a2e6..ded2007d 100644 --- a/src/omnifocus_operator/repository/hybrid/hybrid.py +++ b/src/omnifocus_operator/repository/hybrid/hybrid.py @@ -100,6 +100,37 @@ _TASK_TO_TAG_SQL = "SELECT task, tag FROM TaskToTag" +# -- Schema compatibility helpers -- + + +def _get_table_columns(conn: sqlite3.Connection, table: str) -> frozenset[str]: + """Return the set of column names for *table* in this database. + + Used to detect older OmniFocus database schemas that are missing columns + added in newer versions (e.g. ``datePlanned``, ``repetitionScheduleTypeString``). + Returns an empty frozenset if the table does not exist. + """ + try: + rows = conn.execute(f"PRAGMA table_info({table})").fetchall() + return frozenset(row[1] for row in rows) + except sqlite3.OperationalError: + return frozenset() + + +def _row_get(row: sqlite3.Row, key: str, default: Any = None) -> Any: + """Safely retrieve *key* from a sqlite3.Row, returning *default* if absent. + + OmniFocus adds new columns in database schema upgrades. Older installs + (e.g. those on macOS versions that cannot run the latest OmniFocus) will + not have these columns. Using this helper instead of ``row[key]`` prevents + ``IndexError: No item with that key`` crashes on those schemas. + """ + try: + return row[key] + except IndexError: + return default + + # -- Timezone -- @@ -237,9 +268,9 @@ def _build_repetition_rule(row: sqlite3.Row) -> dict[str, Any] | None: rule_string = row["repetitionRuleString"] if not rule_string: return None - schedule_type_raw = row["repetitionScheduleTypeString"] - catch_up = bool(row["catchUpAutomatically"]) - anchor_key = _ANCHOR_DATE_MAP.get(row["repetitionAnchorDateKey"], "due_date") + schedule_type_raw = _row_get(row, "repetitionScheduleTypeString") + catch_up = bool(_row_get(row, "catchUpAutomatically", False)) + anchor_key = _ANCHOR_DATE_MAP.get(_row_get(row, "repetitionAnchorDateKey"), "due_date") schedule_type = _SCHEDULE_TYPE_MAP.get(schedule_type_raw, schedule_type_raw) frequency = parse_rrule(rule_string) @@ -429,8 +460,8 @@ def _map_task_row( "inherited_completion_date": _parse_timestamp(row["effectiveDateCompleted"]), "drop_date": _parse_timestamp(row["dateHidden"]), "inherited_drop_date": _parse_timestamp(row["effectiveDateHidden"]), - "planned_date": _parse_local_datetime(row["datePlanned"]), - "inherited_planned_date": _parse_timestamp(row["effectiveDatePlanned"]), + "planned_date": _parse_local_datetime(_row_get(row, "datePlanned")), + "inherited_planned_date": _parse_timestamp(_row_get(row, "effectiveDatePlanned")), "estimated_minutes": row["estimatedMinutes"], "has_children": (row["childrenCount"] or 0) > 0, # Phase 56-02: CACHE-01/02/04 reads from Task/Attachment. @@ -488,7 +519,7 @@ def _map_project_row( "defer_date": _parse_local_datetime(row["dateToStart"]), "completion_date": _parse_timestamp(row["dateCompleted"]), "drop_date": _parse_timestamp(row["dateHidden"]), - "planned_date": _parse_local_datetime(row["datePlanned"]), + "planned_date": _parse_local_datetime(_row_get(row, "datePlanned")), "estimated_minutes": row["estimatedMinutes"], "has_children": (row["childrenCount"] or 0) > 0, # Phase 56-02: CACHE-01/02/03/04 reads on projects. @@ -534,13 +565,15 @@ def _map_tag_row(row: sqlite3.Row, tag_name_lookup: dict[str, str]) -> dict[str, "added": _parse_timestamp(row["dateAdded"]), "modified": _parse_timestamp(row["dateModified"]), "availability": _map_tag_availability( - allows_next_action=row["allowsNextAction"], - date_hidden=row["dateHidden"], + allows_next_action=_row_get(row, "allowsNextAction", True), + date_hidden=_row_get(row, "dateHidden"), + ), + "children_are_mutually_exclusive": bool( + _row_get(row, "childrenAreMutuallyExclusive", False) ), - "children_are_mutually_exclusive": bool(row["childrenAreMutuallyExclusive"]), "parent": ( {"id": row["parent"], "name": tag_name_lookup.get(row["parent"], "")} - if row["parent"] is not None + if _row_get(row, "parent") is not None else None ), } diff --git a/tests/test_hybrid_repository.py b/tests/test_hybrid_repository.py index 8bd66c7d..b7b128cf 100644 --- a/tests/test_hybrid_repository.py +++ b/tests/test_hybrid_repository.py @@ -3798,3 +3798,152 @@ async def test_get_all_returns_tasks_in_outline_order( assert tasks_by_name["Child of First"].order == "1.1" assert tasks_by_name["Second"].order == "2" assert tasks_by_name["Inbox Task"].order == "1" + + +class TestOlderSchemaCompatibility: + """Verify graceful handling of older OmniFocus database schemas. + + OmniFocus adds new columns across database schema versions. Users on older + macOS releases may be stuck on an earlier schema that lacks columns such as + ``datePlanned``, ``repetitionScheduleTypeString``, ``catchUpAutomatically``, + ``repetitionAnchorDateKey``, ``allowsNextAction``, and + ``childrenAreMutuallyExclusive``. + + The repository must not raise ``IndexError: No item with that key`` when + those columns are absent; it should fall back to ``None`` / sensible + defaults instead. + """ + + # A CF-epoch float corresponding to a fixed timestamp used in seeded rows. + _CF_NOW = _cf_epoch(datetime(2024, 6, 1, 12, 0, 0, tzinfo=UTC)) + + def _create_older_schema_db(self, tmp_path: Path) -> Path: + """Create a minimal SQLite DB that omits columns added in newer OmniFocus schemas. + + Intentionally absent columns (to simulate an older OmniFocus DB): + Task: datePlanned, effectiveDatePlanned, + repetitionScheduleTypeString, catchUpAutomatically, + repetitionAnchorDateKey + Context: allowsNextAction, childrenAreMutuallyExclusive, parent + """ + db_path = tmp_path / "older_schema.db" + conn = sqlite3.connect(str(db_path)) + try: + conn.executescript(""" + CREATE TABLE Task ( + persistentIdentifier TEXT PRIMARY KEY, + name TEXT, + dateAdded REAL NOT NULL, + dateModified REAL NOT NULL, + plainTextNote TEXT, + flagged INTEGER DEFAULT 0, + effectiveFlagged INTEGER DEFAULT 0, + dateDue TEXT, + dateToStart TEXT, + effectiveDateDue TEXT, + effectiveDateToStart TEXT, + dateCompleted REAL, + effectiveDateCompleted REAL, + dateHidden REAL, + effectiveDateHidden REAL, + estimatedMinutes REAL, + childrenCount INTEGER DEFAULT 0, + inInbox INTEGER DEFAULT 0, + containingProjectInfo TEXT, + parent TEXT, + overdue INTEGER DEFAULT 0, + dueSoon INTEGER DEFAULT 0, + blocked INTEGER DEFAULT 0, + repetitionRuleString TEXT, + rank INTEGER DEFAULT 0, + completeWhenChildrenComplete INTEGER DEFAULT 1, + sequential INTEGER DEFAULT 0 + ); + + CREATE TABLE ProjectInfo ( + pk TEXT PRIMARY KEY, + task TEXT, + lastReviewDate REAL, + nextReviewDate REAL, + reviewRepetitionString TEXT, + nextTask TEXT, + folder TEXT, + effectiveStatus TEXT, + containsSingletonActions INTEGER DEFAULT 0 + ); + + CREATE TABLE Context ( + persistentIdentifier TEXT PRIMARY KEY, + name TEXT, + dateAdded REAL NOT NULL, + dateModified REAL NOT NULL, + dateHidden REAL + ); + + CREATE TABLE Folder ( + persistentIdentifier TEXT PRIMARY KEY, + name TEXT, + dateAdded REAL NOT NULL, + dateModified REAL NOT NULL, + parent TEXT, + rank INTEGER DEFAULT 0 + ); + + CREATE TABLE TaskToTag (task TEXT, tag TEXT); + CREATE TABLE Attachment (task TEXT); + CREATE TABLE Perspective (persistentIdentifier TEXT PRIMARY KEY, plist BLOB); + """) + + conn.execute( + """INSERT INTO Task + (persistentIdentifier, name, dateAdded, dateModified, inInbox, rank) + VALUES ('task-1', 'A waiting task', ?, ?, 1, 0)""", + (self._CF_NOW, self._CF_NOW), + ) + + conn.execute( + """INSERT INTO Context + (persistentIdentifier, name, dateAdded, dateModified) + VALUES ('tag-1', 'Waiting', ?, ?)""", + (self._CF_NOW, self._CF_NOW), + ) + + conn.commit() + finally: + conn.close() + return db_path + + @pytest.mark.asyncio + async def test_list_tasks_succeeds_on_older_schema(self, tmp_path: Path) -> None: + """list_tasks must not raise IndexError when datePlanned and related columns are absent.""" + db_path = self._create_older_schema_db(tmp_path) + repo = HybridRepository(bridge=StubBridge(), db_path=str(db_path)) + query = ListTasksRepoQuery(availability=["available", "blocked"]) + result = await repo.list_tasks(query) + assert result.items, "Expected at least one task from the older-schema DB" + task = result.items[0] + assert task.name == "A waiting task" + assert task.planned_date is None # Column absent → graceful None + + @pytest.mark.asyncio + async def test_list_tags_succeeds_on_older_schema(self, tmp_path: Path) -> None: + """list_tags must not raise IndexError when allowsNextAction / + childrenAreMutuallyExclusive are absent.""" + db_path = self._create_older_schema_db(tmp_path) + repo = HybridRepository(bridge=StubBridge(), db_path=str(db_path)) + query = ListTagsRepoQuery() + result = await repo.list_tags(query) + assert result.items, "Expected at least one tag from the older-schema DB" + tag = result.items[0] + assert tag.name == "Waiting" + assert tag.children_are_mutually_exclusive is False # Column absent → default False + + @pytest.mark.asyncio + async def test_get_all_succeeds_on_older_schema(self, tmp_path: Path) -> None: + """get_all must not raise IndexError on an older schema missing multiple columns.""" + db_path = self._create_older_schema_db(tmp_path) + repo = HybridRepository(bridge=StubBridge(), db_path=str(db_path)) + result = await repo.get_all() + assert isinstance(result, AllEntities) + task_names = [t.name for t in result.tasks] + assert "A waiting task" in task_names