stack: Provider-Schicht für Aufgaben-Tracker mit Super-Productivity-Adapter (#124)
CI / verify (push) Successful in 48s
Release / release (push) Successful in 35s

Files changed:
- .gitignore
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/config.py
- tools/chemenu/errors.py
- tools/chemenu/tasks/__init__.py
- tools/chemenu/tasks/config.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
- tools/chemenu/tests/test_tasks_protocol.py
This commit is contained in:
torben committed 2026-09-19 21:46:56 +02:00
1 parent ee24b6e5b8
commit 1875449b31
16 files changed
+1193 -6

No files matched your search

+318
View File
@@ -0,0 +1,318 @@
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30).
Read and write deliberately use different transports (#124's own design note,
carried as its first acceptance criterion): the read path is headless file
access, the write path is Super Productivity's own local REST API - so a
review can run with the app closed, and a create only needs the app open when
it actually has to ask it for something.
## Read path: the backup snapshot, not a live `db.json`
Desktop Super Productivity keeps its live state in IndexedDB, not in a flat
file called `db.json` on disk - there is no such file to read headlessly.
What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
writes the complete app state as `JSON.stringify(data)` into
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last
by filename (its own comment: "timestamps sort lexically"). That snapshot's
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy`
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`),
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only
looks at the three keys it needs, each an `@ngrx/entity` `EntityState`
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts`
`Task`/`Project`/`Tag`). Verified against the `master` branch of
`super-productivity/super-productivity` on 2026-09-19 - unversioned, per
#124's own note, so a future release is free to reshape it without warning,
which is exactly why every read below fails loudly on a shape it does not
recognize rather than guessing.
## The two GTD conventions this adapter encodes (#119 D9/D30)
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
Productivity has no native field for either:
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
task. Any other tag is left alone.
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) -
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full
stop; this adapter never substitutes the due date for it.
## Someday/Maybe: a project's own backlog
#124 (later, #119) left this as "zu verifizieren": Super Productivity's
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of
task ids per project, apart from the active `taskIds` list a project's board
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one
project at a time; no tag convention is needed.
## Write path: no project-creation endpoint exists
The local REST API (`electron/local-rest-api-handler.service.ts`, verified the
same day) routes `GET /projects` but has no `POST /projects` at all - task
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project`
can therefore not create a project itself; see its docstring and
`chemenu.errors.HumanInterventionRequired`.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Optional
from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks.protocol import (
OpenItems,
ProjectSummary,
SomedayItem,
WaitingItem,
find_project,
normalize_project_name,
)
# The tag title that means "WAITING" (#119 D9/D30), matched case-insensitively
# - this instance's own convention, not something Super Productivity defines.
WAITING_TAG_TITLE = "waiting"
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
_EXPECTED_PROVIDER_CONFIG = (
'{"backups_dir": "~/.config/superProductivity/backups", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
' (or "db_path" instead of "backups_dir" to pin one exact file)'
)
@dataclass(frozen=True)
class SuperProductivityConfig:
"""This provider's own section of `.wikitool-tasks.json`
(`TasksConfig.provider_config`)."""
# Exactly one of these two names where to read from. `db_path` wins when
# both are set - it names one exact file, which is a stronger statement
# than "the newest file in this directory".
backups_dir: Optional[Path]
db_path: Optional[Path]
api_base_url: str
# Unused by this module today - nothing it calls needs authentication
# (`GET /health` is the one unauthenticated exception, and there is no
# write call at all, see the module docstring). Carried through anyway so
# a future capability that does need it does not require a config-shape
# migration to add it.
api_token: Optional[str]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
if not isinstance(data, dict):
raise ValidationError(
f"superproductivity config must be an object. Expected: {_EXPECTED_PROVIDER_CONFIG}"
)
backups_dir = data.get("backups_dir")
db_path = data.get("db_path")
if backups_dir is None and db_path is None:
raise ValidationError(
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
)
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
api_token = data.get("api_token")
if api_token is not None and not isinstance(api_token, str):
raise ValidationError("superproductivity config: api_token must be a string.")
return cls(
backups_dir=Path(backups_dir).expanduser() if backups_dir else None,
db_path=Path(db_path).expanduser() if db_path else None,
api_base_url=api_base_url,
api_token=api_token,
)
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
"""The one file to read: `db_path` if given, else the lexically-greatest
`*.json` filename under `backups_dir` - the same ordering
`electron/backup.ts` itself relies on ("timestamps sort lexically")."""
if cfg.db_path is not None:
if not cfg.db_path.is_file():
raise ValidationError(
f"superproductivity: db_path does not exist: {cfg.db_path}"
)
return cfg.db_path
directory = cfg.backups_dir
assert directory is not None # from_dict guarantees at least one is set
if not directory.is_dir():
raise ValidationError(
f"superproductivity: backups_dir does not exist: {directory}"
)
candidates = sorted(directory.glob("*.json"))
if not candidates:
raise ValidationError(
f"superproductivity: no *.json backup file found under {directory}. Take a "
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), "
"or point db_path/backups_dir at where it actually writes them."
)
return candidates[-1]
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
path = latest_snapshot_path(cfg)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValidationError(f"superproductivity: cannot read {path} ({exc}).") from exc
if not isinstance(data, dict):
raise ValidationError(
f"superproductivity: {path} does not contain a JSON object at its top level."
)
return data
def _entity_state(snapshot: dict[str, Any], key: str, source: Path) -> dict[str, dict]:
"""`snapshot[key]` as an `@ngrx/entity` `{"ids": [...], "entities": {...}}`
map, or a loud `ValidationError` naming exactly what was expected - Super
Productivity's internal model is unversioned (module docstring), so a
shape drift here is expected eventually, not a bug in this adapter."""
value = snapshot.get(key)
if (
not isinstance(value, dict)
or not isinstance(value.get("ids"), list)
or not isinstance(value.get("entities"), dict)
):
raise ValidationError(
f"superproductivity: {source} has no usable '{key}' entity state "
f"({{\"ids\": [...], \"entities\": {{...}}}}). Its internal shape is unversioned and "
"this file may be from a Super Productivity version this adapter does not know - "
f"found: {type(value).__name__ if value is not None else 'missing'}."
)
return value["entities"]
def _epoch_ms_to_date(value: Any) -> Optional[date]:
if not isinstance(value, (int, float)):
return None
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date()
class SuperProductivityReader:
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
for why that matters."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def _read(self) -> tuple[Path, dict[str, dict], dict[str, dict], dict[str, dict]]:
path = latest_snapshot_path(self._cfg)
snapshot = _load_snapshot(self._cfg)
projects = _entity_state(snapshot, "project", path)
tasks = _entity_state(snapshot, "task", path)
tags = _entity_state(snapshot, "tag", path)
return path, projects, tasks, tags
def projects(self) -> list[ProjectSummary]:
_, projects, _, _ = self._read()
return [
ProjectSummary(
name=str(record.get("title", "")),
created=_epoch_ms_to_date(record.get("created")),
)
for record in projects.values()
]
def _is_waiting(self, task: dict, tags: dict[str, dict]) -> bool:
for tag_id in task.get("tagIds") or []:
tag = tags.get(tag_id)
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
return True
return False
def open_items(self, project_name: str) -> OpenItems:
_, projects, tasks, tags = self._read()
target = normalize_project_name(project_name)
project = next(
(p for p in projects.values() if normalize_project_name(str(p.get("title", ""))) == target),
None,
)
if project is None:
return OpenItems(count=0, waiting=())
waiting: list[WaitingItem] = []
count = 0
for task_id in project.get("taskIds") or []:
task = tasks.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
if self._is_waiting(task, tags):
waiting.append(
WaitingItem(
title=str(task.get("title", "")),
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
)
)
return OpenItems(count=count, waiting=tuple(waiting))
def someday_items(self) -> list[SomedayItem]:
_, projects, tasks, _ = self._read()
items: list[SomedayItem] = []
for project in projects.values():
for task_id in project.get("backlogTaskIds") or []:
task = tasks.get(task_id)
if task is None or task.get("isDone"):
continue
items.append(
SomedayItem(
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
)
return items
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
"""Whether the local REST API answers `GET /health` right now - the one
unauthenticated endpoint (module docstring). Never raises: an unreachable
app is an ordinary, expected state (`doctor` reports it, it does not
FAIL), not a defect in this adapter."""
url = cfg.api_base_url.rstrip("/") + "/health"
try:
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - localhost only
return 200 <= response.status < 300
except (urllib.error.URLError, OSError, ValueError):
return False
class SuperProductivityWriter:
"""`TaskWriter` over the local REST API - except there is no API call
this can actually make, see the module docstring."""
def __init__(self, cfg: SuperProductivityConfig, reader: SuperProductivityReader):
self._cfg = cfg
self._reader = reader
def create_project(self, name: str) -> None:
"""Never creates anything. Preflights the name against the read path
(#119 D8) and, if it is free, raises `HumanInterventionRequired`
naming the one thing a human must do - Super Productivity's local
REST API has no project-creation endpoint at all (module docstring),
so this is not a missing feature in this adapter, it is a missing
endpoint upstream."""
existing = find_project(self._reader, name)
if existing is not None:
raise ValidationError(
f"A project named '{name}' (case-insensitively) already exists in "
"Super Productivity - nothing was created."
)
def _verify() -> bool:
return find_project(self._reader, name) is not None
raise HumanInterventionRequired(
"Super Productivity's local REST API has no project-creation endpoint "
"(only GET /projects) - this cannot be automated.\n"
f" 1. Open Super Productivity.\n"
f" 2. Create a project named exactly: {name}\n"
" 3. Tell the agent you have done this, so it can re-check and continue.",
verify=_verify,
)