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
+35
-3
@@ -59,15 +59,20 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7.0.0-beta.10 - 2026-09-20 - Veraltete Skill-Aufzaehlungen in der Instruction-Schicht nachgezogen
|
## 7.0.0-beta.11 - 2026-09-20 - SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
**Breaking Change:** docs verify now requires an adopted `project` type-spec (schema requiring `state:`) and its `kb/gtd/` collection - an instance must adopt types/project.md(.schema.yaml) and kb/gtd/COLLECTION.md from their .template before docs verify passes again
|
**Breaking Change:**
|
||||||
|
- docs verify now requires an adopted `project` type-spec (schema requiring `state:`) and its `kb/gtd/` collection - an instance must adopt types/project.md(.schema.yaml) and kb/gtd/COLLECTION.md from their .template before docs verify passes again
|
||||||
|
- superproductivity's provider section in .wikitool-tasks.json now requires 'access' ('api' or 'snapshot'), no default and no fallback between the two; 'db_path' no longer exists at all. An existing config must add 'access' and, if it used 'db_path', switch to 'backups_dir' (see INSTALL.md's example).
|
||||||
|
|
||||||
**Migration:** none required - no kb/ page content changes - the fix is the ordinary .template adoption every root:kb type already requires, not a data migration
|
**Migration:** none required - No kb/ page content changes - the break is confined to .wikitool-tasks.json, an instance-owned, gitignored file every operator already edits by hand per INSTALL.md's example.
|
||||||
|
|
||||||
<!-- wikitool:bumps -->
|
<!-- wikitool:bumps -->
|
||||||
|
**High impact**
|
||||||
|
- SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
|
||||||
|
|
||||||
**Medium impact**
|
**Medium impact**
|
||||||
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
- Typ `project` und Collection `kb/gtd/`: das Vorhaben als eigene Seitenart
|
||||||
- Task-tracker provider layer, with a Super Productivity adapter
|
- Task-tracker provider layer, with a Super Productivity adapter
|
||||||
@@ -333,6 +338,33 @@ sechs der acht Skills und trug die veraltete Zahl im Namen; er nennt jetzt alle
|
|||||||
Familien erklaert. Dass die Passage weiterhin Schrittzahlen fremder Dateien zitiert, die genauso
|
Familien erklaert. Dass die Passage weiterhin Schrittzahlen fremder Dateien zitiert, die genauso
|
||||||
still veralten koennen, ist als eigene Frage festgehalten und hier bewusst nicht geloest.
|
still veralten koennen, ist als eigene Frage festgehalten und hier bewusst nicht geloest.
|
||||||
|
|
||||||
|
### SP-Zugriffsweg explizit (access: api/snapshot, #133) und follow_up_at-Korrektur (dueWithTime/dueDay, #135)
|
||||||
|
|
||||||
|
Gitea #133: der Super-Productivity-Adapter unterscheidet jetzt zwei sich ausschliessende
|
||||||
|
Zugriffswege, per Pflichtfeld `access` ohne Default und ohne Laufzeit-Ruckfall - `"api"` liest
|
||||||
|
und (fuer #132 vorbereitet) schreibt ausschliesslich ueber die lokale REST-API und den aktuellen
|
||||||
|
Zustand, `"snapshot"` liest ausschliesslich den juengsten Backup-Schnappschuss und ist von dort
|
||||||
|
aus nie schreibbar. `db_path` entfaellt vollstaendig, `backups_dir`s Glob ist auf das echte
|
||||||
|
Zeitstempelmuster (`YYYY-MM-DD_HHmmss.json`) gehaertet, und beide Wege blenden archivierte
|
||||||
|
Tracker-Projekte aus - verifiziert gegen den tatsaechlichen Quellcode von
|
||||||
|
`super-productivity/super-productivity` (`master`, 2026-09-20). `SuperProductivityApiReader` ist
|
||||||
|
der neue Leser fuer `access: "api"`, gegen eine Attrappe getestet, nie gegen eine laufende App.
|
||||||
|
`tools/wikitool new project` verweigert auf einer `access: "snapshot"`-Instanz jetzt vollstaendig
|
||||||
|
(Exit 1, weder Tracker-Projekt noch Seite) statt den nicht mehr moeglichen 42er-Menschenschritt zu
|
||||||
|
versuchen; `wikitool doctor` und `wikitool review` berichten nur noch den tatsaechlich
|
||||||
|
konfigurierten Weg, und jede Antwort von `review` nennt jetzt explizit, aus welchem Weg sie
|
||||||
|
stammt (bei `snapshot` samt Alter des gelesenen Schnappschusses). Aufgeloest damit: #134, dessen
|
||||||
|
Verdacht (`--resume` koennte gegen einen veralteten Schnappschuss verifizieren) durch den Wegfall
|
||||||
|
der Projektanlage auf `snapshot`-Instanzen gegenstandslos wurde.
|
||||||
|
|
||||||
|
Gitea #135, im selben Zug korrigiert: `follow_up_at` las bislang `remindAt`, das sich nur bei
|
||||||
|
einer mit Uhrzeit terminierten und benachrichtigten Aufgabe fuellt - ein ganztaegiger, stiller
|
||||||
|
Tickler (`dueDay` ohne `dueWithTime`, das haeufigste WAITING-Muster) hatte dadurch nie einen
|
||||||
|
`follow_up_at` und fiel bei Pruefung 2 des Wochenrueckblicks still durch. Gelesen wird jetzt
|
||||||
|
`dueWithTime`, sonst `dueDay` - Super Productivitys eigene Leseregel - nie `deadline*` (D9 bleibt
|
||||||
|
in der Sache unveraendert, nur die falsche Berufung auf sie ist korrigiert). Bestehende Instanzen
|
||||||
|
sehen dadurch rueckblickend mehr Befunde, nicht weniger.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
|
## 6.2.0 - 2026-09-19 - Entity-Subtyp project nach codebase umbenannt
|
||||||
|
|||||||
+26
-5
@@ -309,20 +309,41 @@ unlesbare Konfiguration darf nicht als „kein Tracker" durchgehen.
|
|||||||
"someday_stale_months": 5
|
"someday_stale_months": 5
|
||||||
},
|
},
|
||||||
"superproductivity": {
|
"superproductivity": {
|
||||||
"backups_dir": "~/.config/superProductivity/backups",
|
"access": "api",
|
||||||
"api_base_url": "http://127.0.0.1:3876",
|
"api_base_url": "http://127.0.0.1:3876",
|
||||||
"api_token": "<token aus den SP-Einstellungen>"
|
"api_token": "<token aus den SP-Einstellungen>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
"superproductivity": {
|
||||||
|
"access": "snapshot",
|
||||||
|
"backups_dir": "~/.config/superProductivity/backups"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
`provider` wählt den Adapter - ausgeliefert wird bislang `superproductivity`. Der `thresholds`-
|
`provider` wählt den Adapter - ausgeliefert wird bislang `superproductivity`. Der `thresholds`-
|
||||||
Block trägt die drei Schwellwerte des Rückblicks (Konfiguration, nicht Schema): ab wann ein
|
Block trägt die drei Schwellwerte des Rückblicks (Konfiguration, nicht Schema): ab wann ein
|
||||||
Waiting-For überfällig ist, ab welchem Alter ein Tracker-Projekt ohne `kb/`-Seite gemeldet wird,
|
Waiting-For überfällig ist, ab welchem Alter ein Tracker-Projekt ohne `kb/`-Seite gemeldet wird,
|
||||||
und ab wann ein Someday-Eintrag als verstaubt gilt. Der gleichnamige Provider-Block trägt dessen
|
und ab wann ein Someday-Eintrag als verstaubt gilt.
|
||||||
Verbindungsangaben; bei Super Productivity sind Lese- und Schreibpfad verschieden - gelesen wird
|
|
||||||
der jüngste Backup-Schnappschuss unter `backups_dir` (läuft auch ohne laufende App), geschrieben
|
Der gleichnamige Provider-Block trägt dessen Verbindungsangaben, und bei Super Productivity
|
||||||
über die lokale REST-API, die nur antwortet, solange die App läuft.
|
entscheidet `access` **verpflichtend und ohne Rückfall**, welcher von zwei sich ausschließenden
|
||||||
|
Wegen das ist: eine headless bediente Instanz setzt `access: "snapshot"` und liest
|
||||||
|
ausschließlich den jüngsten Backup-Schnappschuss unter `backups_dir` (läuft auch ohne laufende
|
||||||
|
App, aber rein lesend - der Tracker ist von dort aus nicht schreibbar); eine Desktop-Instanz
|
||||||
|
setzt `access: "api"` und spricht ausschließlich die lokale REST-API an, die nur antwortet,
|
||||||
|
solange die App läuft, dafür aber auch den aktuellen Zustand liefert und den Schreibpfad trägt.
|
||||||
|
Der Block nennt nur die Felder seines eigenen Wegs - ein `backups_dir` neben `access: "api"` oder
|
||||||
|
ein `api_token` neben `access: "snapshot"` wird beim Lesen der Konfiguration abgelehnt, nicht
|
||||||
|
ignoriert. `api_token` ist bei `access: "api"` Pflicht, da jeder Endpunkt außer `GET /health`
|
||||||
|
`Authorization: Bearer <token>` verlangt.
|
||||||
|
|
||||||
|
`tools/wikitool new project` legt einen gleichnamigen Tracker-Eintrag nur auf einer
|
||||||
|
`access: "api"`-Instanz an (und auch dort nicht automatisch - siehe die Kommandotabelle). Auf
|
||||||
|
einer `access: "snapshot"`-Instanz verweigert das Kommando vollständig, exit 1: der Tracker ist
|
||||||
|
von dort aus nur lesbar.
|
||||||
|
|
||||||
## Verifikation
|
## Verifikation
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -94,7 +94,7 @@ tools/wikitool <command> --help
|
|||||||
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
|
| `new concept --name "<Name>" --set concept_type=<t> ...` | Scaffold `kb/concepts/<Name>.md` |
|
||||||
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
|
| `new source --name "<Name>" --set raw_files=raw/notes/x.md,raw/notes/y.md [--set source_url=<URL>] [--set entities=A,B] [--set concepts=C,D]` | Scaffold `kb/sources/Source - <Name>.md` (prefix added automatically) with a `raw_files:` list (rejects paths that don't exist) |
|
||||||
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
|
| `new comparison --name "X vs Y" --set entities=X,Y` | Scaffold `kb/comparisons/X vs Y.md` |
|
||||||
| `new project --name "<Name>" --set responsibility=<bereich> [--resume]` | Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider with no write path of its own (Super Productivity has no project-creation endpoint) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused |
|
| `new project --name "<Name>" --set responsibility=<bereich> [--resume]` | Scaffold `kb/gtd/<bereich>/<Name>.md` **and**, if `.wikitool-tasks.json` configures a task tracker, a same-named tracker project - one name, one identity. Tracker before page: the tracker side is settled first, so a failure past that point leaves a tracker project with no page - a state `review`'s check 3 already reports - never a page with no tracker project. No tracker configured is a legitimate, explicitly announced state (page only). A name already taken (case-insensitively) in `kb/` or the tracker is refused outright, naming where it was found, and creates nothing. A provider whose *configured access path* has no write path (Super Productivity's `access: "snapshot"` - the tracker is read-only from there by construction) refuses **entirely**, exit **1**, naming the `access: "api"` instance to use instead - neither the tracker project nor the page is created, and `--resume` behaves the same. A provider that could write but has no project-creation endpoint of its own (Super Productivity's `access: "api"` - `GET /projects` exists, `POST /projects` does not) raises `chemenu.errors.HumanInterventionRequired`; the command shows its instructions and exits **42** (`needs_clearance()`, same posture as the four named gates, without being a fifth one - see that class's docstring), creating nothing. `--resume` is how a later run tells the command a human has done what that message asked: it re-verifies via the read path (`find_project`) before continuing to page creation, rather than trusting the claim, and repeats the same 42 if the tracker still doesn't have it. `--resume` on any other type is refused |
|
||||||
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
|
| `touch --page "<Title>" [--summary "..."] [--provenance <v>] [--date YYYY-MM-DD] [--set field=value ...] [--add field=value ...] [--remove field=value ...] [--no-date] [--dry-run]` | Update a page's own frontmatter: bump `modified:` and optionally rewrite any field its type declares. `--summary`/`--provenance` are shorthands; `--set` reaches every other field and **replaces** its value, while `--add`/`--remove` change single elements of an array field (removing an absent element succeeds and says so). Repeating `--set` for one array field appends *within the call*, and `\,` is a literal comma - same rules as `new --set`. Refused with the command that owns them instead: `type:` (page-lifecycle), and the page-ref arrays `related:`/`sources:`/`entities:`/`concepts:` (`xref`). Everything else the schema declares is settable, and an unknown field lists what the page actually has. Schema-validates the fields it writes, and `raw_files:` entries must exist on disk. A source declares `date:` instead of `modified:`, and that is the *publication* date of the raw material - it is never bumped to today, and changes only when `--date` names a value explicitly. |
|
||||||
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` |
|
| `rename --from "<Old>" --to "<New>" [--dry-run]` | Rename a page and repoint every reference to it: body `[[wikilinks]]` (aliases and anchors preserved), a `[^cite-id]` whose id was derived from the old title (refreshed to match the new one, both in its Footnotes definition and every reference to it), the page's own H1, and every page-ref frontmatter array declared by the type's `page_ref_fields:`. If `--from` is *not* a page but is referenced, it instead repoints those references onto the existing `--to` page and moves nothing - the fix for a reference spelled `act_runner` when the page is `Act Runner` |
|
||||||
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
|
| `rm --page "<Title>" [--yes] [--dry-run]` | Delete a page and mechanically de-link it. Refuses without `--yes` while other pages still reference it. Strips ref-array entries and bare `- [[Title]]` / `- **label:** [[Title]]` bullets; leaves prose and inline citations in place and reports them |
|
||||||
@@ -126,7 +126,7 @@ tools/wikitool <command> --help
|
|||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
| `lint [--json] [--markdown out.md] [--full] [--fail-on-error]` | Structural + provenance checks: broken wikilinks, dangling frontmatter references, orphan pages, index drift, schema gaps, duplicate titles, title mismatches, pages nested more than one directory below their collection (hard - the generated catalog folds these into their area silently rather than merely reading it), uncovered raw files, broken `raw_files:` refs, raw files claimed by more than one source page, unmarked provenance, citation/frontmatter drift, unbalanced generated-region markers, edges whose label is missing or not authorised by the source collection's `outbound:` (both hard once `kb_version` has reached the release that introduced labelled edges - advisory below it, so a corpus mid-migration is not refused by the check measuring it), `see-also` edges whose reverse direction already carries a specific label (advisory only - redundant rather than wrong, and never migration-gated, since no version turns the redundancy into an error), a collection past the catalog's per-area shard threshold that has no areas to shard (advisory only - sharding is automatic but per *area*, so a collection nobody gave areas keeps one table however large it grows; reported with the split its subtype field would produce, and only when that split puts every resulting area at or under the threshold, so a lopsided or small collection stays silent), source pages sitting in the `unclassified` catalog slot (advisory only - `unclassified` is the visible fallback for a genuinely unclear source, not a defect), quote-limit overages (>2 blockquoted lines/page, advisory only). Prints only the sections that found something and always writes the full report to `reports/Lint Report <date>.md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing |
|
||||||
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
| `search ["<text>"] [--field <predicate> ...] [--kind/--subtype/--collection/--tag <v>] [--regex] [--limit N] [--sort [-]<field>] [--backend <name>] [--matches] [--json]` | Find pages in `kb/` without reading the index. Text search runs through a pluggable backend (`rg` today); `--field` predicates are evaluated on frontmatter - `f=v`, `f~substring`, `'f>=v'`, `'f:*'` (present), `'!f'` (absent), repeatable and ANDed. With no text this is a pure structured query. One hit per line, ` | `-separated as `score \| kind/subtype \| title \| path \| summary`, so a hit can be judged without opening the page and then opened without looking it up: **title and path are never truncated** (the title is the identifier `touch`/`xref`/`cite` take), and the summary - the one lossy field, and the only one that may contain the separator - goes last, so splitting on `" \| "` with `maxsplit=4` is unambiguous. Scope is pages: the backend walks `kb/` but drops anything `kb_scan.iter_kb_pages` excludes (the kb-root meta files, every `COLLECTION.md`, every generated `INDEX.md`), which is why a hand-run grep over `kb/` can add none of them but those. `--limit` defaults to 50 (`0` for no limit) and **a truncated result says so** - `50 of 182 result(s)` in the table, `total`/`truncated`/`limit` beside `count` in `--json`, where `count` stays the number of results in the payload; the same default and the same fields are what `api.search` and the MCP `search` tool carry, from one constant. A page whose frontmatter does not parse can match no positive predicate, so it is **named** rather than dropped: `--json` always carries an `unreadable` list of `{path, reason}` (usually empty), and the table form writes the same lines to stderr. `--regex` is applied by `rg` alone, whose engine is linear; the ranking boosts for title and summary are literal-containment only, so a non-literal pattern is ranked by match count. `rg` is killed after 30 s and reported as a failure. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||||
| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`. No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** |
|
| `review [--json]` | The GTD weekly review: joins the configured task-tracker provider (`chemenu.tasks`) against `kb/gtd/` project pages over the case-normalized project name, at read time, storing nothing - not even a `reports/` file. Five checks: **stalled** (a tracker project with zero open items whose `kb/` page is `state: active` - `dormant`/`completed`/`abandoned` never fire, since those states mean the initiative not having a next action is expected rather than a problem), **waiting-overdue** (a `WAITING` item whose `follow_up_at` is older than `thresholds.stalled_waiting_days`), **unpaged-project** (a tracker project with no matching `kb/` page, older than `thresholds.unpaged_project_weeks`), **no-open-loop** (a `kb/` page `state: active` with no matching tracker project, or one with zero open items - the reverse direction of the unpaged-project join, so a rename on either side surfaces on both), **someday-stale** (a someday/maybe item untouched for longer than `thresholds.someday_stale_months`). Thresholds come from `.wikitool-tasks.json`, never from the schema. Text output is one `[check] project: message` line per finding, preceded by a `Source:` line naming which access path answered and, for `superproductivity`'s `access: "snapshot"`, the snapshot's age; `--json` carries the same findings plus `checks_run`/`checks_skipped`/`kb_project_count`/`complete`/`source` (`{"kind": ..., "detail": ...}` or `null`). No `.wikitool-tasks.json` fails immediately with a clear "no tracker configured" message; a provider that cannot be reached mid-run degrades only the checks that needed the failing call, and the report is never rendered as if it were complete - see its error-contract row. Read-only, and **exempt from the Iteration Budget Gate** |
|
||||||
|
|
||||||
### Provenance
|
### Provenance
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ tools/wikitool <command> --help
|
|||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also whether its read path (a backup snapshot) is ready and whether its local REST API answers `GET /health` right now - neither ever `FAIL`s, an app that is simply not running is not a fault), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
| `doctor [--json]` | Check that this instance is correctly configured: dependencies (Python, ripgrep), author resolution, stack version, git identity/branch/remote, published skills, kb/raw/reports/work/instructions structure, personalization (`USER.md`/`SOUL.md` present **and** filled - a file still carrying the template's sentinel is a `FAIL`, since a renamed template is not a filled one), the KB conventions (`kb/CONVENTIONS.md` present, unsentinelled, and naming all three tool-owned section headings - a `FAIL` on any of the three, because `xref`/`cite` write out of it), the environment note (`ENVIRONMENT.md` - optional, so absent is `OK`; a still-templated one is a `WARN`), generated files, whether the MCP `submit` tool is armed (`.wikitool-upload.json` present/absent/malformed, its limits, and how many submissions are waiting in `mcp-upload/` - absent is `OK` and means the write path does not exist at all, malformed is the one `FAIL` here, since a broken opt-in must not silently disable the limits it exists to enforce), the task-tracker provider (`.wikitool-tasks.json` present/absent/malformed - absent is `OK` and means no tracker is configured, malformed is `FAIL` for the same reason the upload opt-in is; for a configured `superproductivity` provider, also its configured `access` path's own state - `access: "api"` reports whether its local REST API answers `GET /health` right now, `access: "snapshot"` reports whether a backup file is ready; the *other* access path is never attempted and is not a finding - and neither ever `FAIL`s, an app that is simply not running is not a fault), the session id source (`OK` for `WIKITOOL_SESSION_ID` or a registered harness variable, `WARN` only for the bare parent-pid fallback - see `chemenu.session`), and telemetry state (on/off, why - installation-form default, `.wikitool-telemetry.json`, or `WIKI_TRACE` - and the current session count/byte total against both caps; never `FAIL`, see [EVALS.md](../EVALS.md)). Read-only, exit 1 only on a `FAIL` (a missing remote, session id, or `VERSION` is a `WARN`, not a fault). Exempt from the Iteration Budget Gate |
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ is atomic, and whether a retry is safe.
|
|||||||
| Command | Exit 1 means | Atomic? | Retry policy |
|
| Command | Exit 1 means | Atomic? | Retry policy |
|
||||||
|---------|--------------|---------|--------------|
|
|---------|--------------|---------|--------------|
|
||||||
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
|
| `new <type>` | Duplicate page title, unknown type, invalid `--set` value, or a `raw_files` path that doesn't exist | Yes - single file write | Not transient; fix the argument and retry once. Never hand-craft the page instead |
|
||||||
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), or `--resume` was passed for a type other than `project` | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision or a bad `--set` is not transient, same as `new <type>`. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own third outcome: the provider cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it |
|
| `new project` | Everything `new <type>` covers, **plus**: the name is already taken in the tracker (case-insensitively), `--resume` was passed for a type other than `project`, or the configured provider's access path has no write path at all (Super Productivity's `access: "snapshot"`) | **No** for the tracker-configured case - a tracker-project write (or its human-clearance request) happens before the kb/ page write, so a failure between the two leaves a tracker project with no page (a state `review`'s check 3 already reports), never a page with no tracker project. Still a single file write when no tracker is configured | A collision, a bad `--set`, or a read-only access path is not transient, same as `new <type>` - the last of those points at the `access: "api"` instance instead and refuses on every `--resume` retry too, since nothing about the config changes by asking again. **Exit 42** (`NEEDS USER CLEARANCE`, not exit 1) is its own separate outcome from the ordinary exit-1 cases above: the provider *can* write but cannot create the project itself and a human must, per the printed instructions; re-run with `--resume` once that is done - it re-verifies via the read path rather than trusting the claim, and exits 42 again unchanged if the tracker still does not have it |
|
||||||
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
|
| `touch` | Page not found; an invalid value for a field it writes; a field owned by another command (`type:`, a page-ref array) or absent from the type's schema; `--add`/`--remove` on a non-array field; a `raw_files:` path that doesn't exist | Yes - single file write, and every refusal happens before it | Fix the argument and retry once. Safe to re-run as-is: `--set` and `--add` are idempotent, and `--remove` of an already-absent element succeeds while reporting it |
|
||||||
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead |
|
| `rename` | Neither `--from` nor `--to` is a page, target title already taken, or `--from` equals `--to` | No - one write per referencing page, then the file move | Safe to retry once as-is; each page's rewrite is idempotent. Use `--dry-run` first to see the blast radius. Never fix up references by hand instead |
|
||||||
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry |
|
| `rm` | Page not found, **or** other pages still reference it and `--yes` was not passed | No - one write per referencing page, then the delete | For "still referenced": show the user the inbound list, get approval, then re-run with `--yes`. Prose references it reports afterwards are an editorial fix, not a retry |
|
||||||
|
|||||||
@@ -426,6 +426,11 @@ def check_tasks_provider() -> Check:
|
|||||||
"nothing configured". Provider reachability itself never affects the
|
"nothing configured". Provider reachability itself never affects the
|
||||||
exit code, same as `check_git_repo`'s remote check: the app being closed
|
exit code, same as `check_git_repo`'s remote check: the app being closed
|
||||||
is normal, not a fault.
|
is normal, not a fault.
|
||||||
|
|
||||||
|
For `superproductivity`, only the instance's configured `access` path is
|
||||||
|
ever attempted (Gitea #133) - `api` reports API reachability, `snapshot`
|
||||||
|
reports whether a backup file is ready; the other path is simply not a
|
||||||
|
finding, since this instance never touches it.
|
||||||
"""
|
"""
|
||||||
from chemenu import config
|
from chemenu import config
|
||||||
from chemenu.errors import ValidationError
|
from chemenu.errors import ValidationError
|
||||||
@@ -456,14 +461,20 @@ def check_tasks_provider() -> Check:
|
|||||||
"tasks-provider", "FAIL", str(exc),
|
"tasks-provider", "FAIL", str(exc),
|
||||||
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
|
f"Fix the 'superproductivity' section of {config.TASKS_CONFIG_FILENAME}",
|
||||||
)
|
)
|
||||||
|
# Only the configured access path is a finding (Gitea #133) - the
|
||||||
|
# other one is not attempted at all, so it has nothing to report.
|
||||||
|
if sp_cfg.access == sp.ACCESS_API:
|
||||||
|
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
|
||||||
|
return Check(
|
||||||
|
"tasks-provider", "OK", f"superproductivity: access=api; {api_state}",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
snapshot_path = sp.latest_snapshot_path(sp_cfg)
|
snapshot_path = sp.latest_snapshot_path(sp_cfg)
|
||||||
read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}"
|
read_state = f"read path OK, newest snapshot {rel_path(snapshot_path)}"
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
read_state = f"read path not ready ({exc})"
|
read_state = f"read path not ready ({exc})"
|
||||||
api_state = "API reachable" if sp.health(sp_cfg) else "API not reachable (app not running?)"
|
|
||||||
return Check(
|
return Check(
|
||||||
"tasks-provider", "OK", f"superproductivity: {read_state}; {api_state}",
|
"tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
|
return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured")
|
||||||
|
|||||||
@@ -290,6 +290,15 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
reader = tasks.build_reader(cfg)
|
reader = tasks.build_reader(cfg)
|
||||||
|
try:
|
||||||
|
writer = tasks.build_writer(cfg, reader)
|
||||||
|
except ValidationError as exc:
|
||||||
|
# E.g. a superproductivity instance with access: "snapshot" (Gitea
|
||||||
|
# #133) - the tracker is read-only from here, so this refuses before
|
||||||
|
# either the collision check or the page write, exactly like any
|
||||||
|
# other precondition failure.
|
||||||
|
fail(str(exc))
|
||||||
|
|
||||||
existing = find_project(reader, page_title)
|
existing = find_project(reader, page_title)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if resume:
|
if resume:
|
||||||
@@ -302,7 +311,6 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
|
|||||||
"instead of being refused here."
|
"instead of being refused here."
|
||||||
)
|
)
|
||||||
|
|
||||||
writer = tasks.build_writer(cfg, reader)
|
|
||||||
try:
|
try:
|
||||||
writer.create_project(page_title)
|
writer.create_project(page_title)
|
||||||
except HumanInterventionRequired as exc:
|
except HumanInterventionRequired as exc:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ def render_report(report: ReviewReport) -> str:
|
|||||||
message`, so a hit can be told apart from the summary line without a
|
message`, so a hit can be told apart from the summary line without a
|
||||||
schema - the same shape `search`'s table takes for the same reason."""
|
schema - the same shape `search`'s table takes for the same reason."""
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
if report.source is not None:
|
||||||
|
lines.append(f"Source: {report.source.kind} ({report.source.detail})")
|
||||||
if report.checks_skipped:
|
if report.checks_skipped:
|
||||||
lines.append("INCOMPLETE - the following check(s) did not run:")
|
lines.append("INCOMPLETE - the following check(s) did not run:")
|
||||||
for check, reason in report.checks_skipped:
|
for check, reason in report.checks_skipped:
|
||||||
@@ -59,6 +61,11 @@ def report_to_dict(report: ReviewReport) -> dict:
|
|||||||
],
|
],
|
||||||
"kb_project_count": report.kb_project_count,
|
"kb_project_count": report.kb_project_count,
|
||||||
"complete": report.complete,
|
"complete": report.complete,
|
||||||
|
"source": (
|
||||||
|
{"kind": report.source.kind, "detail": report.source.detail}
|
||||||
|
if report.source is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -27,7 +27,7 @@ from chemenu import config, kb_scan
|
|||||||
from chemenu.errors import ValidationError
|
from chemenu.errors import ValidationError
|
||||||
from chemenu.tasks import build_reader
|
from chemenu.tasks import build_reader
|
||||||
from chemenu.tasks import config as tasks_config
|
from chemenu.tasks import config as tasks_config
|
||||||
from chemenu.tasks.protocol import OpenItems, normalize_project_name
|
from chemenu.tasks.protocol import OpenItems, ReadSource, normalize_project_name
|
||||||
|
|
||||||
CHECK_STALLED = "stalled"
|
CHECK_STALLED = "stalled"
|
||||||
CHECK_WAITING_OVERDUE = "waiting_overdue"
|
CHECK_WAITING_OVERDUE = "waiting_overdue"
|
||||||
@@ -61,6 +61,7 @@ class ReviewReport:
|
|||||||
checks_run: tuple[str, ...]
|
checks_run: tuple[str, ...]
|
||||||
checks_skipped: tuple[tuple[str, str], ...] # (check, reason)
|
checks_skipped: tuple[tuple[str, str], ...] # (check, reason)
|
||||||
kb_project_count: int
|
kb_project_count: int
|
||||||
|
source: Optional[ReadSource]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def complete(self) -> bool:
|
def complete(self) -> bool:
|
||||||
@@ -128,6 +129,14 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
|||||||
reader = build_reader(cfg)
|
reader = build_reader(cfg)
|
||||||
kb_projects = _load_kb_projects(Path(root) / "kb")
|
kb_projects = _load_kb_projects(Path(root) / "kb")
|
||||||
|
|
||||||
|
try:
|
||||||
|
source: Optional[ReadSource] = reader.source()
|
||||||
|
except ValidationError:
|
||||||
|
# The same read that would answer this has already failed, or is
|
||||||
|
# about to below - `source` degrading to `None` here is no worse
|
||||||
|
# than the check it would have described also being skipped.
|
||||||
|
source = None
|
||||||
|
|
||||||
findings: list[Finding] = []
|
findings: list[Finding] = []
|
||||||
checks_run: list[str] = []
|
checks_run: list[str] = []
|
||||||
checks_skipped: list[tuple[str, str]] = []
|
checks_skipped: list[tuple[str, str]] = []
|
||||||
@@ -248,4 +257,5 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport:
|
|||||||
checks_run=tuple(checks_run),
|
checks_run=tuple(checks_run),
|
||||||
checks_skipped=tuple(checks_skipped),
|
checks_skipped=tuple(checks_skipped),
|
||||||
kb_project_count=len(kb_projects),
|
kb_project_count=len(kb_projects),
|
||||||
|
source=source,
|
||||||
)
|
)
|
||||||
@@ -24,11 +24,16 @@ from chemenu.tasks.protocol import TaskReader, TaskWriter
|
|||||||
|
|
||||||
|
|
||||||
def build_reader(cfg: TasksConfig) -> TaskReader:
|
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":
|
if cfg.provider == "superproductivity":
|
||||||
from chemenu.tasks import superproductivity as sp
|
from chemenu.tasks import superproductivity as sp
|
||||||
|
|
||||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
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)
|
return sp.SuperProductivityReader(sp_cfg)
|
||||||
raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.")
|
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
|
"""Dispatch on `cfg.provider` to a concrete `TaskWriter`, over an
|
||||||
already-built `reader` - a writer that needs to re-check the read path
|
already-built `reader` - a writer that needs to re-check the read path
|
||||||
(e.g. `SuperProductivityWriter`'s own collision preflight) reads through
|
(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":
|
if cfg.provider == "superproductivity":
|
||||||
from chemenu.tasks import superproductivity as sp
|
from chemenu.tasks import superproductivity as sp
|
||||||
|
|
||||||
sp_cfg = sp.SuperProductivityConfig.from_dict(cfg.provider_config)
|
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)
|
return sp.SuperProductivityWriter(sp_cfg, reader)
|
||||||
raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.")
|
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 = (
|
expected = (
|
||||||
'{"schema": 1, "provider": "superproductivity", '
|
'{"schema": 1, "provider": "superproductivity", '
|
||||||
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
|
'"thresholds": {"stalled_waiting_days": 14, "unpaged_project_weeks": 3, '
|
||||||
'"someday_stale_months": 5}, "superproductivity": {"backups_dir": "...", '
|
'"someday_stale_months": 5}, "superproductivity": {"access": "api", '
|
||||||
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}}'
|
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."} '
|
||||||
|
'(or {"access": "snapshot", "backups_dir": "..."} - see INSTALL.md)}'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
provider = str(data["provider"])
|
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
|
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()`
|
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`)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -43,12 +55,36 @@ class WaitingItem:
|
|||||||
Faelligkeitsdatum") - a provider that has no separate concept for this
|
Faelligkeitsdatum") - a provider that has no separate concept for this
|
||||||
must not fall back to reusing the due date, it must decide it cannot
|
must not fall back to reusing the due date, it must decide it cannot
|
||||||
supply the field and leave it `None` instead.
|
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
|
title: str
|
||||||
follow_up_at: Optional[date]
|
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)
|
@dataclass(frozen=True)
|
||||||
class OpenItems:
|
class OpenItems:
|
||||||
"""A project's momentary open-loop count, plus the subset that is
|
"""A project's momentary open-loop count, plus the subset that is
|
||||||
@@ -99,6 +135,15 @@ class TaskReader(Protocol):
|
|||||||
projects."""
|
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):
|
class TaskWriter(Protocol):
|
||||||
"""The write path a provider adapter offers only if it actually can
|
"""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,
|
Read and write access are chosen **per instance, explicitly, exclusively**
|
||||||
carried as its first acceptance criterion): the read path is headless file
|
(Gitea #133): a headless-operated instance sets `access: "snapshot"` and only
|
||||||
access, the write path is Super Productivity's own local REST API - so a
|
ever reads the periodic backup file on disk; a desktop instance sets
|
||||||
review can run with the app closed, and a create only needs the app open when
|
`access: "api"` and only ever talks to Super Productivity's own local REST
|
||||||
it actually has to ask it for something.
|
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
|
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.
|
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`
|
What *does* exist as a plain file is a periodic snapshot: `electron/backup.ts`
|
||||||
writes the complete app state as `JSON.stringify(data)` into
|
writes the complete app state as `JSON.stringify(data)` into
|
||||||
`<userData>/backups/<timestamp>.json` on every backup, newest-timestamp-last
|
`<userData>/backups/YYYY-MM-DD_HHmmss.json` on every backup - a fixed-width
|
||||||
by filename (its own comment: "timestamps sort lexically"). That snapshot's
|
timestamp name, so a lexical sort is also a chronological one, which is what
|
||||||
top-level shape is `AppDataComplete`/`AppDataCompleteLegacy`
|
`latest_snapshot_path` relies on. That snapshot's top-level shape is
|
||||||
(`src/app/op-log/model/model-config.ts` / `src/app/imex/sync/sync.model.ts`),
|
`AppDataComplete`/`AppDataCompleteLegacy` (`src/app/op-log/model/model-config.ts`
|
||||||
keyed by feature name - `task`, `project`, `tag`, ... - and this reader only
|
/ `src/app/imex/sync/sync.model.ts`), keyed by feature name - `task`,
|
||||||
looks at the three keys it needs, each an `@ngrx/entity` `EntityState`
|
`project`, `tag`, ... - and this reader only looks at the three keys it
|
||||||
(`{"ids": [...], "entities": {...}}`, `packages/plugin-api/src/types.ts`
|
needs, each an `@ngrx/entity` `EntityState` (`{"ids": [...], "entities": {...}}`,
|
||||||
`Task`/`Project`/`Tag`). Verified against the `master` branch of
|
`packages/plugin-api/src/types.ts` `Task`/`Project`/`Tag`). Unversioned, per
|
||||||
`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 -
|
||||||
#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
|
||||||
which is exactly why every read below fails loudly on a shape it does not
|
guessing.
|
||||||
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
|
Only the `WAITING` status and `follow_up_at` are machine-readable, and Super
|
||||||
Productivity has no native field for either:
|
Productivity has no native field for either:
|
||||||
|
|
||||||
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
|
- **`WAITING`** is a tag named `waiting` (case-insensitively), attached to the
|
||||||
task. Any other tag is left alone.
|
task. Any other tag is left alone.
|
||||||
- **`follow_up_at`** is the task's own `remindAt` (a reminder timestamp) -
|
- **`follow_up_at`** is the task's own *scheduled* date - `dueWithTime` if
|
||||||
deliberately not `dueDay`/`dueWithTime` (#119 D9: "ausdruecklich nicht das
|
set, else `dueDay` (`task.model.ts`'s own read rule: "check dueWithTime
|
||||||
Faelligkeitsdatum"). A task with no reminder set has no `follow_up_at`, full
|
FIRST"). This is **not** the earlier `remindAt` mapping from #124: `remindAt`
|
||||||
stop; this adapter never substitutes the due date for it.
|
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
|
## Someday/Maybe: a project's own backlog
|
||||||
|
|
||||||
#124 (later, #119) left this as "zu verifizieren": Super Productivity's
|
Super Productivity's `ProjectBasicCfg.backlogTaskIds` is exactly this - a
|
||||||
`ProjectBasicCfg.backlogTaskIds` is exactly this - a second, separate list of
|
second, separate list of task ids per project, apart from the active
|
||||||
task ids per project, apart from the active `taskIds` list a project's board
|
`taskIds` list a project's board shows. This adapter reads someday/maybe
|
||||||
shows. This adapter reads someday/maybe items from `backlogTaskIds`, one
|
items from `backlogTaskIds`, one project at a time; no tag convention is
|
||||||
project at a time; no tag convention is needed.
|
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
|
Neither transport routes `POST /projects` - task CRUD exists, project CRUD
|
||||||
same day) routes `GET /projects` but has no `POST /projects` at all - task
|
does not, verified the same day as the rest of this module. So
|
||||||
CRUD exists, project CRUD does not. `SuperProductivityWriter.create_project`
|
`SuperProductivityWriter.create_project` can never create a project itself
|
||||||
can therefore not create a project itself; see its docstring and
|
regardless of `access`; see its docstring and
|
||||||
`chemenu.errors.HumanInterventionRequired`.
|
`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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -67,6 +97,7 @@ from chemenu.errors import HumanInterventionRequired, ValidationError
|
|||||||
from chemenu.tasks.protocol import (
|
from chemenu.tasks.protocol import (
|
||||||
OpenItems,
|
OpenItems,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
|
ReadSource,
|
||||||
SomedayItem,
|
SomedayItem,
|
||||||
WaitingItem,
|
WaitingItem,
|
||||||
find_project,
|
find_project,
|
||||||
@@ -79,82 +110,119 @@ WAITING_TAG_TITLE = "waiting"
|
|||||||
|
|
||||||
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
DEFAULT_API_BASE_URL = "http://127.0.0.1:3876"
|
||||||
|
|
||||||
_EXPECTED_PROVIDER_CONFIG = (
|
ACCESS_API = "api"
|
||||||
'{"backups_dir": "~/.config/superProductivity/backups", '
|
ACCESS_SNAPSHOT = "snapshot"
|
||||||
'"api_base_url": "http://127.0.0.1:3876", "api_token": "..."}'
|
KNOWN_ACCESS = (ACCESS_API, ACCESS_SNAPSHOT)
|
||||||
' (or "db_path" instead of "backups_dir" to pin one exact file)'
|
|
||||||
|
# `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)
|
@dataclass(frozen=True)
|
||||||
class SuperProductivityConfig:
|
class SuperProductivityConfig:
|
||||||
"""This provider's own section of `.wikitool-tasks.json`
|
"""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
|
access: str
|
||||||
# both are set - it names one exact file, which is a stronger statement
|
|
||||||
# than "the newest file in this directory".
|
|
||||||
backups_dir: Optional[Path]
|
backups_dir: Optional[Path]
|
||||||
db_path: Optional[Path]
|
|
||||||
api_base_url: str
|
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]
|
api_token: Optional[str]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
|
def from_dict(cls, data: dict[str, Any]) -> "SuperProductivityConfig":
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValidationError(
|
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")
|
backups_dir = data.get("backups_dir")
|
||||||
db_path = data.get("db_path")
|
if not backups_dir:
|
||||||
if backups_dir is None and db_path is None:
|
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"superproductivity config needs 'backups_dir' or 'db_path' - the read path has "
|
"superproductivity config: backups_dir is required when access: 'snapshot' - "
|
||||||
f"nothing to read otherwise. Expected: {_EXPECTED_PROVIDER_CONFIG}"
|
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(
|
return cls(
|
||||||
backups_dir=Path(backups_dir).expanduser() if backups_dir else None,
|
access=access,
|
||||||
db_path=Path(db_path).expanduser() if db_path else None,
|
backups_dir=Path(backups_dir).expanduser(),
|
||||||
api_base_url=api_base_url,
|
api_base_url=DEFAULT_API_BASE_URL,
|
||||||
api_token=api_token,
|
api_token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
|
def latest_snapshot_path(cfg: SuperProductivityConfig) -> Path:
|
||||||
"""The one file to read: `db_path` if given, else the lexically-greatest
|
"""The lexically-greatest `YYYY-MM-DD_HHmmss.json` filename under
|
||||||
`*.json` filename under `backups_dir` - the same ordering
|
`backups_dir` - the same ordering `electron/backup.ts` writes by
|
||||||
`electron/backup.ts` itself relies on ("timestamps sort lexically")."""
|
construction. Only files matching that exact pattern are candidates
|
||||||
if cfg.db_path is not None:
|
(Gitea #133): a manual export (`sp-backup_*.json` and its variants) sorts
|
||||||
if not cfg.db_path.is_file():
|
lexically *after* every timestamp and would otherwise pin every reader to
|
||||||
raise ValidationError(
|
itself forever."""
|
||||||
f"superproductivity: db_path does not exist: {cfg.db_path}"
|
|
||||||
)
|
|
||||||
return cfg.db_path
|
|
||||||
directory = cfg.backups_dir
|
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():
|
if not directory.is_dir():
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"superproductivity: backups_dir does not exist: {directory}"
|
f"superproductivity: backups_dir does not exist: {directory}"
|
||||||
)
|
)
|
||||||
candidates = sorted(directory.glob("*.json"))
|
candidates = sorted(directory.glob(_SNAPSHOT_GLOB))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"superproductivity: no *.json backup file found under {directory}. Take a "
|
f"superproductivity: no timestamped backup file (YYYY-MM-DD_HHmmss.json) found "
|
||||||
"backup from Super Productivity (Settings -> Backup & Sync -> Local backups), "
|
f"under {directory}. Take a backup from Super Productivity (Settings -> Backup & "
|
||||||
"or point db_path/backups_dir at where it actually writes them."
|
"Sync -> Local backups), or point backups_dir at where it actually writes them."
|
||||||
)
|
)
|
||||||
return candidates[-1]
|
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]:
|
def _load_snapshot(cfg: SuperProductivityConfig) -> dict[str, Any]:
|
||||||
path = latest_snapshot_path(cfg)
|
path = latest_snapshot_path(cfg)
|
||||||
try:
|
try:
|
||||||
@@ -194,10 +262,37 @@ def _epoch_ms_to_date(value: Any) -> Optional[date]:
|
|||||||
return datetime.fromtimestamp(value / 1000, tz=timezone.utc).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:
|
class SuperProductivityReader:
|
||||||
"""`TaskReader` over a Super Productivity backup snapshot. Re-reads the
|
"""`TaskReader` over a Super Productivity backup snapshot
|
||||||
snapshot on every call - see `chemenu.errors.HumanInterventionRequired`
|
(`access: "snapshot"`). Re-reads the snapshot on every call - see
|
||||||
for why that matters."""
|
`chemenu.errors.HumanInterventionRequired` for why that matters."""
|
||||||
|
|
||||||
def __init__(self, cfg: SuperProductivityConfig):
|
def __init__(self, cfg: SuperProductivityConfig):
|
||||||
self._cfg = cfg
|
self._cfg = cfg
|
||||||
@@ -208,6 +303,11 @@ class SuperProductivityReader:
|
|||||||
projects = _entity_state(snapshot, "project", path)
|
projects = _entity_state(snapshot, "project", path)
|
||||||
tasks = _entity_state(snapshot, "task", path)
|
tasks = _entity_state(snapshot, "task", path)
|
||||||
tags = _entity_state(snapshot, "tag", 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
|
return path, projects, tasks, tags
|
||||||
|
|
||||||
def projects(self) -> list[ProjectSummary]:
|
def projects(self) -> list[ProjectSummary]:
|
||||||
@@ -220,13 +320,6 @@ class SuperProductivityReader:
|
|||||||
for record in projects.values()
|
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:
|
def open_items(self, project_name: str) -> OpenItems:
|
||||||
_, projects, tasks, tags = self._read()
|
_, projects, tasks, tags = self._read()
|
||||||
target = normalize_project_name(project_name)
|
target = normalize_project_name(project_name)
|
||||||
@@ -244,12 +337,9 @@ class SuperProductivityReader:
|
|||||||
if task is None or task.get("isDone"):
|
if task is None or task.get("isDone"):
|
||||||
continue
|
continue
|
||||||
count += 1
|
count += 1
|
||||||
if self._is_waiting(task, tags):
|
if _is_waiting(task, tags):
|
||||||
waiting.append(
|
waiting.append(
|
||||||
WaitingItem(
|
WaitingItem(title=str(task.get("title", "")), follow_up_at=_follow_up_at(task))
|
||||||
title=str(task.get("title", "")),
|
|
||||||
follow_up_at=_epoch_ms_to_date(task.get("remindAt")),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return OpenItems(count=count, waiting=tuple(waiting))
|
return OpenItems(count=count, waiting=tuple(waiting))
|
||||||
|
|
||||||
@@ -269,6 +359,14 @@ class SuperProductivityReader:
|
|||||||
)
|
)
|
||||||
return items
|
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:
|
def health(cfg: SuperProductivityConfig, *, timeout: float = 2.0) -> bool:
|
||||||
"""Whether the local REST API answers `GET /health` right now - the one
|
"""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
|
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:
|
class SuperProductivityWriter:
|
||||||
"""`TaskWriter` over the local REST API - except there is no API call
|
"""`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._cfg = cfg
|
||||||
self._reader = reader
|
self._reader = reader
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ def test_tasks_provider_fails_on_a_bad_superproductivity_section(instance):
|
|||||||
|
|
||||||
|
|
||||||
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance):
|
def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(instance):
|
||||||
|
"""`access: snapshot` - only the read path is a finding; the API is not
|
||||||
|
even attempted (Gitea #133)."""
|
||||||
backups = config.ROOT / "sp-backups"
|
backups = config.ROOT / "sp-backups"
|
||||||
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
@@ -379,14 +381,35 @@ def test_tasks_provider_ok_but_names_the_unready_read_path_when_configured(insta
|
|||||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
||||||
"someday_stale_months": 5,
|
"someday_stale_months": 5,
|
||||||
},
|
},
|
||||||
"superproductivity": {"backups_dir": str(backups)},
|
"superproductivity": {"access": "snapshot", "backups_dir": str(backups)},
|
||||||
}),
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
checks = doctor.run_doctor()
|
checks = doctor.run_doctor()
|
||||||
assert _status(checks, "tasks-provider") == "OK"
|
assert _status(checks, "tasks-provider") == "OK"
|
||||||
detail = _detail(checks, "tasks-provider")
|
detail = _detail(checks, "tasks-provider")
|
||||||
|
assert "access=snapshot" in detail
|
||||||
assert "not ready" in detail
|
assert "not ready" in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance):
|
||||||
|
"""`access: api` - the API reachability is the finding; the (irrelevant)
|
||||||
|
backup file is not even looked at (Gitea #133)."""
|
||||||
|
(config.ROOT / config.TASKS_CONFIG_FILENAME).write_text(
|
||||||
|
json.dumps({
|
||||||
|
"schema": 1, "provider": "superproductivity",
|
||||||
|
"thresholds": {
|
||||||
|
"stalled_waiting_days": 14, "unpaged_project_weeks": 3,
|
||||||
|
"someday_stale_months": 5,
|
||||||
|
},
|
||||||
|
"superproductivity": {"access": "api", "api_base_url": "http://127.0.0.1:1", "api_token": "t"},
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
checks = doctor.run_doctor()
|
||||||
|
assert _status(checks, "tasks-provider") == "OK"
|
||||||
|
detail = _detail(checks, "tasks-provider")
|
||||||
|
assert "access=api" in detail
|
||||||
assert "not reachable" in detail
|
assert "not reachable" in detail
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import contextlib
|
||||||
|
import http.server
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from chemenu.commands._util import coerce_set_value, parse_set_fields
|
from chemenu.commands._util import coerce_set_value, parse_set_fields
|
||||||
from chemenu.commands.new_page import _page_subdir
|
from chemenu.commands.new_page import _page_subdir
|
||||||
@@ -12,34 +16,50 @@ _TASKS_THRESHOLDS = {
|
|||||||
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
|
"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_API_TOKEN = "test-token"
|
||||||
|
|
||||||
def _write_tasks_config(root, db_path) -> None:
|
|
||||||
|
def _write_tasks_config(root, base_url: str) -> None:
|
||||||
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
|
"""A minimal `.wikitool-tasks.json` pointing the superproductivity
|
||||||
provider at `db_path` - same shape `test_review.py`'s own helper writes,
|
provider's `access: "api"` path at `base_url` (Gitea #133) - `new
|
||||||
duplicated locally rather than imported since test files in this suite
|
project` only ever has a write path on that access mode. Duplicated
|
||||||
do not import each other (see `instructions/dev/testing-conventions.md`
|
locally rather than imported since test files in this suite do not
|
||||||
for the isolation this mirrors one layer up)."""
|
import each other (see `instructions/dev/testing-conventions.md` for the
|
||||||
|
isolation this mirrors one layer up)."""
|
||||||
(root / ".wikitool-tasks.json").write_text(
|
(root / ".wikitool-tasks.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
|
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
|
||||||
"superproductivity": {"db_path": str(db_path)},
|
"superproductivity": {"access": "api", "api_base_url": base_url, "api_token": _API_TOKEN},
|
||||||
}),
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_sp_snapshot(root, project_titles: list[str]):
|
def _write_tasks_config_snapshot(root, backups_dir) -> None:
|
||||||
"""A Super Productivity backup snapshot whose `project` entity state
|
"""The other access path (Gitea #133) - used only by the tests below
|
||||||
holds one project per title in `project_titles` and nothing else -
|
that confirm `new project` refuses entirely against it."""
|
||||||
enough for `find_project`/`create_project`'s own preflight, which is all
|
(root / ".wikitool-tasks.json").write_text(
|
||||||
`new project`'s tracker step reads or writes (it never calls
|
json.dumps({
|
||||||
`open_items`/`someday_items`)."""
|
"schema": 1, "provider": "superproductivity", "thresholds": _TASKS_THRESHOLDS,
|
||||||
db_path = root / "db.json"
|
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
|
||||||
|
}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sp_backup(root, project_titles: list[str]) -> Path:
|
||||||
|
"""A Super Productivity backup snapshot, named the way `electron/backup.ts`
|
||||||
|
actually names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133), whose `project`
|
||||||
|
entity state holds one project per title in `project_titles` and nothing
|
||||||
|
else. Used only by the `access: "snapshot"` refusal tests - that path
|
||||||
|
never reaches a tracker write regardless of what this file contains."""
|
||||||
|
backups_dir = root / "backups"
|
||||||
|
backups_dir.mkdir(exist_ok=True)
|
||||||
projects = {
|
projects = {
|
||||||
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
|
f"p{i}": {"id": f"p{i}", "title": title, "created": 1700000000000}
|
||||||
for i, title in enumerate(project_titles)
|
for i, title in enumerate(project_titles)
|
||||||
}
|
}
|
||||||
db_path.write_text(
|
(backups_dir / "2026-01-01_000000.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"project": {"ids": list(projects), "entities": projects},
|
"project": {"ids": list(projects), "entities": projects},
|
||||||
"task": {"ids": [], "entities": {}},
|
"task": {"ids": [], "entities": {}},
|
||||||
@@ -47,7 +67,69 @@ def _write_sp_snapshot(root, project_titles: list[str]):
|
|||||||
}),
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return db_path
|
return backups_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _make_api_handler(state: dict):
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
posted = False
|
||||||
|
|
||||||
|
def do_GET(self): # noqa: N802 - stdlib method name
|
||||||
|
if self.path == "/health":
|
||||||
|
self._reply(200, {"ok": True})
|
||||||
|
return
|
||||||
|
if self.headers.get("Authorization") != f"Bearer {_API_TOKEN}":
|
||||||
|
self._reply(401, {"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
if self.path == "/projects":
|
||||||
|
self._reply(200, state["projects"])
|
||||||
|
return
|
||||||
|
if self.path in ("/tasks", "/tags"):
|
||||||
|
self._reply(200, [])
|
||||||
|
return
|
||||||
|
self._reply(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self): # noqa: N802
|
||||||
|
# `new project` must never attempt this on the API path either -
|
||||||
|
# there is no POST /projects endpoint upstream (Gitea #124/#133).
|
||||||
|
Handler.posted = True
|
||||||
|
self._reply(404, {"error": "no such endpoint (fixture)"})
|
||||||
|
|
||||||
|
def _reply(self, code: int, payload) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args): # silence stderr noise during the test run
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Handler
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _api_server(project_titles: list[str]):
|
||||||
|
"""A stub Super Productivity local REST API serving just enough of
|
||||||
|
`GET /projects`/`/tasks`/`/tags` for `find_project`/`create_project`'s
|
||||||
|
own preflight - the only thing `new project`'s tracker step reads."""
|
||||||
|
state = {"projects": [
|
||||||
|
{"id": f"p{i}", "title": title, "created": 1700000000000, "taskIds": [], "backlogTaskIds": []}
|
||||||
|
for i, title in enumerate(project_titles)
|
||||||
|
]}
|
||||||
|
handler_cls = _make_api_handler(state)
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield server, handler_cls
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(server: http.server.HTTPServer) -> str:
|
||||||
|
return f"http://127.0.0.1:{server.server_address[1]}"
|
||||||
|
|
||||||
|
|
||||||
def _invoke_new(monkeypatch, kb_dir, args):
|
def _invoke_new(monkeypatch, kb_dir, args):
|
||||||
@@ -174,33 +256,35 @@ def test_new_project_says_explicitly_when_no_tracker_is_configured(monkeypatch,
|
|||||||
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
|
def test_new_project_needs_clearance_when_the_tracker_has_no_write_path(monkeypatch, kb_dir):
|
||||||
"""Super Productivity can never create a project itself (Gitea #124) -
|
"""Super Productivity can never create a project itself (Gitea #124) -
|
||||||
the first attempt against a free name must exit 42 and create nothing on
|
the first attempt against a free name must exit 42 and create nothing on
|
||||||
either side."""
|
either side, and never attempt a POST (Gitea #133: no such endpoint
|
||||||
|
exists on the API path either)."""
|
||||||
root = kb_dir.parent
|
root = kb_dir.parent
|
||||||
db_path = _write_sp_snapshot(root, [])
|
with _api_server([]) as (server, handler_cls):
|
||||||
_write_tasks_config(root, db_path)
|
_write_tasks_config(root, _base_url(server))
|
||||||
|
|
||||||
result = _invoke_new(monkeypatch, kb_dir, [
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||||
])
|
])
|
||||||
assert result.exit_code == 42
|
assert result.exit_code == 42
|
||||||
assert "NEEDS USER CLEARANCE" in result.output
|
assert "NEEDS USER CLEARANCE" in result.output
|
||||||
assert "--resume" in result.output
|
assert "--resume" in result.output
|
||||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
assert handler_cls.posted is False
|
||||||
|
|
||||||
|
|
||||||
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
|
def test_new_project_refuses_a_tracker_collision_without_resume(monkeypatch, kb_dir):
|
||||||
"""The tracker already has this name (case-insensitively) and --resume
|
"""The tracker already has this name (case-insensitively) and --resume
|
||||||
was not passed - #126's AC: refuse, name the collision, create nothing."""
|
was not passed - #126's AC: refuse, name the collision, create nothing."""
|
||||||
root = kb_dir.parent
|
root = kb_dir.parent
|
||||||
db_path = _write_sp_snapshot(root, ["kueche renovieren"])
|
with _api_server(["kueche renovieren"]) as (server, _handler_cls):
|
||||||
_write_tasks_config(root, db_path)
|
_write_tasks_config(root, _base_url(server))
|
||||||
|
|
||||||
result = _invoke_new(monkeypatch, kb_dir, [
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||||
])
|
])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "already exists" in result.output
|
assert "already exists" in result.output
|
||||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
|
def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypatch, kb_dir):
|
||||||
@@ -209,15 +293,15 @@ def test_new_project_resume_continues_past_an_existing_tracker_project(monkeypat
|
|||||||
re-run with --resume must verify it via the read path and continue to
|
re-run with --resume must verify it via the read path and continue to
|
||||||
page creation instead of treating it as a collision."""
|
page creation instead of treating it as a collision."""
|
||||||
root = kb_dir.parent
|
root = kb_dir.parent
|
||||||
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
|
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
|
||||||
_write_tasks_config(root, db_path)
|
_write_tasks_config(root, _base_url(server))
|
||||||
|
|
||||||
result = _invoke_new(monkeypatch, kb_dir, [
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||||
])
|
])
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert "already existed" in result.output
|
assert "already existed" in result.output
|
||||||
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
assert (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
|
||||||
|
|
||||||
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
|
def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monkeypatch, kb_dir):
|
||||||
@@ -226,13 +310,45 @@ def test_new_project_resume_still_refuses_when_the_human_has_not_acted_yet(monke
|
|||||||
message again, not a silent pass-through (#126's own wording: "wirft das
|
message again, not a silent pass-through (#126's own wording: "wirft das
|
||||||
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
|
Kommando dieselbe HumanInterventionRequired-Meldung erneut")."""
|
||||||
root = kb_dir.parent
|
root = kb_dir.parent
|
||||||
db_path = _write_sp_snapshot(root, [])
|
with _api_server([]) as (server, _handler_cls):
|
||||||
_write_tasks_config(root, db_path)
|
_write_tasks_config(root, _base_url(server))
|
||||||
|
|
||||||
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 42
|
||||||
|
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_project_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
|
||||||
|
"""Gitea #133: `access: "snapshot"` is read-only from here - `new
|
||||||
|
project` creates neither a tracker project nor a page and exits 1, not
|
||||||
|
42 (nothing is waiting on a human's clearance, the command simply cannot
|
||||||
|
do this from a snapshot instance), pointing at an access: "api" one."""
|
||||||
|
root = kb_dir.parent
|
||||||
|
backups_dir = _write_sp_backup(root, [])
|
||||||
|
_write_tasks_config_snapshot(root, backups_dir)
|
||||||
|
|
||||||
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "access: 'api'" in result.output
|
||||||
|
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_project_resume_also_refuses_entirely_on_snapshot_access(monkeypatch, kb_dir):
|
||||||
|
"""--resume changes nothing about the snapshot-access refusal (Gitea
|
||||||
|
#133's own AC) - even against a tracker that already has the project."""
|
||||||
|
root = kb_dir.parent
|
||||||
|
backups_dir = _write_sp_backup(root, ["Kueche renovieren"])
|
||||||
|
_write_tasks_config_snapshot(root, backups_dir)
|
||||||
|
|
||||||
result = _invoke_new(monkeypatch, kb_dir, [
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||||
])
|
])
|
||||||
assert result.exit_code == 42
|
assert result.exit_code == 1
|
||||||
|
assert "access: 'api'" in result.output
|
||||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
|
||||||
|
|
||||||
@@ -253,17 +369,18 @@ def test_new_project_never_leaves_only_the_page_when_the_write_fails(monkeypatch
|
|||||||
import chemenu.commands.new_page as new_page
|
import chemenu.commands.new_page as new_page
|
||||||
|
|
||||||
root = kb_dir.parent
|
root = kb_dir.parent
|
||||||
db_path = _write_sp_snapshot(root, ["Kueche renovieren"])
|
|
||||||
_write_tasks_config(root, db_path)
|
|
||||||
|
|
||||||
def _boom(path, frontmatter, body):
|
def _boom(path, frontmatter, body):
|
||||||
raise OSError("disk full (fixture)")
|
raise OSError("disk full (fixture)")
|
||||||
|
|
||||||
monkeypatch.setattr(new_page, "write_page", _boom)
|
monkeypatch.setattr(new_page, "write_page", _boom)
|
||||||
|
|
||||||
result = _invoke_new(monkeypatch, kb_dir, [
|
with _api_server(["Kueche renovieren"]) as (server, _handler_cls):
|
||||||
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
_write_tasks_config(root, _base_url(server))
|
||||||
])
|
|
||||||
|
result = _invoke_new(monkeypatch, kb_dir, [
|
||||||
|
"new", "project", "--name", "Kueche renovieren", "--set", "responsibility=haus", "--resume",
|
||||||
|
])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "already confirmed to exist" in " ".join(result.output.split())
|
assert "already confirmed to exist" in " ".join(result.output.split())
|
||||||
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
assert not (kb_dir / "gtd/haus/Kueche renovieren.md").exists()
|
||||||
|
|||||||
@@ -51,8 +51,14 @@ def _entity_state(records: dict[str, dict]) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path:
|
def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path:
|
||||||
db_path = root / "db.json"
|
"""Writes a Super Productivity backup snapshot into a fresh `backups/`
|
||||||
db_path.write_text(
|
directory under `root`, named the way `electron/backup.ts` actually
|
||||||
|
names it (`YYYY-MM-DD_HHmmss.json`, Gitea #133) so the hardened glob in
|
||||||
|
`latest_snapshot_path` picks it up. Returns the `backups_dir`, not the
|
||||||
|
file itself - that is what a `superproductivity` config section names."""
|
||||||
|
backups_dir = root / "backups"
|
||||||
|
backups_dir.mkdir(exist_ok=True)
|
||||||
|
(backups_dir / "2026-01-01_000000.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"project": _entity_state(projects),
|
"project": _entity_state(projects),
|
||||||
"task": _entity_state(tasks),
|
"task": _entity_state(tasks),
|
||||||
@@ -60,16 +66,16 @@ def _write_snapshot(root: Path, projects: dict, tasks: dict, tags: dict) -> Path
|
|||||||
}),
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
return db_path
|
return backups_dir
|
||||||
|
|
||||||
|
|
||||||
def _write_tasks_config(root: Path, db_path: Path, thresholds: dict | None = None) -> None:
|
def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None = None) -> None:
|
||||||
(root / ".wikitool-tasks.json").write_text(
|
(root / ".wikitool-tasks.json").write_text(
|
||||||
json.dumps({
|
json.dumps({
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"provider": "superproductivity",
|
"provider": "superproductivity",
|
||||||
"thresholds": thresholds or THRESHOLDS,
|
"thresholds": thresholds or THRESHOLDS,
|
||||||
"superproductivity": {"db_path": str(db_path)},
|
"superproductivity": {"access": "snapshot", "backups_dir": str(backups_dir)},
|
||||||
}),
|
}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -110,8 +116,8 @@ def _tree(root: Path) -> dict[str, bytes]:
|
|||||||
def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire):
|
def test_check1_stalled_only_fires_for_active(tmp_path, state, should_fire):
|
||||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
"taskIds": [], "backlogTaskIds": []}}
|
"taskIds": [], "backlogTaskIds": []}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Ship Chemenu 7.0", state)
|
_project_page(tmp_path, "Ship Chemenu 7.0", state)
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
@@ -131,10 +137,10 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
|
|||||||
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
|
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
|
||||||
"taskIds": ["t1"], "backlogTaskIds": []}}
|
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||||
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
|
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
|
||||||
"tagIds": ["tag-wait"], "remindAt": _ms(2026, month, remind_day)}}
|
"tagIds": ["tag-wait"], "dueWithTime": _ms(2026, month, remind_day)}}
|
||||||
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, tasks, tags)
|
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Kueche renovieren", "active")
|
_project_page(tmp_path, "Kueche renovieren", "active")
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
@@ -142,6 +148,23 @@ def test_check2_waiting_overdue_threshold(tmp_path, remind_day, should_fire):
|
|||||||
assert fired == should_fire
|
assert fired == should_fire
|
||||||
|
|
||||||
|
|
||||||
|
def test_check2_fires_for_an_all_day_waiting_item_with_no_due_with_time(tmp_path):
|
||||||
|
"""The gap Gitea #135 closed: a `waiting` task scheduled all-day
|
||||||
|
(`dueDay`, no `dueWithTime`, no reminder) must still surface as overdue -
|
||||||
|
under #124's original `remindAt` mapping it silently never did."""
|
||||||
|
projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||||
|
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot - Tobias", "isDone": False,
|
||||||
|
"tagIds": ["tag-wait"], "dueDay": "2026-01-01"}}
|
||||||
|
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
||||||
|
backups_dir = _write_snapshot(tmp_path, projects, tasks, tags)
|
||||||
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
|
_project_page(tmp_path, "Kueche renovieren", "active")
|
||||||
|
|
||||||
|
report = run_review(tmp_path, today=TODAY)
|
||||||
|
assert any(f.check == CHECK_WAITING_OVERDUE for f in report.findings)
|
||||||
|
|
||||||
|
|
||||||
# --- check 3: unpaged tracker project ----------------------------------------
|
# --- check 3: unpaged tracker project ----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -153,8 +176,8 @@ def test_check3_unpaged_project_age_threshold(tmp_path, created_year_month_day,
|
|||||||
y, m, d = created_year_month_day
|
y, m, d = created_year_month_day
|
||||||
projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d),
|
projects = {"p1": {"id": "p1", "title": "No Page Yet", "created": _ms(y, m, d),
|
||||||
"taskIds": [], "backlogTaskIds": []}}
|
"taskIds": [], "backlogTaskIds": []}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
(tmp_path / "kb" / "gtd").mkdir(parents=True)
|
(tmp_path / "kb" / "gtd").mkdir(parents=True)
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
@@ -168,8 +191,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
|
|||||||
projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1),
|
projects = {"p1": {"id": "p1", "title": "kueche renovieren", "created": _ms(2020, 1, 1),
|
||||||
"taskIds": ["t1"], "backlogTaskIds": []}}
|
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||||
tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}}
|
tasks = {"t1": {"id": "t1", "title": "Irgendwas tun", "isDone": False, "tagIds": []}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, tasks, {})
|
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Kueche renovieren", "active")
|
_project_page(tmp_path, "Kueche renovieren", "active")
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
@@ -181,8 +204,8 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_check4_fires_when_no_tracker_project_exists(tmp_path):
|
def test_check4_fires_when_no_tracker_project_exists(tmp_path):
|
||||||
db_path = _write_snapshot(tmp_path, {}, {}, {})
|
backups_dir = _write_snapshot(tmp_path, {}, {}, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Ghost Project", "active")
|
_project_page(tmp_path, "Ghost Project", "active")
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
@@ -199,8 +222,8 @@ def test_a_run_touches_no_file(tmp_path):
|
|||||||
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
||||||
tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [],
|
tasks = {"t3": {"id": "t3", "title": "Someday item", "isDone": False, "tagIds": [],
|
||||||
"updated": _ms(2020, 1, 1)}}
|
"updated": _ms(2020, 1, 1)}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, tasks, {})
|
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
||||||
|
|
||||||
before = _tree(tmp_path)
|
before = _tree(tmp_path)
|
||||||
@@ -221,8 +244,8 @@ def test_check5_someday_stale_threshold(tmp_path, updated_ymd, should_fire):
|
|||||||
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
"taskIds": [], "backlogTaskIds": ["t3"]}}
|
||||||
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
|
tasks = {"t3": {"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False,
|
||||||
"tagIds": [], "updated": _ms(y, m, 1)}}
|
"tagIds": [], "updated": _ms(y, m, 1)}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, tasks, {})
|
backups_dir = _write_snapshot(tmp_path, projects, tasks, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings)
|
fired = any(f.check == CHECK_SOMEDAY_STALE for f in report.findings)
|
||||||
@@ -239,10 +262,10 @@ def test_no_tasks_config_is_a_clear_validation_error(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path):
|
def test_unreachable_provider_skips_the_project_list_checks_but_not_someday(tmp_path):
|
||||||
"""A db_path that does not exist is the unreachable-provider case: checks
|
"""A backups_dir that does not exist is the unreachable-provider case: checks
|
||||||
1/2/3/4 cannot run at all, but check 5 uses the same failing read and is
|
1/2/3/4 cannot run at all, but check 5 uses the same failing read and is
|
||||||
skipped too - the report must say so, never look like a quiet week."""
|
skipped too - the report must say so, never look like a quiet week."""
|
||||||
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json")
|
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
|
||||||
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
||||||
|
|
||||||
report = run_review(tmp_path, today=TODAY)
|
report = run_review(tmp_path, today=TODAY)
|
||||||
@@ -275,7 +298,7 @@ def test_cli_incomplete_report_exits_1_and_still_prints(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(config, "ROOT", tmp_path)
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
use_shipped_type_specs(monkeypatch)
|
use_shipped_type_specs(monkeypatch)
|
||||||
_write_tasks_config(tmp_path, tmp_path / "does-not-exist.json")
|
_write_tasks_config(tmp_path, tmp_path / "does-not-exist-dir")
|
||||||
(tmp_path / "kb" / "gtd").mkdir(parents=True)
|
(tmp_path / "kb" / "gtd").mkdir(parents=True)
|
||||||
|
|
||||||
result = runner.invoke(app, ["review"])
|
result = runner.invoke(app, ["review"])
|
||||||
@@ -294,8 +317,8 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
|
|||||||
use_shipped_type_specs(monkeypatch)
|
use_shipped_type_specs(monkeypatch)
|
||||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
"taskIds": [], "backlogTaskIds": []}}
|
"taskIds": [], "backlogTaskIds": []}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
||||||
|
|
||||||
text_result = runner.invoke(app, ["review"])
|
text_result = runner.invoke(app, ["review"])
|
||||||
@@ -321,6 +344,39 @@ def test_cli_json_and_text_agree_on_findings(tmp_path, monkeypatch):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_names_its_source_and_the_snapshot_age(tmp_path):
|
||||||
|
"""Gitea #133's own AC: every answer says which access path it came from,
|
||||||
|
and a snapshot answer says how old it is."""
|
||||||
|
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": [], "backlogTaskIds": []}}
|
||||||
|
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||||
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
|
|
||||||
|
report = run_review(tmp_path, today=TODAY)
|
||||||
|
assert report.source is not None
|
||||||
|
assert report.source.kind == "snapshot"
|
||||||
|
assert "day(s) old" in report.source.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_shows_the_source_in_both_render_forms(tmp_path, monkeypatch):
|
||||||
|
from chemenu import config
|
||||||
|
|
||||||
|
monkeypatch.setattr(config, "ROOT", tmp_path)
|
||||||
|
use_shipped_type_specs(monkeypatch)
|
||||||
|
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": [], "backlogTaskIds": []}}
|
||||||
|
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||||
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
|
|
||||||
|
text_result = runner.invoke(app, ["review"])
|
||||||
|
json_result = runner.invoke(app, ["review", "--json"])
|
||||||
|
assert "Source: snapshot" in text_result.output
|
||||||
|
|
||||||
|
payload = json.loads(json_result.output)
|
||||||
|
assert payload["source"]["kind"] == "snapshot"
|
||||||
|
assert "day(s) old" in payload["source"]["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_review_is_exempt_from_the_iteration_budget():
|
def test_review_is_exempt_from_the_iteration_budget():
|
||||||
assert is_exempt("review", []) is True
|
assert is_exempt("review", []) is True
|
||||||
assert is_exempt("review", ["--json"]) is True
|
assert is_exempt("review", ["--json"]) is True
|
||||||
@@ -339,8 +395,8 @@ def test_a_run_leaves_the_git_tree_untouched(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
"taskIds": [], "backlogTaskIds": []}}
|
"taskIds": [], "backlogTaskIds": []}}
|
||||||
db_path = _write_snapshot(tmp_path, projects, {}, {})
|
backups_dir = _write_snapshot(tmp_path, projects, {}, {})
|
||||||
_write_tasks_config(tmp_path, db_path)
|
_write_tasks_config(tmp_path, backups_dir)
|
||||||
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
_project_page(tmp_path, "Ship Chemenu 7.0", "active")
|
||||||
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
|
subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True)
|
||||||
subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True)
|
subprocess.run(["git", "commit", "-q", "-m", "fixture"], cwd=tmp_path, check=True)
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124).
|
"""Tests for `chemenu.tasks.superproductivity` (Gitea #124, #133, #135).
|
||||||
|
|
||||||
The fixture snapshot below mirrors the real shape verified against
|
The fixture snapshot below mirrors the real shape verified against
|
||||||
`super-productivity/super-productivity`'s `master` branch on 2026-09-19: a
|
`super-productivity/super-productivity`'s `master` branch: a flat top-level
|
||||||
flat top-level object with `project`/`task`/`tag` as `@ngrx/entity`
|
object with `project`/`task`/`tag` as `@ngrx/entity` `{"ids": [...],
|
||||||
`{"ids": [...], "entities": {...}}` maps (see the module docstring for the
|
"entities": {...}}` maps for the snapshot path, and the same records as a
|
||||||
exact source files). No test here starts a real Super Productivity instance,
|
flat list (the local REST API's own shape) for the API path (see the module
|
||||||
touches the network beyond a loopback socket, or reads a path under the
|
docstring for the exact source files). No test here starts a real Super
|
||||||
developer's home - everything is built inside `tmp_path`
|
Productivity instance or touches anything beyond a loopback socket and
|
||||||
(`instructions/dev/testing-conventions.md`).
|
`tmp_path` (`instructions/dev/testing-conventions.md`).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ def _snapshot() -> dict:
|
|||||||
"projectId": "p1",
|
"projectId": "p1",
|
||||||
"isDone": False,
|
"isDone": False,
|
||||||
"tagIds": ["tag-wait"],
|
"tagIds": ["tag-wait"],
|
||||||
"remindAt": _ms(2026, 3, 1),
|
"dueWithTime": _ms(2026, 3, 1),
|
||||||
},
|
},
|
||||||
"t2": {
|
"t2": {
|
||||||
"id": "t2",
|
"id": "t2",
|
||||||
@@ -94,73 +96,115 @@ def _write_snapshot(path: Path, data: dict | None = None) -> None:
|
|||||||
path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8")
|
path.write_text(json.dumps(data if data is not None else _snapshot()), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_snapshot(task_extra: dict) -> dict:
|
||||||
|
projects = {"p1": {"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": ["t1"], "backlogTaskIds": []}}
|
||||||
|
tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False,
|
||||||
|
"tagIds": ["tag-wait"], **task_extra}}
|
||||||
|
tags = {"tag-wait": {"id": "tag-wait", "title": "waiting"}}
|
||||||
|
return {"project": _entity_state(projects), "task": _entity_state(tasks), "tag": _entity_state(tags)}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cfg(tmp_path) -> sp.SuperProductivityConfig:
|
def cfg(tmp_path) -> sp.SuperProductivityConfig:
|
||||||
db_path = tmp_path / "db.json"
|
backups_dir = tmp_path / "backups"
|
||||||
_write_snapshot(db_path)
|
backups_dir.mkdir()
|
||||||
|
_write_snapshot(backups_dir / "2026-03-01_120000.json")
|
||||||
return sp.SuperProductivityConfig(
|
return sp.SuperProductivityConfig(
|
||||||
backups_dir=None, db_path=db_path, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None
|
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
|
||||||
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- from_dict ---------------------------------------------------------------
|
# --- from_dict -----------------------------------------------------------------
|
||||||
|
|
||||||
def test_from_dict_requires_backups_dir_or_db_path():
|
def test_from_dict_requires_access():
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError, match="access"):
|
||||||
sp.SuperProductivityConfig.from_dict({"api_base_url": "http://127.0.0.1:3876"})
|
sp.SuperProductivityConfig.from_dict({"backups_dir": "/x"})
|
||||||
|
|
||||||
|
|
||||||
def test_from_dict_defaults_the_api_base_url():
|
def test_from_dict_rejects_an_unknown_access_value():
|
||||||
cfg_ = sp.SuperProductivityConfig.from_dict({"db_path": "/x/db.json"})
|
with pytest.raises(ValidationError, match="access"):
|
||||||
|
sp.SuperProductivityConfig.from_dict({"access": "auto", "backups_dir": "/x"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_api_requires_a_token():
|
||||||
|
with pytest.raises(ValidationError, match="api_token"):
|
||||||
|
sp.SuperProductivityConfig.from_dict({"access": "api"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_api_defaults_the_base_url():
|
||||||
|
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "api", "api_token": "t"})
|
||||||
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
|
assert cfg_.api_base_url == sp.DEFAULT_API_BASE_URL
|
||||||
|
assert cfg_.access == sp.ACCESS_API
|
||||||
|
|
||||||
|
|
||||||
def test_from_dict_expands_user_in_paths(monkeypatch):
|
def test_from_dict_api_rejects_a_snapshot_field():
|
||||||
|
with pytest.raises(ValidationError, match="backups_dir"):
|
||||||
|
sp.SuperProductivityConfig.from_dict(
|
||||||
|
{"access": "api", "api_token": "t", "backups_dir": "/x"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_snapshot_requires_backups_dir():
|
||||||
|
with pytest.raises(ValidationError, match="backups_dir"):
|
||||||
|
sp.SuperProductivityConfig.from_dict({"access": "snapshot"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_snapshot_rejects_an_api_field():
|
||||||
|
with pytest.raises(ValidationError, match="api_token"):
|
||||||
|
sp.SuperProductivityConfig.from_dict(
|
||||||
|
{"access": "snapshot", "backups_dir": "/x", "api_token": "t"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dict_snapshot_expands_user_in_backups_dir(monkeypatch):
|
||||||
monkeypatch.setenv("HOME", "/home/fixture")
|
monkeypatch.setenv("HOME", "/home/fixture")
|
||||||
cfg_ = sp.SuperProductivityConfig.from_dict({"backups_dir": "~/backups"})
|
cfg_ = sp.SuperProductivityConfig.from_dict({"access": "snapshot", "backups_dir": "~/backups"})
|
||||||
assert cfg_.backups_dir == Path("/home/fixture/backups")
|
assert cfg_.backups_dir == Path("/home/fixture/backups")
|
||||||
|
|
||||||
|
|
||||||
# --- latest_snapshot_path / schema drift -------------------------------------
|
def test_from_dict_rejects_db_path_entirely():
|
||||||
|
"""`db_path` was #133's own casualty - it must not silently work as an
|
||||||
def test_db_path_must_exist(tmp_path):
|
alias for `backups_dir` under either access mode."""
|
||||||
cfg_ = sp.SuperProductivityConfig(
|
|
||||||
backups_dir=None, db_path=tmp_path / "missing.json",
|
|
||||||
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
|
||||||
)
|
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
sp.latest_snapshot_path(cfg_)
|
sp.SuperProductivityConfig.from_dict({"access": "snapshot", "db_path": "/x/db.json"})
|
||||||
|
|
||||||
|
|
||||||
|
# --- latest_snapshot_path / schema drift ---------------------------------------
|
||||||
|
|
||||||
def test_backups_dir_must_exist(tmp_path):
|
def test_backups_dir_must_exist(tmp_path):
|
||||||
cfg_ = sp.SuperProductivityConfig(
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
backups_dir=tmp_path / "nope", db_path=None,
|
access=sp.ACCESS_SNAPSHOT, backups_dir=tmp_path / "nope",
|
||||||
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
sp.latest_snapshot_path(cfg_)
|
sp.latest_snapshot_path(cfg_)
|
||||||
|
|
||||||
|
|
||||||
def test_backups_dir_with_no_json_files_is_an_error(tmp_path):
|
def test_backups_dir_with_no_timestamped_file_is_an_error(tmp_path):
|
||||||
backups = tmp_path / "backups"
|
backups = tmp_path / "backups"
|
||||||
backups.mkdir()
|
backups.mkdir()
|
||||||
|
(backups / "sp-backup_2026-01-01.json").write_text("{}", encoding="utf-8")
|
||||||
cfg_ = sp.SuperProductivityConfig(
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
|
||||||
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
sp.latest_snapshot_path(cfg_)
|
sp.latest_snapshot_path(cfg_)
|
||||||
|
|
||||||
|
|
||||||
def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
|
def test_backups_dir_picks_the_lexically_latest_timestamped_file(tmp_path):
|
||||||
backups = tmp_path / "backups"
|
backups = tmp_path / "backups"
|
||||||
backups.mkdir()
|
backups.mkdir()
|
||||||
older = _snapshot()
|
older = _snapshot()
|
||||||
older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project"
|
older["project"]["entities"]["p1"]["title"] = "Old Snapshot Project"
|
||||||
newer = _snapshot()
|
newer = _snapshot()
|
||||||
_write_snapshot(backups / "2026-01-01T000000Z.json", older)
|
_write_snapshot(backups / "2026-01-01_000000.json", older)
|
||||||
_write_snapshot(backups / "2026-02-01T000000Z.json", newer)
|
_write_snapshot(backups / "2026-02-01_000000.json", newer)
|
||||||
cfg_ = sp.SuperProductivityConfig(
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
backups_dir=backups, db_path=None, api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
|
||||||
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||||
)
|
)
|
||||||
reader = sp.SuperProductivityReader(cfg_)
|
reader = sp.SuperProductivityReader(cfg_)
|
||||||
names = {p.name for p in reader.projects()}
|
names = {p.name for p in reader.projects()}
|
||||||
@@ -168,10 +212,31 @@ def test_backups_dir_picks_the_lexically_latest_file(tmp_path):
|
|||||||
assert "Old Snapshot Project" not in names
|
assert "Old Snapshot Project" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_manual_export_never_wins_even_placed_next_to_an_older_timestamp(tmp_path):
|
||||||
|
"""`sp-backup_*.json` sorts lexically *after* every timestamp - the
|
||||||
|
hardened glob (Gitea #133) must never pick it, whatever its own name or
|
||||||
|
mtime looks like next to the real backups."""
|
||||||
|
backups = tmp_path / "backups"
|
||||||
|
backups.mkdir()
|
||||||
|
timestamped = _snapshot()
|
||||||
|
manual_export = _snapshot()
|
||||||
|
manual_export["project"]["entities"]["p1"]["title"] = "From Manual Export"
|
||||||
|
_write_snapshot(backups / "2026-01-01_000000.json", timestamped)
|
||||||
|
_write_snapshot(backups / "sp-backup_2026-06-01.json", manual_export)
|
||||||
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
|
access=sp.ACCESS_SNAPSHOT, backups_dir=backups,
|
||||||
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||||
|
)
|
||||||
|
reader = sp.SuperProductivityReader(cfg_)
|
||||||
|
names = {p.name for p in reader.projects()}
|
||||||
|
assert "Ship Chemenu 7.0" in names
|
||||||
|
assert "From Manual Export" not in names
|
||||||
|
|
||||||
|
|
||||||
def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
|
def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
|
||||||
data = _snapshot()
|
data = _snapshot()
|
||||||
data["task"] = ["not", "an", "entity", "state"]
|
data["task"] = ["not", "an", "entity", "state"]
|
||||||
_write_snapshot(cfg.db_path, data)
|
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||||
reader = sp.SuperProductivityReader(cfg)
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
with pytest.raises(ValidationError, match="task"):
|
with pytest.raises(ValidationError, match="task"):
|
||||||
reader.projects()
|
reader.projects()
|
||||||
@@ -180,13 +245,13 @@ def test_schema_drift_on_task_key_fails_loud_not_silent(cfg):
|
|||||||
def test_missing_top_level_key_fails_loud(cfg):
|
def test_missing_top_level_key_fails_loud(cfg):
|
||||||
data = _snapshot()
|
data = _snapshot()
|
||||||
del data["project"]
|
del data["project"]
|
||||||
_write_snapshot(cfg.db_path, data)
|
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||||
reader = sp.SuperProductivityReader(cfg)
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
with pytest.raises(ValidationError, match="project"):
|
with pytest.raises(ValidationError, match="project"):
|
||||||
reader.projects()
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
# --- read path ----------------------------------------------------------------
|
# --- read path (snapshot) -------------------------------------------------------
|
||||||
|
|
||||||
def test_projects_lists_name_and_created(cfg):
|
def test_projects_lists_name_and_created(cfg):
|
||||||
reader = sp.SuperProductivityReader(cfg)
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
@@ -195,13 +260,23 @@ def test_projects_lists_name_and_created(cfg):
|
|||||||
assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
|
assert by_name["Kueche renovieren"].created == date(2026, 2, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects_excludes_archived(cfg):
|
||||||
|
data = _snapshot()
|
||||||
|
data["project"]["entities"]["p1"]["isArchived"] = True
|
||||||
|
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||||
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
|
names = {p.name for p in reader.projects()}
|
||||||
|
assert "Ship Chemenu 7.0" not in names
|
||||||
|
assert "Kueche renovieren" in names
|
||||||
|
|
||||||
|
|
||||||
def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg):
|
def test_open_items_counts_undone_tasks_and_matches_name_case_insensitively(cfg):
|
||||||
reader = sp.SuperProductivityReader(cfg)
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
result = reader.open_items("ship CHEMENU 7.0")
|
result = reader.open_items("ship CHEMENU 7.0")
|
||||||
assert result.count == 2
|
assert result.count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_open_items_reports_waiting_with_follow_up_at_not_due_date(cfg):
|
def test_open_items_reports_waiting_with_follow_up_at_from_due_with_time(cfg):
|
||||||
reader = sp.SuperProductivityReader(cfg)
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
result = reader.open_items("Ship Chemenu 7.0")
|
result = reader.open_items("Ship Chemenu 7.0")
|
||||||
assert len(result.waiting) == 1
|
assert len(result.waiting) == 1
|
||||||
@@ -230,7 +305,49 @@ def test_someday_items_come_from_backlog_task_ids_only(cfg):
|
|||||||
assert items[0].modified == date(2026, 1, 15)
|
assert items[0].modified == date(2026, 1, 15)
|
||||||
|
|
||||||
|
|
||||||
# --- write path: HumanInterventionRequired -----------------------------------
|
def test_source_names_the_snapshot_file(cfg):
|
||||||
|
source = sp.SuperProductivityReader(cfg).source()
|
||||||
|
assert source.kind == sp.ACCESS_SNAPSHOT
|
||||||
|
assert "2026-03-01_120000.json" in source.detail
|
||||||
|
|
||||||
|
|
||||||
|
# --- follow_up_at mapping (Gitea #135) -------------------------------------------
|
||||||
|
|
||||||
|
def test_follow_up_at_reads_due_with_time(cfg):
|
||||||
|
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueWithTime": _ms(2026, 3, 1)}))
|
||||||
|
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||||
|
assert waiting[0].follow_up_at == date(2026, 3, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_up_at_falls_back_to_due_day(cfg):
|
||||||
|
"""The gap #135 closed: an all-day, notification-free tickler has no
|
||||||
|
`dueWithTime` and no (now-removed) `remindAt` at all, and must still be
|
||||||
|
read as a follow-up date."""
|
||||||
|
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({"dueDay": "2026-03-15"}))
|
||||||
|
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||||
|
assert waiting[0].follow_up_at == date(2026, 3, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_up_at_prefers_due_with_time_over_due_day(cfg):
|
||||||
|
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
|
||||||
|
"dueWithTime": _ms(2026, 3, 1), "dueDay": "2026-04-01",
|
||||||
|
}))
|
||||||
|
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||||
|
assert waiting[0].follow_up_at == date(2026, 3, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_follow_up_at_never_reads_remind_at_or_deadline_fields(cfg):
|
||||||
|
_write_snapshot(sp.latest_snapshot_path(cfg), _minimal_snapshot({
|
||||||
|
"remindAt": _ms(2026, 3, 1),
|
||||||
|
"deadlineDay": "2026-03-01",
|
||||||
|
"deadlineWithTime": _ms(2026, 3, 1),
|
||||||
|
"deadlineRemindAt": _ms(2026, 3, 1),
|
||||||
|
}))
|
||||||
|
waiting = sp.SuperProductivityReader(cfg).open_items("Ship Chemenu 7.0").waiting
|
||||||
|
assert waiting[0].follow_up_at is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- write path: HumanInterventionRequired ---------------------------------------
|
||||||
|
|
||||||
def test_create_project_refuses_a_name_collision(cfg):
|
def test_create_project_refuses_a_name_collision(cfg):
|
||||||
reader = sp.SuperProductivityReader(cfg)
|
reader = sp.SuperProductivityReader(cfg)
|
||||||
@@ -257,17 +374,17 @@ def test_create_project_asks_a_human_and_verify_reflects_the_read_path(cfg):
|
|||||||
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
|
"id": "p3", "title": "Kueche renovieren, Phase 2", "created": _ms(2026, 4, 1),
|
||||||
"taskIds": [], "backlogTaskIds": [],
|
"taskIds": [], "backlogTaskIds": [],
|
||||||
}
|
}
|
||||||
_write_snapshot(cfg.db_path, data)
|
_write_snapshot(sp.latest_snapshot_path(cfg), data)
|
||||||
|
|
||||||
assert exc.verify() is True
|
assert exc.verify() is True
|
||||||
|
|
||||||
|
|
||||||
# --- health -------------------------------------------------------------------
|
# --- health ------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_health_is_false_when_nothing_listens(tmp_path):
|
def test_health_is_false_when_nothing_listens(tmp_path):
|
||||||
cfg_ = sp.SuperProductivityConfig(
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
backups_dir=None, db_path=tmp_path / "db.json",
|
access=sp.ACCESS_API, backups_dir=None,
|
||||||
api_base_url="http://127.0.0.1:1", api_token=None,
|
api_base_url="http://127.0.0.1:1", api_token="t",
|
||||||
)
|
)
|
||||||
assert sp.health(cfg_, timeout=0.5) is False
|
assert sp.health(cfg_, timeout=0.5) is False
|
||||||
|
|
||||||
@@ -289,10 +406,191 @@ def test_health_is_true_when_the_endpoint_answers(tmp_path):
|
|||||||
try:
|
try:
|
||||||
port = server.server_address[1]
|
port = server.server_address[1]
|
||||||
cfg_ = sp.SuperProductivityConfig(
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
backups_dir=None, db_path=tmp_path / "db.json",
|
access=sp.ACCESS_API, backups_dir=None,
|
||||||
api_base_url=f"http://127.0.0.1:{port}", api_token=None,
|
api_base_url=f"http://127.0.0.1:{port}", api_token="t",
|
||||||
)
|
)
|
||||||
assert sp.health(cfg_, timeout=2.0) is True
|
assert sp.health(cfg_, timeout=2.0) is True
|
||||||
finally:
|
finally:
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
thread.join(timeout=2)
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the API read path (Gitea #133) -------------------------------------------------
|
||||||
|
|
||||||
|
def _make_api_handler(routes: dict[str, Any], token: str, *, status_override: dict[str, int] | None = None):
|
||||||
|
status_override = status_override or {}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self): # noqa: N802
|
||||||
|
if self.path == "/health":
|
||||||
|
self._reply(200, {"ok": True})
|
||||||
|
return
|
||||||
|
override = status_override.get(self.path)
|
||||||
|
if override is not None:
|
||||||
|
self._reply(override, {"error": "fixture"})
|
||||||
|
return
|
||||||
|
if self.headers.get("Authorization") != f"Bearer {token}":
|
||||||
|
self._reply(401, {"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
if self.path in routes:
|
||||||
|
self._reply(200, routes[self.path])
|
||||||
|
return
|
||||||
|
self._reply(404, {"error": "not found"})
|
||||||
|
|
||||||
|
def _reply(self, code: int, payload: Any) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args): # noqa: D401 - silence stderr noise
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Handler
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _api_server(routes: dict[str, Any], *, token: str = "test-token", status_override=None):
|
||||||
|
handler_cls = _make_api_handler(routes, token, status_override=status_override)
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", 0), handler_cls)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield server
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_cfg(server: http.server.HTTPServer, *, token: str = "test-token") -> sp.SuperProductivityConfig:
|
||||||
|
port = server.server_address[1]
|
||||||
|
return sp.SuperProductivityConfig(
|
||||||
|
access=sp.ACCESS_API, backups_dir=None,
|
||||||
|
api_base_url=f"http://127.0.0.1:{port}", api_token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixture_records() -> tuple[list[dict], list[dict], list[dict]]:
|
||||||
|
projects = [
|
||||||
|
{"id": "p1", "title": "Ship Chemenu 7.0", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": ["t1", "t2"], "backlogTaskIds": ["t3"]},
|
||||||
|
{"id": "p2", "title": "Archived Project", "created": _ms(2026, 1, 1),
|
||||||
|
"taskIds": [], "backlogTaskIds": [], "isArchived": True},
|
||||||
|
]
|
||||||
|
tasks = [
|
||||||
|
{"id": "t1", "title": "Warte auf Angebot vom Elektriker - Tobias", "isDone": False,
|
||||||
|
"tagIds": ["tag-wait"], "dueDay": "2026-03-15"},
|
||||||
|
# A subtask: inherits projectId from its parent, is NOT itself in
|
||||||
|
# project.taskIds (only t1/t2 are) - counting via project.taskIds
|
||||||
|
# must not pick it up, matching the snapshot path (Gitea #133).
|
||||||
|
{"id": "t2b", "title": "Unterschritt von t1", "isDone": False, "tagIds": [],
|
||||||
|
"projectId": "p1", "parentId": "t1"},
|
||||||
|
{"id": "t2", "title": "Kickoff-Meeting vorbereiten", "isDone": False, "tagIds": []},
|
||||||
|
{"id": "t3", "title": "Irgendwann Keller aufraeumen", "isDone": False, "tagIds": [],
|
||||||
|
"updated": _ms(2026, 1, 15)},
|
||||||
|
]
|
||||||
|
tags = [{"id": "tag-wait", "title": "Waiting"}]
|
||||||
|
return projects, tasks, tags
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_projects_excludes_archived():
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
names = {p.name for p in reader.projects()}
|
||||||
|
assert "Ship Chemenu 7.0" in names
|
||||||
|
assert "Archived Project" not in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_open_items_counts_against_project_task_ids_not_project_id_filter():
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
result = reader.open_items("Ship Chemenu 7.0")
|
||||||
|
assert result.count == 2 # t1, t2 - not the subtask t2b
|
||||||
|
assert len(result.waiting) == 1
|
||||||
|
assert result.waiting[0].follow_up_at == date(2026, 3, 15)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_someday_items():
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
items = reader.someday_items()
|
||||||
|
assert [i.title for i in items] == ["Irgendwann Keller aufraeumen"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_401_without_the_right_token_fails_loud():
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}, token="right-token") as server:
|
||||||
|
cfg_ = _api_cfg(server, token="wrong-token")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
sp.SuperProductivityApiReader(cfg_).projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_503_app_not_ready_is_its_own_message():
|
||||||
|
with _api_server({"/projects": []}, status_override={"/projects": 503}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
with pytest.raises(ValidationError, match="APP_NOT_READY"):
|
||||||
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_unreachable_fails_loud_not_silent(tmp_path):
|
||||||
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
|
access=sp.ACCESS_API, backups_dir=None,
|
||||||
|
api_base_url="http://127.0.0.1:1", api_token="t",
|
||||||
|
)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
sp.SuperProductivityApiReader(cfg_).projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_non_list_response_fails_loud():
|
||||||
|
with _api_server({"/projects": {"not": "a list"}}) as server:
|
||||||
|
reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
reader.projects()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_reader_source_is_live_and_needs_no_network():
|
||||||
|
"""`source()` on the API path must not itself perform a request - a
|
||||||
|
static description is correct regardless of reachability (Gitea #133)."""
|
||||||
|
cfg_ = sp.SuperProductivityConfig(
|
||||||
|
access=sp.ACCESS_API, backups_dir=None,
|
||||||
|
api_base_url="http://127.0.0.1:1", api_token="t",
|
||||||
|
)
|
||||||
|
source = sp.SuperProductivityApiReader(cfg_).source()
|
||||||
|
assert source.kind == sp.ACCESS_API
|
||||||
|
|
||||||
|
|
||||||
|
# --- equivalence: both access paths agree on the same fixture (Gitea #133) --------
|
||||||
|
|
||||||
|
def test_snapshot_and_api_readers_agree_on_the_same_fixture(tmp_path):
|
||||||
|
projects, tasks, tags = _fixture_records()
|
||||||
|
|
||||||
|
backups_dir = tmp_path / "backups"
|
||||||
|
backups_dir.mkdir()
|
||||||
|
_write_snapshot(backups_dir / "2026-03-01_000000.json", {
|
||||||
|
"project": _entity_state({p["id"]: p for p in projects}),
|
||||||
|
"task": _entity_state({t["id"]: t for t in tasks}),
|
||||||
|
"tag": _entity_state({t["id"]: t for t in tags}),
|
||||||
|
})
|
||||||
|
snapshot_reader = sp.SuperProductivityReader(sp.SuperProductivityConfig(
|
||||||
|
access=sp.ACCESS_SNAPSHOT, backups_dir=backups_dir,
|
||||||
|
api_base_url=sp.DEFAULT_API_BASE_URL, api_token=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
with _api_server({"/projects": projects, "/tasks": tasks, "/tags": tags}) as server:
|
||||||
|
api_reader = sp.SuperProductivityApiReader(_api_cfg(server))
|
||||||
|
|
||||||
|
assert {p.name: p.created for p in snapshot_reader.projects()} == \
|
||||||
|
{p.name: p.created for p in api_reader.projects()}
|
||||||
|
|
||||||
|
snap_items = snapshot_reader.open_items("Ship Chemenu 7.0")
|
||||||
|
api_items = api_reader.open_items("Ship Chemenu 7.0")
|
||||||
|
assert snap_items.count == api_items.count
|
||||||
|
assert [(w.title, w.follow_up_at) for w in snap_items.waiting] == \
|
||||||
|
[(w.title, w.follow_up_at) for w in api_items.waiting]
|
||||||
|
|
||||||
|
assert [(s.title, s.modified) for s in snapshot_reader.someday_items()] == \
|
||||||
|
[(s.title, s.modified) for s in api_reader.someday_items()]
|
||||||
@@ -19,8 +19,8 @@ VALID = {
|
|||||||
"someday_stale_months": 5,
|
"someday_stale_months": 5,
|
||||||
},
|
},
|
||||||
"superproductivity": {
|
"superproductivity": {
|
||||||
|
"access": "snapshot",
|
||||||
"backups_dir": "/tmp/does-not-need-to-exist-for-parsing",
|
"backups_dir": "/tmp/does-not-need-to-exist-for-parsing",
|
||||||
"api_base_url": "http://127.0.0.1:3876",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user