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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions src/omnifocus_operator/repository/hybrid/hybrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
),
}
Expand Down
149 changes: 149 additions & 0 deletions tests/test_hybrid_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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