stack: Provider-Schicht für Aufgaben-Tracker mit Super-Productivity-Adapter (#124)
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:
1 parent
ee24b6e5b8
commit
1875449b31
16 files changed
+1193
-6
No files matched your search
@@ -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,
|
||||
)
|
||||
Reference in new issue
Block a user