stack: Provider-Schicht für Aufgaben-Tracker mit Super-Productivity-Adapter (#124)
Files changed: - .gitignore - CHANGES.md - VERSION - tools/CONTRACT.md - tools/chemenu/commands/docs_verify.py - tools/chemenu/commands/doctor.py - tools/chemenu/config.py - tools/chemenu/errors.py - tools/chemenu/tasks/__init__.py - tools/chemenu/tasks/config.py - tools/chemenu/tasks/protocol.py - tools/chemenu/tasks/superproductivity.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_superproductivity.py - tools/chemenu/tests/test_tasks_config.py - tools/chemenu/tests/test_tasks_protocol.py
This commit is contained in:
1 parent
ee24b6e5b8
commit
1875449b31
16 files changed
+1193
-6
No files matched your search
@@ -0,0 +1,12 @@
|
||||
"""The task-tracker provider layer (Gitea #124, #119 D2/D3/D25).
|
||||
|
||||
`kb/` owns a project's durable memory; a task tracker owns its momentary open
|
||||
loops (#119 D1). This package is the one place `wikitool` crosses that line -
|
||||
never an instruction, never a second MCP server (#119 D25): a provider is a
|
||||
Python object behind `chemenu.tasks.protocol.TaskReader`/`TaskWriter`, and
|
||||
everything above this package (the future `wikitool review`/`new project`
|
||||
commands, #125/#126) talks to that protocol and nothing provider-specific.
|
||||
|
||||
No command lives here yet - this package is a library, per #124's own scope
|
||||
note ("Kein Kommando. Diese Schicht ist Bibliothek").
|
||||
"""
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Reads `.wikitool-tasks.json` (Gitea #124) - `config.TASKS_CONFIG_FILENAME`.
|
||||
|
||||
Same posture as `chemenu.upload.read_config`: absent means "no tracker
|
||||
configured for this instance", a legitimate state that `doctor` reports as OK,
|
||||
never as a fault. Malformed is a `ValidationError`, never silently ignored -
|
||||
this file decides which provider (and which credentials) the review talks to,
|
||||
so a broken one must not be read as "nothing configured".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from chemenu import config
|
||||
from chemenu.errors import ValidationError
|
||||
|
||||
# The providers this package ships an adapter for. Checked at read time so a
|
||||
# typo in `provider` fails here, at the one place that knows the full list,
|
||||
# rather than surfacing later as an unhelpful "unknown provider" from whatever
|
||||
# code tried to dispatch on it.
|
||||
KNOWN_PROVIDERS = ("superproductivity",)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Thresholds:
|
||||
"""The weekly review's three staleness thresholds (#119 D10/D26) - each
|
||||
named for the check it feeds, not for its unit alone, since two of the
|
||||
three checks besides the "obvious" one also read a day count via
|
||||
`datetime.timedelta`."""
|
||||
|
||||
stalled_waiting_days: int # check 2: a WAITING item older than this
|
||||
unpaged_project_weeks: int # check 3: a tracker project with no kb/ page, older than this
|
||||
someday_stale_months: int # check 5: a someday/maybe item untouched for this long
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TasksConfig:
|
||||
"""The opt-in, read from `.wikitool-tasks.json`."""
|
||||
|
||||
provider: str
|
||||
thresholds: Thresholds
|
||||
provider_config: dict[str, Any]
|
||||
|
||||
|
||||
def read_config(root: "Any") -> "TasksConfig | None":
|
||||
"""The tracker configuration for `root`, or `None` when the file is
|
||||
absent - which means no tracker is configured for this instance, not that
|
||||
one failed to load."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
path = Path(root) / config.TASKS_CONFIG_FILENAME
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValidationError(
|
||||
f"{config.TASKS_CONFIG_FILENAME} is unreadable ({exc}). It decides which task "
|
||||
"tracker the weekly review talks to, so a broken file is not treated as 'no "
|
||||
"tracker configured' - fix it or delete it deliberately."
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(f"{config.TASKS_CONFIG_FILENAME} must contain a JSON object.")
|
||||
|
||||
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": "..."}}'
|
||||
)
|
||||
try:
|
||||
provider = str(data["provider"])
|
||||
raw_thresholds = data["thresholds"]
|
||||
stalled_waiting_days = int(raw_thresholds["stalled_waiting_days"])
|
||||
unpaged_project_weeks = int(raw_thresholds["unpaged_project_weeks"])
|
||||
someday_stale_months = int(raw_thresholds["someday_stale_months"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ValidationError(
|
||||
f"{config.TASKS_CONFIG_FILENAME} is missing or misshapes a required field ({exc}). "
|
||||
f"Expected: {expected}"
|
||||
) from exc
|
||||
|
||||
if provider not in KNOWN_PROVIDERS:
|
||||
raise ValidationError(
|
||||
f"{config.TASKS_CONFIG_FILENAME}: unknown provider {provider!r}. "
|
||||
f"Known: {', '.join(KNOWN_PROVIDERS)}"
|
||||
)
|
||||
for field_name, value in (
|
||||
("stalled_waiting_days", stalled_waiting_days),
|
||||
("unpaged_project_weeks", unpaged_project_weeks),
|
||||
("someday_stale_months", someday_stale_months),
|
||||
):
|
||||
if value <= 0:
|
||||
raise ValidationError(
|
||||
f"{config.TASKS_CONFIG_FILENAME}: thresholds.{field_name} must be positive."
|
||||
)
|
||||
|
||||
provider_config = data.get(provider)
|
||||
if not isinstance(provider_config, dict):
|
||||
raise ValidationError(
|
||||
f"{config.TASKS_CONFIG_FILENAME}: missing or non-object {provider!r} section "
|
||||
f"holding that provider's own connection settings. Expected: {expected}"
|
||||
)
|
||||
|
||||
return TasksConfig(
|
||||
provider=provider,
|
||||
thresholds=Thresholds(
|
||||
stalled_waiting_days=stalled_waiting_days,
|
||||
unpaged_project_weeks=unpaged_project_weeks,
|
||||
someday_stale_months=someday_stale_months,
|
||||
),
|
||||
provider_config=provider_config,
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The provider-agnostic read/write shape (Gitea #124, #119 D26/D31).
|
||||
|
||||
Every provider adapter under `chemenu.tasks` implements `TaskReader` and, if
|
||||
it can, `TaskWriter` - two separate `Protocol`s rather than one, because #124's
|
||||
own acceptance criteria requires exactly that: "ein Adapter kann den
|
||||
Schreibpfad nicht anbieten, ohne dass der Lesepfad davon beruehrt wird". A
|
||||
provider whose write path cannot exist (see `SuperProductivityWriter`) simply
|
||||
does not implement `TaskWriter` - nothing here forces it to.
|
||||
|
||||
The read shape is fixed by what the weekly review (#119 D26, built in #125)
|
||||
needs and nothing more: which projects exist and when they were created, how
|
||||
many open items each one has and which of those are `WAITING` with a
|
||||
`follow_up_at` (#119 D9/D30 - the *only* two machine-readable parts of a
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Optional, Protocol, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectSummary:
|
||||
"""One tracker project, as the review needs it: its name (the sole join
|
||||
key with a `kb/gtd/` page, #119 D8) and when it was created."""
|
||||
|
||||
name: str
|
||||
created: Optional[date]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WaitingItem:
|
||||
"""One open item carrying the `WAITING` status (#119 D9/D30).
|
||||
|
||||
`title` is shown verbatim, person and all - the review never parses it.
|
||||
`follow_up_at` is the one machine-readable date, and it is deliberately
|
||||
**not** the item's due date (#119 D9: "ausdruecklich nicht das
|
||||
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.
|
||||
"""
|
||||
|
||||
title: str
|
||||
follow_up_at: Optional[date]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenItems:
|
||||
"""A project's momentary open-loop count, plus the subset that is
|
||||
`WAITING`. `count` includes the waiting items - it is "how many open
|
||||
items", not "how many open items that aren't waiting"."""
|
||||
|
||||
count: int
|
||||
waiting: Sequence[WaitingItem]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SomedayItem:
|
||||
"""One someday/maybe item: its title and when it last changed, for
|
||||
check 5's staleness read (#119 D26)."""
|
||||
|
||||
title: str
|
||||
modified: Optional[date]
|
||||
|
||||
|
||||
def normalize_project_name(name: str) -> str:
|
||||
"""The case- and whitespace-normalized form of a project name (#119 D8):
|
||||
collapse internal whitespace, then casefold. Used both ways round - to
|
||||
preflight a name against the read path before a create, and to check
|
||||
whether a human's out-of-band creation (`HumanInterventionRequired.verify`)
|
||||
actually landed - so the same normalization must decide both, or a name
|
||||
that passes one check could fail the other."""
|
||||
return " ".join(name.strip().split()).casefold()
|
||||
|
||||
|
||||
class TaskReader(Protocol):
|
||||
"""The read path every provider adapter must implement."""
|
||||
|
||||
def projects(self) -> list[ProjectSummary]:
|
||||
"""Every project the tracker currently knows, in no particular
|
||||
order."""
|
||||
...
|
||||
|
||||
def open_items(self, project_name: str) -> OpenItems:
|
||||
"""Open items for the project named `project_name` (matched
|
||||
case-normalized, #119 D8). A project the tracker does not know
|
||||
returns `OpenItems(count=0, waiting=())` - "no open items" and "no
|
||||
such project" are not distinguished here, because check 3 (#119 D26)
|
||||
is what tells those apart, over the read path's `projects()` list."""
|
||||
...
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
"""Every someday/maybe item the tracker currently holds, across all
|
||||
projects."""
|
||||
...
|
||||
|
||||
|
||||
class TaskWriter(Protocol):
|
||||
"""The write path a provider adapter offers only if it actually can
|
||||
(#124's own acceptance criteria: offering this must not touch the read
|
||||
path's availability)."""
|
||||
|
||||
def create_project(self, name: str) -> None:
|
||||
"""Create a tracker project named `name`, after checking `name` is
|
||||
not already taken (case-normalized, #119 D8) via the read path.
|
||||
|
||||
Raises `chemenu.errors.ValidationError` if the name collides, or if
|
||||
the provider is reachable but refuses for a reason a human cannot fix
|
||||
by way of `chemenu.errors.HumanInterventionRequired` (e.g. the
|
||||
provider app is simply not running). Raises
|
||||
`chemenu.errors.HumanInterventionRequired` if this provider has no way
|
||||
to create a project itself and a human must do it out of band - see
|
||||
that class's docstring for the full contract, including `verify()`.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def find_project(reader: TaskReader, name: str) -> Optional[ProjectSummary]:
|
||||
"""The project matching `name` case-normalized (#119 D8), or `None`.
|
||||
Shared by a `TaskWriter`'s preflight collision check and by a
|
||||
`HumanInterventionRequired.verify()` closure - both are the same
|
||||
question, "does a project by this name exist right now", asked at two
|
||||
different moments."""
|
||||
target = normalize_project_name(name)
|
||||
for project in reader.projects():
|
||||
if normalize_project_name(project.name) == target:
|
||||
return project
|
||||
return None
|
||||
@@ -0,0 +1,318 @@
|
||||
"""The Super Productivity adapter (Gitea #124, #119 D2/D8/D9/D30).
|
||||
|
||||
Read and write deliberately use different transports (#124's own design note,
|
||||
carried as its first acceptance criterion): the read path is headless file
|
||||
access, the write path is Super Productivity's own local REST API - so a
|
||||
review can run with the app closed, and a create only needs the app open when
|
||||
it actually has to ask it for something.
|
||||
|
||||
## Read path: the backup snapshot, not a live `db.json`
|
||||
|
||||
Desktop Super Productivity keeps its live state in IndexedDB, not in a flat
|
||||
file called `db.json` on disk - there is no such file to read headlessly.
|
||||
What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
|
||||
writes the complete app state as `JSON.stringify(data)` into
|
||||
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last
|
||||
by filename (its own comment: "timestamps sort lexically"). That snapshot's
|
||||
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy`
|
||||
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`),
|
||||
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only
|
||||
looks at the three keys it needs, each an `@ngrx/entity` `EntityState`
|
||||
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts`
|
||||
`Task`/`Project`/`Tag`). Verified against the `master` branch of
|
||||
`super-productivity/super-productivity` on 2026-09-19 - unversioned, per
|
||||
#124's own note, so a future release is free to reshape it without warning,
|
||||
which is exactly why every read below fails loudly on a shape it does not
|
||||
recognize rather than guessing.
|
||||
|
||||
## The two GTD conventions this adapter encodes (#119 D9/D30)
|
||||
|
||||
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
|
||||
Productivity has no native field for either:
|
||||
|
||||
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
|
||||
task. Any other tag is left alone.
|
||||
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) -
|
||||
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das
|
||||
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full
|
||||
stop; this adapter never substitutes the due date for it.
|
||||
|
||||
## Someday/Maybe: a project's own backlog
|
||||
|
||||
#124 (later, #119) left this as "zu verifizieren": Super Productivity's
|
||||
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of
|
||||
task ids per project, apart from the active `taskIds` list a project's board
|
||||
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one
|
||||
project at a time; no tag convention is needed.
|
||||
|
||||
## Write path: no project-creation endpoint exists
|
||||
|
||||
The local REST API (`electron/local-rest-api-handler.service.ts`, verified the
|
||||
same day) routes `GET /projects` but has no `POST /projects` at all - task
|
||||
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project`
|
||||
can therefore not create a project itself; see its docstring and
|
||||
`chemenu.errors.HumanInterventionRequired`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from chemenu.errors import HumanInterventionRequired, ValidationError
|
||||
from chemenu.tasks.protocol import (
|
||||
OpenItems,
|
||||
ProjectSummary,
|
||||
SomedayItem,
|
||||
WaitingItem,
|
||||
find_project,
|
||||
normalize_project_name,
|
||||
)
|
||||
|
||||
# The tag title that means "WAITING" (#119 D9/D30), matched case-insensitively
|
||||
# - this instance's own convention, not something Super Productivity defines.
|
||||
WAITING_TAG_TITLE = "waiting"
|
||||
|
||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
||||
|
||||
_EXPECTED_PROVIDER_CONFIG = (
|
||||
'{"backups_dir": "~/.config/superProductivity/backups", '
|
||||
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
|
||||
' (or "db_path" instead of "backups_dir" to pin one exact file)'
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SuperProductivityConfig:
|
||||
"""This provider's own section of `.wikitool-tasks.json`
|
||||
(`TasksConfig.provider_config`)."""
|
||||
|
||||
# Exactly one of these two names where to read from. `db_path` wins when
|
||||
# both are set - it names one exact file, which is a stronger statement
|
||||
# than "the newest file in this directory".
|
||||
backups_dir: Optional[Path]
|
||||
db_path: Optional[Path]
|
||||
api_base_url: str
|
||||
# Unused by this module today - nothing it calls needs authentication
|
||||
# (`GET /health` is the one unauthenticated exception, and there is no
|
||||
# write call at all, see the module docstring). Carried through anyway so
|
||||
# a future capability that does need it does not require a config-shape
|
||||
# migration to add it.
|
||||
api_token: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"superproductivity config must be an object. Expected: {_EXPECTED_PROVIDER_CONFIG}"
|
||||
)
|
||||
backups_dir = data.get("backups_dir")
|
||||
db_path = data.get("db_path")
|
||||
if backups_dir is None and db_path is None:
|
||||
raise ValidationError(
|
||||
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
|
||||
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
|
||||
)
|
||||
api_base_url = str(data.get("api_base_url") or DEFAULT_API_BASE_URL)
|
||||
api_token = data.get("api_token")
|
||||
if api_token is not None and not isinstance(api_token, str):
|
||||
raise ValidationError("superproductivity config: api_token must be a string.")
|
||||
return cls(
|
||||
backups_dir=Path(backups_dir).expanduser() if backups_dir else None,
|
||||
db_path=Path(db_path).expanduser() if db_path else None,
|
||||
api_base_url=api_base_url,
|
||||
api_token=api_token,
|
||||
)
|
||||
|
||||
|
||||
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
|
||||
"""The one file to read: `db_path` if given, else the lexically-greatest
|
||||
`*.json` filename under `backups_dir` - the same ordering
|
||||
`electron/backup.ts` itself relies on ("timestamps sort lexically")."""
|
||||
if cfg.db_path is not None:
|
||||
if not cfg.db_path.is_file():
|
||||
raise ValidationError(
|
||||
f"superproductivity: db_path does not exist: {cfg.db_path}"
|
||||
)
|
||||
return cfg.db_path
|
||||
directory = cfg.backups_dir
|
||||
assert directory is not None # from_dict guarantees at least one is set
|
||||
if not directory.is_dir():
|
||||
raise ValidationError(
|
||||
f"superproductivity: backups_dir does not exist: {directory}"
|
||||
)
|
||||
candidates = sorted(directory.glob("*.json"))
|
||||
if not candidates:
|
||||
raise ValidationError(
|
||||
f"superproductivity: no *.json backup file found under {directory}. Take a "
|
||||
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), "
|
||||
"or point db_path/backups_dir at where it actually writes them."
|
||||
)
|
||||
return candidates[-1]
|
||||
|
||||
|
||||
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
|
||||
path = latest_snapshot_path(cfg)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValidationError(f"superproductivity: cannot read {path} ({exc}).") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValidationError(
|
||||
f"superproductivity: {path} does not contain a JSON object at its top level."
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def _entity_state(snapshot: dict[str, Any], key: str, source: Path) -> dict[str, dict]:
|
||||
"""`snapshot[key]` as an `@ngrx/entity` `{"ids": [...], "entities": {...}}`
|
||||
map, or a loud `ValidationError` naming exactly what was expected - Super
|
||||
Productivity's internal model is unversioned (module docstring), so a
|
||||
shape drift here is expected eventually, not a bug in this adapter."""
|
||||
value = snapshot.get(key)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or not isinstance(value.get("ids"), list)
|
||||
or not isinstance(value.get("entities"), dict)
|
||||
):
|
||||
raise ValidationError(
|
||||
f"superproductivity: {source} has no usable '{key}' entity state "
|
||||
f"({{\"ids\": [...], \"entities\": {{...}}}}). Its internal shape is unversioned and "
|
||||
"this file may be from a Super Productivity version this adapter does not know - "
|
||||
f"found: {type(value).__name__ if value is not None else 'missing'}."
|
||||
)
|
||||
return value["entities"]
|
||||
|
||||
|
||||
def _epoch_ms_to_date(value: Any) -> Optional[date]:
|
||||
if not isinstance(value, (int, float)):
|
||||
return None
|
||||
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).date()
|
||||
|
||||
|
||||
class SuperProductivityReader:
|
||||
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the
|
||||
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
|
||||
for why that matters."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig):
|
||||
self._cfg = cfg
|
||||
|
||||
def _read(self) -> tuple[Path, dict[str, dict], dict[str, dict], dict[str, dict]]:
|
||||
path = latest_snapshot_path(self._cfg)
|
||||
snapshot = _load_snapshot(self._cfg)
|
||||
projects = _entity_state(snapshot, "project", path)
|
||||
tasks = _entity_state(snapshot, "task", path)
|
||||
tags = _entity_state(snapshot, "tag", path)
|
||||
return path, projects, tasks, tags
|
||||
|
||||
def projects(self) -> list[ProjectSummary]:
|
||||
_, projects, _, _ = self._read()
|
||||
return [
|
||||
ProjectSummary(
|
||||
name=str(record.get("title", "")),
|
||||
created=_epoch_ms_to_date(record.get("created")),
|
||||
)
|
||||
for record in projects.values()
|
||||
]
|
||||
|
||||
def _is_waiting(self, task: dict, tags: dict[str, dict]) -> bool:
|
||||
for tag_id in task.get("tagIds") or []:
|
||||
tag = tags.get(tag_id)
|
||||
if tag and str(tag.get("title", "")).strip().casefold() == WAITING_TAG_TITLE:
|
||||
return True
|
||||
return False
|
||||
|
||||
def open_items(self, project_name: str) -> OpenItems:
|
||||
_, projects, tasks, tags = self._read()
|
||||
target = normalize_project_name(project_name)
|
||||
project = next(
|
||||
(p for p in projects.values() if normalize_project_name(str(p.get("title", ""))) == target),
|
||||
None,
|
||||
)
|
||||
if project is None:
|
||||
return OpenItems(count=0, waiting=())
|
||||
|
||||
waiting: list[WaitingItem] = []
|
||||
count = 0
|
||||
for task_id in project.get("taskIds") or []:
|
||||
task = tasks.get(task_id)
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
count += 1
|
||||
if self._is_waiting(task, tags):
|
||||
waiting.append(
|
||||
WaitingItem(
|
||||
title=str(task.get("title", "")),
|
||||
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
|
||||
)
|
||||
)
|
||||
return OpenItems(count=count, waiting=tuple(waiting))
|
||||
|
||||
def someday_items(self) -> list[SomedayItem]:
|
||||
_, projects, tasks, _ = self._read()
|
||||
items: list[SomedayItem] = []
|
||||
for project in projects.values():
|
||||
for task_id in project.get("backlogTaskIds") or []:
|
||||
task = tasks.get(task_id)
|
||||
if task is None or task.get("isDone"):
|
||||
continue
|
||||
items.append(
|
||||
SomedayItem(
|
||||
title=str(task.get("title", "")),
|
||||
modified=_epoch_ms_to_date(task.get("updated") or task.get("created")),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
|
||||
"""Whether the local REST API answers `GET /health` right now - the one
|
||||
unauthenticated endpoint (module docstring). Never raises: an unreachable
|
||||
app is an ordinary, expected state (`doctor` reports it, it does not
|
||||
FAIL), not a defect in this adapter."""
|
||||
url = cfg.api_base_url.rstrip("/") + "/health"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - localhost only
|
||||
return 200 <= response.status < 300
|
||||
except (urllib.error.URLError, OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
class SuperProductivityWriter:
|
||||
"""`TaskWriter` over the local REST API - except there is no API call
|
||||
this can actually make, see the module docstring."""
|
||||
|
||||
def __init__(self, cfg: SuperProductivityConfig, reader: SuperProductivityReader):
|
||||
self._cfg = cfg
|
||||
self._reader = reader
|
||||
|
||||
def create_project(self, name: str) -> None:
|
||||
"""Never creates anything. Preflights the name against the read path
|
||||
(#119 D8) and, if it is free, raises `HumanInterventionRequired`
|
||||
naming the one thing a human must do - Super Productivity's local
|
||||
REST API has no project-creation endpoint at all (module docstring),
|
||||
so this is not a missing feature in this adapter, it is a missing
|
||||
endpoint upstream."""
|
||||
existing = find_project(self._reader, name)
|
||||
if existing is not None:
|
||||
raise ValidationError(
|
||||
f"A project named '{name}' (case-insensitively) already exists in "
|
||||
"Super Productivity - nothing was created."
|
||||
)
|
||||
|
||||
def _verify() -> bool:
|
||||
return find_project(self._reader, name) is not None
|
||||
|
||||
raise HumanInterventionRequired(
|
||||
"Super Productivity's local REST API has no project-creation endpoint "
|
||||
"(only GET /projects) - this cannot be automated.\n"
|
||||
f" 1. Open Super Productivity.\n"
|
||||
f" 2. Create a project named exactly: {name}\n"
|
||||
" 3. Tell the agent you have done this, so it can re-check and continue.",
|
||||
verify=_verify,
|
||||
)
|
||||
Reference in new issue
Block a user