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
271 lines
12 KiB
Python
271 lines
12 KiB
Python
"""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)
|
|
and `task list`/`task close` (#138) need and nothing more: which projects
|
|
exist and when they were created, every open item each one has - id, title,
|
|
and whether it is `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`) never sees a value this process
|
|
cached from before that step.
|
|
|
|
An item's own id (#138) is read-only data, like everything else here - it is
|
|
never stored by `wikitool`, only ever passed straight back into
|
|
`TaskWriter.close_item` within the same invocation. That keeps the "one name
|
|
is the only coupling" decision (`docs/knowledge-and-commitment.md` § "One
|
|
name, carrying the duties of an identifier") intact: no id-to-anything
|
|
mapping is ever written down, so there is nothing to keep in sync.
|
|
|
|
**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
|
|
|
|
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).
|
|
|
|
`id` is the provider's own item id (#138) - read-only, never guessed,
|
|
passed straight into `TaskWriter.close_item` when the review's
|
|
`waiting_overdue` (b) is confirmed. `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.
|
|
|
|
**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.
|
|
"""
|
|
|
|
id: str
|
|
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 OpenItem:
|
|
"""One open item as `task list` (#138) needs it - id, title, and whether
|
|
it carries the `WAITING` status. Deliberately thinner than `WaitingItem`
|
|
(no `follow_up_at`): a waiting item still appears here, just without the
|
|
one field only the waiting-overdue check reads."""
|
|
|
|
id: str
|
|
title: str
|
|
waiting: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OpenItems:
|
|
"""A project's momentary open-loop count, the subset that is `WAITING`,
|
|
and the full list `task list` prints. `count` includes the waiting items
|
|
- it is "how many open items", not "how many open items that aren't
|
|
waiting". `items` and `waiting` overlap by design: a `WaitingItem` is
|
|
also present in `items`, since `task list` shows every open item
|
|
regardless of status."""
|
|
|
|
count: int
|
|
waiting: Sequence[WaitingItem]
|
|
items: Sequence[OpenItem]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SomedayItem:
|
|
"""One someday/maybe item: its id, title and when it last changed. `id`
|
|
is the provider's own item id (#138), passed into `TaskWriter.close_item`
|
|
when the review's `someday_stale` (b) is confirmed. `modified` feeds
|
|
check 5's staleness read (#119 D26)."""
|
|
|
|
id: str
|
|
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=(), items=())` - "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."""
|
|
...
|
|
|
|
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
|
|
(#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 create_item(
|
|
self,
|
|
title: str,
|
|
*,
|
|
project_name: Optional[str],
|
|
waiting: bool = False,
|
|
follow_up_at: Optional[date] = None,
|
|
notes: Optional[str] = None,
|
|
) -> None:
|
|
"""Create one open item - a tracker `Posten`, never a kb/ page
|
|
(Gitea #132 D1). `title` is stored verbatim, exactly like
|
|
`WaitingItem.title` - never parsed.
|
|
|
|
`project_name=None` is the caller's own explicit choice of the
|
|
tracker's inbox (#132 D4 "Weg 3"), never a stand-in for "no project
|
|
was given" - the CLI's own `--inbox` flag is the only thing allowed
|
|
to produce it; an omitted `--project` is refused before this is ever
|
|
called. A `project_name` that is given must already exist
|
|
(case-normalized, #119 D8) - this never creates a project itself and
|
|
never searches or guesses one (#132 D6): `chemenu.errors.ValidationError`
|
|
if no such project exists.
|
|
|
|
`waiting`/`follow_up_at` set #119's own WAITING/`follow_up_at` pair
|
|
(D9/D30) - the same two machine-readable parts `WaitingItem` reads
|
|
back. Raises `ValidationError` if the provider can represent items at
|
|
all (it offers `TaskWriter`) but has no way to mark one WAITING right
|
|
now - e.g. Super Productivity's `waiting` tag does not exist yet and
|
|
tags cannot be created via its API (#132's own verified constraint):
|
|
an item is never created *without* the status it was asked for.
|
|
|
|
`notes` carries D5's freetext backref to a kb/ page - stored
|
|
verbatim, never parsed, exactly the posture `WaitingItem.title`
|
|
already has for the person named in it.
|
|
|
|
Unlike `create_project`, this never raises
|
|
`chemenu.errors.HumanInterventionRequired`: every provider offering
|
|
`TaskWriter` at all has been verified to have a real item-creation
|
|
call (#132 - the gap `create_project` hits, no project-creation
|
|
endpoint, does not exist on the item side).
|
|
"""
|
|
...
|
|
|
|
def close_item(self, item_id: str) -> None:
|
|
"""Mark the item `item_id` done - never delete it (Gitea #138). This
|
|
is the only closing write this stack ever makes: no "remove", no
|
|
"move the reminder forward". `item_id` is the provider's own id
|
|
(`WaitingItem.id`/`SomedayItem.id`/`OpenItem.id`), read fresh
|
|
immediately before the call and never guessed or looked up by title -
|
|
the tracker-side identity is opaque and provider-defined, unlike the
|
|
project name (#119 D8), which is why this takes an id rather than a
|
|
title the way `create_item` takes a project name.
|
|
|
|
Raises `chemenu.errors.ValidationError` if no item with this id
|
|
exists right now - nothing is written. Like `create_item`, never
|
|
raises `chemenu.errors.HumanInterventionRequired`: every provider
|
|
offering `TaskWriter` has a real per-item write call, the same gap
|
|
`create_project` alone hits.
|
|
"""
|
|
...
|
|
|
|
|
|
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
|