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
700 lines
32 KiB
Python
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."
|
|
)
|