SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
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:
1 parent
3c1d4cb028
commit
e07d1ca42a
17 files changed
+1112
-239
No files matched your search
@@ -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}.")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user