Files
chemenu/tools/chemenu/tasks/superproductivity.py
T
torben 62d1c5e636
CI / verify (push) Successful in 54s
Release / release (push) Successful in 36s
task: Weekly review proposes task new/task close; tracker gains a closing write path
Files changed:
- CHANGES.md
- INSTALL.md
- README.md
- VERSION
- docs/knowledge-and-commitment.md
- instructions/gtd-weekly-review/SKILL.md
- instructions/ingest-large-tree.md
- instructions/wiki-ingest/SKILL.md
- tools/CONTRACT.md
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/commands/task_cmd.py
- tools/chemenu/review.py
- tools/chemenu/tasks/protocol.py
- tools/chemenu/tasks/superproductivity.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_task_cmd.py
2026-09-22 21:46:09 +02:00

700 lines
32 KiB
Python

"""The Super Productivity adapter (Gitea #124, #133, #135; #119 D2/D8/D9/D30).
Read and write access are chosen **per instance, explicitly, exclusively**
(Gitea #133): a headless-operated instance sets `access: "snapshot"` and only
ever reads the periodic backup file on disk; a desktop instance sets
`access: "api"` and only ever talks to Super Productivity's own local REST
API, which also carries the current live state and the one write call this
adapter offers. There is no third value, no default, and no runtime fallback
between the two - the config decides once, at read time, which half of this
module ever runs.
## `access: "snapshot"` - the backup file, 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/YYYY-MM-DD_HHmmss.json` on every backup - a fixed-width
timestamp name, so a lexical sort is also a chronological one, which is what
`latest_snapshot_path` relies on. 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`). Unversioned, per
#124's own note, so a future release is free to reshape it without warning -
every read below fails loudly on a shape it does not recognize rather than
guessing.
## `access: "api"` - the local REST API, the current live state
The routes live in the renderer, not the Electron main process:
`src/app/core/electron/local-rest-api-handler.service.ts` registers
`GET /status`, `GET /focus`, `GET|POST /task-control/*`, `GET|POST /tasks`,
`GET|PATCH|DELETE /tasks/:id`, `GET /projects`, `GET /tags` - project and tag
CRUD do not exist. Every endpoint but `GET /health` requires
`Authorization: Bearer <api_token>`; a closed app or an unauthenticated
request are not the same failure - `GET /health` answers `503 APP_NOT_READY`
when the backend is up but the renderer is not yet, which this adapter
surfaces as its own message rather than folding into "unreachable".
`GET /projects` runs through `selectUnarchivedProjects` and excludes
`isArchived` projects server-side; the snapshot path below does the same
filtering itself, so the two access paths agree on that without either one
needing to know how the other got there (verified against `master`,
2026-09-20).
## The two GTD conventions this adapter encodes (#119 D9/D30, corrected #135)
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 *scheduled* date - `dueWithTime` if
set, else `dueDay` (`task.model.ts`'s own read rule: "check dueWithTime
FIRST"). This is **not** the earlier `remindAt` mapping from #124: `remindAt`
only exists when a task is scheduled with a specific time *and* someone
asked for a notification, so an all-day, notification-free tickler carried
no `follow_up_at` at all under that mapping - a real gap #135 closed.
`deadlineDay`/`deadlineWithTime`/`deadlineRemindAt` are Super Productivity's
actual due-date fields (its own model docstrings say so) and are never read
here (#119 D9 continues to exclude them) - the point of #135's correction
is that `due*` was never the thing D9 excludes, whatever the name suggests.
A task with neither `dueWithTime` nor `dueDay` has no `follow_up_at`, full
stop; this adapter never substitutes the deadline for it.
## Someday/Maybe: a project's own backlog
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, on either access mode
Neither transport routes `POST /projects` - task CRUD exists, project CRUD
does not, verified the same day as the rest of this module. So
`SuperProductivityWriter.create_project` can never create a project itself
regardless of `access`; see its docstring and
`chemenu.errors.HumanInterventionRequired`. A writer is offered at all only
when `access: "api"` - see `chemenu.tasks.build_writer` - because on
`access: "snapshot"` the tracker is read-only from here by construction, not
by an extra check bolted onto this module (Gitea #133).
## Closing an item: `PATCH /tasks/:id` with `isDone: true`, nothing else
Verified against `super-productivity/super-productivity`'s `master` branch
(Gitea #138, 2026-09-22): `local-rest-api-handler.service.ts` routes
`PATCH /tasks/:id` through `pickAllowedFields`/`validateWritableFields` and
then a single `this._taskService.update(taskId, changes)` call - the exact
path `TaskService.setDone(id)` itself takes
(`update(id, { isDone: true })`), with no special-casing of `isDone` in
either the service or the task reducer. Concretely:
- `isDone` is in `ALLOWED_TASK_FIELDS`, so the route accepts it.
- Marking a task done through this API is **bit-identical** to the UI's own
checkbox: neither sets `doneOn` or any other field - `TaskCopy.doneOn`
exists on the model but nothing in `setDone`'s own call path writes it, so
a task closed here looks exactly like one a human clicked done on, not a
half-written state with a missing timestamp the UI would have set.
- An unknown task id makes the same handler return `404 TASK_NOT_FOUND`
before any write happens, which this module's `_ApiClient` already turns
into an ordinary `ValidationError` - no separate existence preflight is
needed for `close_item` to write nothing on a bad id.
`DELETE /tasks/:id` also exists on this API but is never called by this
module (Gitea #138 E7): a tracker item this adapter can create, it can only
ever mark done, never remove - the reversible half of the write surface, not
the irreversible one.
"""
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 (
OpenItem,
OpenItems,
ProjectSummary,
ReadSource,
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"
# Super Productivity's own inbox project id, verified against
# `project.const.ts`/`project.selectors.ts` on `master` (Gitea #132, 2026-09-20):
# a real project entity the store adds to itself if missing
# (`_addInboxProjectIfNecessary`), but `selectUnarchivedProjects` filters it out
# unconditionally by this exact id - so it never appears in `GET /projects`
# (nor in the snapshot path's own `project` entity state, which mirrors that
# filtering, module docstring). `create_item`'s `--inbox` route is the only
# place this module ever writes it; because of the same filter, an item filed
# there is invisible to every `chemenu.review` check that walks
# `TaskReader.projects()` - "Inbox" never appears as a project name to join
# against, not merely one this instance chooses to exclude.
INBOX_PROJECT_ID = "INBOX_PROJECT"
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
ACCESS_API = "api"
ACCESS_SNAPSHOT = "snapshot"
KNOWN_ACCESS = (ACCESS_API, ACCESS_SNAPSHOT)
# `electron/backup.ts` writes exactly this shape - a fixed-width timestamp,
# no prefix. Restricting the glob to it (Gitea #133) is what keeps a manually
# exported file (`sp-backup_*.json` and friends, which sort *after* every
# timestamp lexically) from ever being picked as "newest".
_SNAPSHOT_GLOB = (
"[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]_"
"[0-9][0-9][0-9][0-9][0-9][0-9].json"
)
_EXPECTED_API_CONFIG = '{"access": "api", "api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
_EXPECTED_SNAPSHOT_CONFIG = '{"access": "snapshot", "backups_dir": "~/.config/superProductivity/backups"}'
@dataclass(frozen=True)
class SuperProductivityConfig:
"""This provider's own section of `.wikitool-tasks.json`
(`TasksConfig.provider_config`). `access` decides both halves at once -
which path is read *and* whether a write path exists at all - and the
section carries only the fields that access path uses (Gitea #133): a
`snapshot` config with an `api_base_url` in it, or an `api` config with a
`backups_dir` in it, is rejected at read time, not ignored."""
access: str
backups_dir: Optional[Path]
api_base_url: str
api_token: Optional[str]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
if not isinstance(data, dict):
raise ValidationError(
"superproductivity config must be an object. Expected one of: "
f"{_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
)
access = data.get("access")
if access not in KNOWN_ACCESS:
raise ValidationError(
"superproductivity config needs 'access', either 'api' or 'snapshot' - "
"required, no default and no fallback between them (Gitea #133). Got: "
f"{access!r}. Expected one of: {_EXPECTED_API_CONFIG} or {_EXPECTED_SNAPSHOT_CONFIG}"
)
if access == ACCESS_API:
extra = sorted(set(data) - {"access", "api_base_url", "api_token"})
if extra:
raise ValidationError(
f"superproductivity config: access: 'api' does not take {extra} - a "
"section names only one access path's own fields (Gitea #133). "
f"Expected: {_EXPECTED_API_CONFIG}"
)
api_token = data.get("api_token")
if not isinstance(api_token, str) or not api_token:
raise ValidationError(
"superproductivity config: api_token is required when access: 'api' - "
"every endpoint but GET /health requires Authorization: Bearer <token>. "
f"Expected: {_EXPECTED_API_CONFIG}"
)
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
return cls(access=access, backups_dir=None, api_base_url=api_base_url, api_token=api_token)
extra = sorted(set(data) - {"access", "backups_dir"})
if extra:
raise ValidationError(
f"superproductivity config: access: 'snapshot' does not take {extra} - a "
"section names only one access path's own fields (Gitea #133). "
f"Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
)
backups_dir = data.get("backups_dir")
if not backups_dir:
raise ValidationError(
"superproductivity config: backups_dir is required when access: 'snapshot' - "
f"the read path has nothing to read otherwise. Expected: {_EXPECTED_SNAPSHOT_CONFIG}"
)
return cls(
access=access,
backups_dir=Path(backups_dir).expanduser(),
api_base_url=DEFAULT_API_BASE_URL,
api_token=None,
)
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
"""The lexically-greatest `YYYY-MM-DD_HHmmss.json` filename under
`backups_dir` - the same ordering `electron/backup.ts` writes by
construction. Only files matching that exact pattern are candidates
(Gitea #133): a manual export (`sp-backup_*.json` and its variants) sorts
lexically *after* every timestamp and would otherwise pin every reader to
itself forever."""
directory = cfg.backups_dir
assert directory is not None # from_dict guarantees this for access: snapshot
if not directory.is_dir():
raise ValidationError(
f"superproductivity: backups_dir does not exist: {directory}"
)
candidates = sorted(directory.glob(_SNAPSHOT_GLOB))
if not candidates:
raise ValidationError(
f"superproductivity: no timestamped backup file (YYYY-MM-DD_HHmmss.json) found "
f"under {directory}. Take a backup from Super Productivity (Settings -> Backup & "
"Sync -> Local backups), or point backups_dir at where it actually writes them."
)
return candidates[-1]
def _snapshot_age_days(path: Path) -> int:
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
return (datetime.now(tz=timezone.utc) - mtime).days
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()
def _iso_day_to_date(value: Any) -> Optional[date]:
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _follow_up_at(task: dict) -> Optional[date]:
"""`follow_up_at` per #135's corrected mapping: `dueWithTime` first (Super
Productivity's own read rule - it takes priority over `dueDay`), else
`dueDay`. Never `deadline*` (#119 D9) and never the old `remindAt`."""
due_with_time = _epoch_ms_to_date(task.get("dueWithTime"))
if due_with_time is not None:
return due_with_time
return _iso_day_to_date(task.get("dueDay"))
def _is_waiting(task: dict, tags_by_id: dict[str, dict]) -> bool:
for tag_id in task.get("tagIds") or []:
tag = tags_by_id.get(tag_id)
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
return True
return False
class SuperProductivityReader:
"""`TaskReader` over a Super Productivity backup snapshot
(`access: "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)
# Both access paths exclude archived projects (Gitea #133) - the API
# does it server-side (`selectUnarchivedProjects`), this path mirrors
# it explicitly so the two agree without either knowing about the
# other.
projects = {pid: p for pid, p in projects.items() if not p.get("isArchived")}
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 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=(), items=())
waiting: list[WaitingItem] = []
all_items: list[OpenItem] = []
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
task_id_str = str(task.get("id", task_id))
task_title = str(task.get("title", ""))
is_waiting = _is_waiting(task, tags)
if is_waiting:
waiting.append(
WaitingItem(
id=task_id_str, title=task_title, follow_up_at=_follow_up_at(task)
)
)
all_items.append(OpenItem(id=task_id_str, title=task_title, waiting=is_waiting))
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
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(
id=str(task.get("id", task_id)),
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
)
return items
def source(self) -> ReadSource:
path = latest_snapshot_path(self._cfg)
age = _snapshot_age_days(path)
return ReadSource(
kind=ACCESS_SNAPSHOT,
detail=f"snapshot {path.name}, {age} day(s) old",
)
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
def _expect_list(value: Any, what: str) -> list[dict]:
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise ValidationError(
f"superproductivity: API {what} did not return a list of objects - the response "
"shape does not match what this adapter expects."
)
return value
class _ApiClient:
"""The one HTTP transport `SuperProductivityApiReader`/`SuperProductivityWriter`
use - a thin, loudly-failing wrapper, not a general REST client. `get` and
`post` (Gitea #132) share one request/error path, so a shape drift or a
new failure mode only needs handling once."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def get(self, path: str, *, timeout: float = 10.0) -> Any:
return self._request("GET", path, timeout=timeout)
def post(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
return self._request("POST", path, body=body, timeout=timeout)
def patch(self, path: str, body: dict, *, timeout: float = 10.0) -> Any:
return self._request("PATCH", path, body=body, timeout=timeout)
def _request(
self, method: str, path: str, *, body: Optional[dict] = None, timeout: float = 10.0
) -> Any:
url = self._cfg.api_base_url.rstrip("/") + path
headers = {"Authorization": f"Bearer {self._cfg.api_token}"}
data = None
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
response_body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 503:
raise ValidationError(
f"superproductivity: API answered 503 APP_NOT_READY for "
f"{method} {path} - the app's backend is up but its renderer is not ready "
"yet. Wait a moment and retry."
) from exc
raise ValidationError(
f"superproductivity: API returned HTTP {exc.code} for {method} {path}."
) from exc
except (urllib.error.URLError, OSError) as exc:
raise ValidationError(
f"superproductivity: API not reachable at {self._cfg.api_base_url} ({exc}). "
"Is Super Productivity running?"
) from exc
try:
return json.loads(response_body)
except json.JSONDecodeError as exc:
raise ValidationError(
f"superproductivity: API returned unparseable JSON for {method} {path}."
) from exc
class SuperProductivityApiReader:
"""`TaskReader` over the local REST API (`access: "api"`) - the current
live state, re-fetched on every call. `GET /projects` already excludes
archived projects server-side; open items are counted against
`project.taskIds` rather than `GET /tasks?projectId=`, because a subtask
inherits its parent's `projectId` and would otherwise be double-counted
against that filter (Gitea #133) - the same source of truth
`SuperProductivityReader` uses on the snapshot side."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
self._client = _ApiClient(cfg)
def _read(self) -> tuple[list[dict], dict[str, dict], dict[str, dict]]:
projects = _expect_list(self._client.get("/projects"), "/projects")
tasks = _expect_list(self._client.get("/tasks"), "/tasks")
tags = _expect_list(self._client.get("/tags"), "/tags")
# `GET /projects` already runs through `selectUnarchivedProjects`
# server-side - filtered again here so both access paths hold the
# same guarantee (Gitea #133) rather than one of them trusting the
# other end to have done it.
projects = [p for p in projects if not p.get("isArchived")]
tasks_by_id = {t["id"]: t for t in tasks if isinstance(t.get("id"), str)}
tags_by_id = {t["id"]: t for t in tags if isinstance(t.get("id"), str)}
return projects, tasks_by_id, tags_by_id
def projects(self) -> list[ProjectSummary]:
projects, _, _ = self._read()
return [
ProjectSummary(name=str(p.get("title", "")), created=_epoch_ms_to_date(p.get("created")))
for p in projects
]
def open_items(self, project_name: str) -> OpenItems:
projects, tasks_by_id, tags_by_id = self._read()
target = normalize_project_name(project_name)
project = next(
(p for p in projects if normalize_project_name(str(p.get("title", ""))) == target), None
)
if project is None:
return OpenItems(count=0, waiting=(), items=())
waiting: list[WaitingItem] = []
all_items: list[OpenItem] = []
count = 0
for task_id in project.get("taskIds") or []:
task = tasks_by_id.get(task_id)
if task is None or task.get("isDone"):
continue
count += 1
task_id_str = str(task.get("id", task_id))
task_title = str(task.get("title", ""))
is_waiting = _is_waiting(task, tags_by_id)
if is_waiting:
waiting.append(
WaitingItem(
id=task_id_str, title=task_title, follow_up_at=_follow_up_at(task)
)
)
all_items.append(OpenItem(id=task_id_str, title=task_title, waiting=is_waiting))
return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items))
def someday_items(self) -> list[SomedayItem]:
projects, tasks_by_id, _ = self._read()
items: list[SomedayItem] = []
for project in projects:
for task_id in project.get("backlogTaskIds") or []:
task = tasks_by_id.get(task_id)
if task is None or task.get("isDone"):
continue
items.append(
SomedayItem(
id=str(task.get("id", task_id)),
title=str(task.get("title", "")),
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
)
)
return items
def source(self) -> ReadSource:
# Static by construction - every call above re-fetches, so there is
# nothing "live" needs to check first, and no network call is spent
# just to answer this.
return ReadSource(kind=ACCESS_API, detail="live (local REST API)")
class SuperProductivityWriter:
"""`TaskWriter` over the local REST API. `create_project` never actually
creates anything - see the module docstring; `create_item` (Gitea #132)
does, since `POST /tasks` exists where `POST /projects` does not. Only
offered by `chemenu.tasks.build_writer` when `access: "api"` (Gitea #133)
- on `access: "snapshot"` the tracker is read-only from here, and that
refusal happens before this class is ever constructed."""
def __init__(self, cfg: SuperProductivityConfig, reader):
self._cfg = cfg
self._reader = reader
self._client = _ApiClient(cfg)
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,
)
def create_item(
self,
title: str,
*,
project_name: Optional[str],
waiting: bool = False,
follow_up_at: Optional[date] = None,
notes: Optional[str] = None,
) -> None:
"""`POST /tasks` (Gitea #132) - the endpoint `create_project` cannot
reach an equivalent of. Resolves every precondition (the target
project's own id, the `waiting` tag's own id) before making the one
write, so a missing precondition never leaves behind a half-written
item - no task without the WAITING status it was asked for."""
if project_name is None:
project_id = INBOX_PROJECT_ID
else:
if find_project(self._reader, project_name) is None:
raise ValidationError(
f"No project named '{project_name}' (case-insensitively) exists in Super "
"Productivity - this command does not create one (Gitea #132 D6). Run "
"`wikitool new project` first, or pass --inbox."
)
project_id = self._project_id(project_name)
body: dict[str, Any] = {"title": title, "projectId": project_id}
if notes:
body["notes"] = notes
if waiting:
body["tagIds"] = [self._waiting_tag_id()]
if follow_up_at is not None:
body["dueDay"] = follow_up_at.isoformat()
self._client.post("/tasks", body)
def close_item(self, item_id: str) -> None:
"""`PATCH /tasks/:id` with `{"isDone": true}` (Gitea #138) - see the
module docstring's "Closing an item" section for why this one field
is bit-identical to the UI's own done checkbox and why no existence
preflight is needed: an unknown `item_id` makes the same route
return `404 TASK_NOT_FOUND` before writing anything, which
`_ApiClient._request` already turns into a `ValidationError`. Never
sends `DELETE` - see #138 E7, marking done is the only closing write
this stack makes."""
self._client.patch(f"/tasks/{item_id}", {"isDone": True})
def _project_id(self, project_name: str) -> str:
"""Super Productivity's own id for `project_name`, read fresh from the
API. `ProjectSummary` (the protocol-level read shape every provider
shares) deliberately carries no id - not every provider has one - so
a writer that needs one reads it itself here rather than the generic
read path growing an SP-specific field for this one caller."""
target = normalize_project_name(project_name)
for record in _expect_list(self._client.get("/projects"), "/projects"):
if normalize_project_name(str(record.get("title", ""))) == target:
project_id = record.get("id")
if isinstance(project_id, str) and project_id:
return project_id
raise ValidationError(
f"superproductivity: project '{project_name}' matched the read path moments ago but "
"its API record now has no usable id - the response shape does not match what this "
"adapter expects."
)
def _waiting_tag_id(self) -> str:
"""The `waiting` tag's own id, or a loud refusal (Gitea #132's own
acceptance criterion): tags cannot be created via this API (only
`GET /tags` exists, module docstring), so a WAITING item is never
created without its status - the precondition is checked before
`POST /tasks` is ever called, not patched up after."""
for record in _expect_list(self._client.get("/tags"), "/tags"):
if str(record.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
tag_id = record.get("id")
if isinstance(tag_id, str) and tag_id:
return tag_id
raise ValidationError(
f"superproductivity: no tag named '{WAITING_TAG_TITLE}' exists - tags cannot be "
"created via the API (only GET /tags, Gitea #132). Create it in Super Productivity "
"first, then retry."
)