SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
CI / verify (push) Successful in 50s
Release / release (push) Successful in 36s

Files changed:
- CHANGES.md
- INSTALL.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/review_cmd.py
- tools/chemenu/review.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_new_page.py
- tools/chemenu/tests/test_review.py
- tools/chemenu/tests/test_superproductivity.py
- tools/chemenu/tests/test_tasks_config.py
This commit is contained in:
torben committed 2026-09-20 20:52:05 +02:00
1 parent 3c1d4cb028
commit e07d1ca42a
17 files changed
+1112 -239

No files matched your search

+20 -2
View File
@@ -24,11 +24,16 @@ from chemenu.tasks.protocol import TaskReader, TaskWriter
def build_reader(cfg: TasksConfig) -> TaskReader:
"""Dispatch on `cfg.provider` to a concrete `TaskReader`."""
"""Dispatch on `cfg.provider` to a concrete `TaskReader`. For
`superproductivity` the concrete class also depends on
`access` (Gitea #133): `"api"` reads the live local REST API,
`"snapshot"` reads the backup file - never both, never a fallback."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
if sp_cfg.access == sp.ACCESS_API:
return sp.SuperProductivityApiReader(sp_cfg)
return sp.SuperProductivityReader(sp_cfg)
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
@@ -37,10 +42,23 @@ def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter:
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
already-built `reader` - a writer that needs to re-check the read path
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through
the same object its caller does, rather than opening a second one."""
the same object its caller does, rather than opening a second one.
For `superproductivity`, a writer exists only when `access: "api"`
(Gitea #133): on `access: "snapshot"` the tracker is read-only from here
by construction, so this raises `ValidationError` rather than returning a
writer that could never do anything - the same posture as "no writer is
wired up for this provider at all", just scoped to one access mode of
one provider instead of the whole provider."""
if cfg.provider == "superproductivity":
from chemenu.tasks import superproductivity as sp
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
if sp_cfg.access != sp.ACCESS_API:
raise ValidationError(
"superproductivity: the tracker is read-only from here (access: "
f"'{sp_cfg.access}') - the write path only exists on an access: 'api' "
"instance (Gitea #133)."
)
return sp.SuperProductivityWriter(sp_cfg, reader)
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
+3 -2
View File
@@ -66,8 +66,9 @@ def read_config(root: "Any") -> "TasksConfig | None":
expected = (
'{"schema": 1, "provider": "superproductivity", '
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
'"someday_stale_months": 5}, "superproductivity": {"backups_dir": "...", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}}'
'"someday_stale_months": 5}, "superproductivity": {"access": "api", '
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."} '
'(or {"access": "snapshot", "backups_dir": "..."} - see INSTALL.md)}'
)
try:
provider = str(data["provider"])
+46 -1
View File
@@ -15,7 +15,19 @@ waiting-for item; the person stays in the title's free text), and which
someday/maybe items exist and when they last moved. None of this is cached
here - a `TaskReader` re-reads on every call, so a caller checking `verify()`
after a human's out-of-band step (see `chemenu.errors.HumanInterventionRequired`)
sees the current state, not a snapshot from before that step.
never sees a value this process cached from before that step.
**Re-reading is not the same as reading the current state** (Gitea #134,
resolved by #133's design rather than by a fix here): a point-in-time source
- Super Productivity's `access: "snapshot"`, say - re-reads the *latest file
on disk* on every call, which is only as current as that file's own age; a
caller's `verify()` can still answer "not yet" against a step that already
happened, if nothing has written a fresher file since. Only a genuinely live
source - `access: "api"` - re-reads the actual current state. A `TaskReader`
that is not always live should say so through `source()`
(`chemenu.tasks.protocol.ReadSource`), so a caller can tell "re-read, but
possibly stale" apart from "re-read, and current" instead of assuming the
stronger claim for every provider.
"""
from __future__ import annotations
@@ -43,12 +55,36 @@ class WaitingItem:
Faelligkeitsdatum") - a provider that has no separate concept for this
must not fall back to reusing the due date, it must decide it cannot
supply the field and leave it `None` instead.
**This rule binds the concept, not a field's name** (Gitea #135's own
correction, after #124's Super Productivity adapter read the wrong field
under this exact rule): a provider whose own vocabulary does not line up
with "due date" - a field called `due*` that actually means scheduling
rather than a deadline, say - must be checked against what its
documentation says the field *means*, not against what its name suggests
to an outsider. Getting this backwards produced a real bug: a whole class
of tickler (an all-day, notification-free follow-up) silently never
counted as `follow_up_at` at all.
"""
title: str
follow_up_at: Optional[date]
@dataclass(frozen=True)
class ReadSource:
"""Where one `TaskReader.source()` call's data came from, for display
only (Gitea #133) - `chemenu.review`/`wikitool doctor` show it, no check
branches on it. `kind` is provider-defined (e.g. `"api"`/`"snapshot"` for
Super Productivity); `detail` is the human-readable line, which for a
point-in-time source (a snapshot file, not a live call) names its age -
the two access paths can live on different machines and nobody may ever
see them side by side, so the answer itself has to say how fresh it is."""
kind: str
detail: str
@dataclass(frozen=True)
class OpenItems:
"""A project's momentary open-loop count, plus the subset that is
@@ -99,6 +135,15 @@ class TaskReader(Protocol):
projects."""
...
def source(self) -> ReadSource:
"""Where this reader's data comes from, for display (Gitea #133) -
never consulted by a check, only by `chemenu.review`/`wikitool doctor`
to say which path answered and, for a point-in-time source, how old
it is. Must not perform a network call beyond what answering it
cheaply requires - a provider whose read path is always live can
answer this without touching the network at all."""
...
class TaskWriter(Protocol):
"""The write path a provider adapter offers only if it actually can
+319 -93
View File
@@ -1,57 +1,87 @@
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30).
"""The Super Productivity adapter (Gitea #124, #133, #135; #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 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.
## Read path: the backup snapshot, not a live `db.json`
## `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/<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.
`<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.
## The two GTD conventions this adapter encodes (#119 D9/D30)
## `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 `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.
- **`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
#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.
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
## Write path: no project-creation endpoint exists, on either access mode
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`.
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).
"""
from __future__ import annotations
@@ -67,6 +97,7 @@ from chemenu.errors import HumanInterventionRequired, ValidationError
from chemenu.tasks.protocol import (
OpenItems,
ProjectSummary,
ReadSource,
SomedayItem,
WaitingItem,
find_project,
@@ -79,82 +110,119 @@ 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)'
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`)."""
(`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."""
# 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".
access: str
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}"
"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")
db_path = data.get("db_path")
if backups_dir is None and db_path is None:
if not backups_dir:
raise ValidationError(
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
"superproductivity config: backups_dir is required when access: 'snapshot' - "
f"the read path has nothing to read otherwise. Expected: {_EXPECTED_SNAPSHOT_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,
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 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
"""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 at least one is set
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("*.json"))
candidates = sorted(directory.glob(_SNAPSHOT_GLOB))
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."
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:
@@ -194,10 +262,37 @@ def _epoch_ms_to_date(value: Any) -> Optional[date]:
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. Re-reads the
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
for why that matters."""
"""`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
@@ -208,6 +303,11 @@ class SuperProductivityReader:
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]:
@@ -220,13 +320,6 @@ class SuperProductivityReader:
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)
@@ -244,12 +337,9 @@ class SuperProductivityReader:
if task is None or task.get("isDone"):
continue
count += 1
if self._is_waiting(task, tags):
if _is_waiting(task, tags):
waiting.append(
WaitingItem(
title=str(task.get("title", "")),
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
)
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
)
return OpenItems(count=count, waiting=tuple(waiting))
@@ -269,6 +359,14 @@ class SuperProductivityReader:
)
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
@@ -283,11 +381,139 @@ def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
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` uses - a thin,
loudly-failing wrapper, not a general REST client."""
def __init__(self, cfg: SuperProductivityConfig):
self._cfg = cfg
def get(self, path: str, *, timeout: float = 10.0) -> Any:
url = self._cfg.api_base_url.rstrip("/") + path
request = urllib.request.Request(
url, headers={"Authorization": f"Bearer {self._cfg.api_token}"}
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
body = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 503:
raise ValidationError(
"superproductivity: API answered 503 APP_NOT_READY for "
f"{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 {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(body)
except json.JSONDecodeError as exc:
raise ValidationError(
f"superproductivity: API returned unparseable JSON for {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=())
waiting: list[WaitingItem] = []
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
if _is_waiting(task, tags_by_id):
waiting.append(
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
)
return OpenItems(count=count, waiting=tuple(waiting))
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(
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 - except there is no API call
this can actually make, see the module docstring."""
this can actually make, see the module docstring. 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: SuperProductivityReader):
def __init__(self, cfg: SuperProductivityConfig, reader):
self._cfg = cfg
self._reader = reader