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
319 lines
14 KiB
Python
319 lines
14 KiB
Python
"""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,
|
|
)
|