diff --git a/CHANGES.md b/CHANGES.md index 9e05756..3772aaa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,6 +59,49 @@ concern - readable here, never shipped as something to parse. --- +## 7.1.0-beta.2 - 2026-09-25 - version bump no longer points at version release in its output + +**Author:** Torben Nehmer + + +**Medium impact** +- CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them + +**Low impact** +- version bump no longer points at version release in its output + + +### CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them + +A second task-tracker adapter, `caldav` (RFC 4791/5545), so an instance is not bound to Super +Productivity - built against Nextcloud Tasks with iOS *Erinnerungen* as the mobile client and +verified against a real account. `TaskReader`/`TaskWriter` are implemented in full without +`tasks/protocol.py` changing at all, proving the provider layer is genuinely exchangeable. The +mapping: a project is a calendar collection whose only supported component is `VTODO`; `WAITING` +is a `waiting` category; `follow_up_at` is `DTSTART`, never `DUE`; a project's `created` falls +back to the earliest item's own `CREATED` (no server in the test account returns +`DAV:creationdate` on a calendar); `create_project` uses a real `MKCALENDAR` call and so never +needs a human-clearance step the way Super Productivity's does; `close_item` changes only +`STATUS`/`COMPLETED`/`PERCENT-COMPLETE`/`LAST-MODIFIED`/`DTSTAMP` on the existing resource and +refuses on an ETag conflict; nothing is ever deleted. Every address used - a list's or an item's +- comes from the server's own `href`, never built from the configured URL and a name, since the +configured URL may be an alias for a different canonical path. + +Alongside it, `wikitool review`'s checks 2 and 3 no longer silently skip a value a provider +cannot supply - a `WAITING` item with no `follow_up_at`, or a tracker project with no +determinable creation date - and instead report it as its own finding +(`waiting_no_follow_up`/`project_age_unknown`), for every provider. `icalendar` is now a required +dependency (`tools/requirements.txt`), imported only when `caldav` is actually configured. + +### version bump no longer points at version release in its output + +`version bump` used to end with "and `version release` once the candidate is ready to ship", and +an agent read that as its own next step and fixed a candidate into a release unasked. The hint +is gone: whether a candidate ships is the user's decision, as `DEVELOPMENT.md` already says for +humans, and `instructions/dev/version-parts.md` step 7 now says so for agents. + +--- + ## 7.0.0 - 2026-09-22 - Task-Tracker-Anbindung: Vorhaben als Seitenart, Verpflichtungsschicht, Weekly Review als Read-Time-Join **Author:** Torben Nehmer diff --git a/INSTALL.md b/INSTALL.md index 842b1cf..e809805 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -323,10 +323,13 @@ unlesbare Konfiguration darf nicht als „kein Tracker" durchgehen. } ``` -`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 -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. +`provider` wählt den Adapter - ausgeliefert werden `superproductivity` und `caldav`. Der +`thresholds`-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, und ab wann ein Someday-Eintrag als verstaubt gilt. Ein Wert, den der Provider +gar nicht liefern kann - ein Waiting-Posten ohne Wiedervorlagedatum, ein Tracker-Projekt ohne +bestimmbares Anlagedatum - wird vom Rückblick nicht still übersprungen, sondern als eigene +Fundstelle gemeldet (`waiting_no_follow_up`/`project_age_unknown`). Der gleichnamige Provider-Block trägt dessen Verbindungsangaben, und bei Super Productivity entscheidet `access` **verpflichtend und ohne Rückfall**, welcher von zwei sich ausschließenden @@ -350,6 +353,47 @@ Schreibweg - er markiert einen Posten erledigt, löscht ihn nie - und verweigert `access: "snapshot"` auf dieselbe Weise. `tools/wikitool task list --project` ist rein lesend und beantwortet daher auf beiden Zugriffsarten. +**`caldav`** ist der standardbasierte zweite Adapter (RFC 4791/5545), gegen Nextcloud Tasks +verifiziert, mit iOS *Erinnerungen* als mobilem Client - gebaut nach dem Zuschnitt: auf dem +Telefon wird abgehakt, gepflegt wird am Schreibtisch. Anders als bei Super Productivity gibt es +nur einen Zugriffsweg - CalDAV ist immer ein Netzwerkzugriff, kein `access`-Feld nötig: + +```json + "caldav": { + "url": "https:///remote.php/dav/calendars//", + "username": "", + "app_password": "", + "inbox_list": "Inbox", + "someday_list": "Someday", + "exclude_lists": [""] + } +``` + +`url` zeigt auf das CalDAV-Calendar-Home-Set des Kontos; sie darf ein Alias sein (Nextcloud +akzeptiert dort einen Kurznamen), da jede spätere Adresse ausschließlich aus den vom Server +gelieferten `href`s stammt, nie aus dieser URL und einem Namen zusammengesetzt wird. `username` +ist der Login-Name, der von der Benutzer-ID in der URL abweichen kann - ein +Nextcloud-App-Passwort wird empfohlen, nicht das Kontopasswort. `inbox_list`/`someday_list` +nennen die beiden festen Listen (je genau eine pro Instanz); `exclude_lists` nimmt vorhandene +reine Aufgabenlisten heraus, die keine Projekte sind - eine Liste mit `VEVENT`-Anteil zählt +ohnehin nie als Projekt. + +Ein Projekt ist dort eine Liste, deren unterstützte Komponente ausschließlich `VTODO` ist; `tools/wikitool +new project` legt sie automatisch per `MKCALENDAR` an - anders als bei Super Productivity ohne +Rückfrage, weil CalDAV einen echten Anlage-Befehl für Listen kennt. Die Eindeutigkeitsprüfung +läuft gegen **jede** Liste im Konto, auch gegen ausgeschlossene, Inbox, Someday und gemischte +Kalender - kollidiert ein neuer Name mit einer davon, wird nichts angelegt und die Kollision +genannt; die vorhandene Liste in Nextcloud umzubenennen bleibt Handarbeit. `tools/wikitool task +new`/`task close` funktionieren auf einer `caldav`-Instanz uneingeschränkt - es gibt keinen +reinen Lesemodus wie `access: "snapshot"`. Beim Abhaken ändert `task close` ausschließlich +`STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `LAST-MODIFIED` und `DTSTAMP` an der bestehenden +`.ics`-Ressource; jede andere Eigenschaft - auch eine unbekannte `X-`-Eigenschaft oder ein Alarm +- bleibt unverändert erhalten, und eine seit dem Lesen veränderte Ressource (ETag-Konflikt) +schreibt nichts und bricht mit exit 1 ab. Gelöscht wird nie etwas. + +Listen abgeschlossener Projekte bleiben nach dem Archivieren bestehen - der Adapter löscht nie +eine Liste; das übernimmt der Betreiber von Hand in Nextcloud, sobald gewünscht. + ## Verifikation ```bash diff --git a/VERSION b/VERSION index 66ce77b..0d60536 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.0.0 +7.1.0-beta.2 diff --git a/instructions/dev/version-parts.md b/instructions/dev/version-parts.md index 8046575..2e7a26f 100644 --- a/instructions/dev/version-parts.md +++ b/instructions/dev/version-parts.md @@ -198,7 +198,12 @@ the three-line test below is usually enough. never reported by anything. The 5.0.0 candidate is the case: it declared `--no-migration` for a TOC-verification change, then absorbed a schema removal that migrates 152 pages. -7. **Review the graded list before fixing the candidate, and regrade what reads wrong.** Run +7. **Fix the candidate only when the user asks for a release.** Whether a candidate ships is the + user's call, never a session's: a work package being finished is not a reason, since the + candidate model exists precisely so that one does not become one release. A session that + bumps stops at the open `-beta.N` candidate; the next `publish` then carries it without + triggering `release.yml`. Once the user does ask, review the graded list first, and regrade + what reads wrong. Run `tools/wikitool version regrade` with no arguments - it lists every bump at its current grade, numbered in rendered order. A candidate that grew over several sessions often has a bump graded in isolation that reads differently once the whole shape is visible; `version regrade 3 7 diff --git a/instructions/gtd-weekly-review/SKILL.md b/instructions/gtd-weekly-review/SKILL.md index d21c409..8d686b6 100644 --- a/instructions/gtd-weekly-review/SKILL.md +++ b/instructions/gtd-weekly-review/SKILL.md @@ -59,6 +59,8 @@ surface, lives in `docs/knowledge-and-commitment.md`, which this skill does not | `unpaged_project` | A tracker project has no `kb/` page, past the age threshold | (a) It has grown a memory worth keeping (participants, decisions, context) - `tools/wikitool new project --name "" --set responsibility=`. (b) It genuinely never needs one - confirm and leave it tracker-only | Ask: would anyone, including the operator in six months, need to know *why* this exists or who is in it? If yes, (a); a project that is fully explained by its own title and task list stays (b) | | `no_open_loop` | A `kb/` page is `state: active` but its tracker project is missing or empty | (a) Same three options as `stalled` above. (b) The name diverged - a rename happened on one side only | Before assuming a stall, check whether a *similarly* named tracker project exists. If it does, this is `instructions/page-lifecycle.md`'s rename case (`tools/wikitool rename` for the page, plus renaming the tracker project to match), not a state change - the review reports both directions of a rename so it never has to be inferred silently | | `someday_stale` | A someday/maybe item has not been touched past the threshold | (a) Activate it - give it a page with `tools/wikitool new project` if it is ready to become a committed initiative. (b) Strike it - propose `tools/wikitool task close --id ""` (the finding's own `item_id`), asked as one combined question naming the item's title and id, then run it once confirmed. (c) Leave it - still genuinely "maybe" | Would the user commit to starting this today? If yes, (a). If it no longer belongs on the list at all, (b). If it is still worth keeping but not yet, (c) is a legitimate answer, not inaction - do not force a decision the user is not ready to make | + | `waiting_no_follow_up` | A `WAITING` item carries no `follow_up_at` at all - the provider had nothing to judge staleness against, so `waiting_overdue` could not even ask the question | (a) Set a follow-up date on the item, in the tracker itself - there is no `wikitool` command for this, same as moving a reminder forward. (b) Leave it open-ended deliberately - some commitments genuinely have no date yet | Ask whether there is a date to follow up on at all. If yes, (a); if the item is a genuine "whenever they get back to me", (b) is legitimate, but say so plainly rather than treating the finding as resolved by itself | + | `project_age_unknown` | A tracker project has no determinable creation date - the provider could not supply one (an empty project, or a server that never reports it), so `unpaged_project` could not judge its age either way | (a) Judge it on its own merits regardless of age - if it clearly deserves a `kb/` page now, `tools/wikitool new project --name "" --set responsibility=`. (b) Leave it - it becomes ordinary `unpaged_project` material once it does gain a determinable age | There is no date to reason from here, unlike `unpaged_project` - ask the same "would anyone need to know why this exists" question from that row, but without an age argument on either side | 3. **Record what was decided or learned on the page - never the task list.** A decision made this week (a scope cut, a direction change) goes under `## Entscheidungen`; something that showed diff --git a/tools/CONTRACT.md b/tools/CONTRACT.md index f4c8846..9adda6e 100644 --- a/tools/CONTRACT.md +++ b/tools/CONTRACT.md @@ -129,7 +129,7 @@ tools/wikitool --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 .md` (or `--markdown`), naming the path - `--full` prints everything, `--json` prints the findings and writes nothing | | `search [""] [--field ...] [--kind/--subtype/--collection/--tag ] [--regex] [--limit N] [--sort [-]] [--backend ] [--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, 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** | +| `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`). A value a provider genuinely cannot supply - a `WAITING` item with no `follow_up_at` at all, a tracker project with no determinable creation date - is its own finding (`waiting_no_follow_up`/`project_age_unknown`) rather than a silent skip of waiting-overdue/unpaged-project for that item or project. 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 @@ -219,7 +219,7 @@ tools/wikitool --help | 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 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 | +| `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; for a configured `caldav` provider, whether the server is reachable and Basic auth succeeds - also never a `FAIL`, only a broken config block is), 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 @@ -313,7 +313,7 @@ is atomic, and whether a retry is safe. | Command | Exit 1 means | Atomic? | Retry policy | |---------|--------------|---------|--------------| | `new ` | 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 ` 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 ` - 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 | +| `new project` | Everything `new ` covers, **plus**: the name is already taken in the tracker (case-insensitively - for `caldav` this is checked against every list in the account, not only the ones counted as projects), `--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 ` - 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, and is `superproductivity`-only: that 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. `caldav` never produces this outcome - `MKCALENDAR` is a real collection-creation verb, so a valid, non-colliding name always creates the list itself | | `task new` | No `.wikitool-tasks.json`, neither or both of `--project`/`--inbox` given, a `--follow-up-at` without `--waiting` or not `YYYY-MM-DD`, a `--project` name matching no tracker project, `--waiting` against a provider with no way to represent it right now (Super Productivity: the `waiting` tag does not exist), or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call, made only once every precondition (the project's own id, the WAITING tag's own id) is confirmed to exist, so a missing one never leaves a half-written item behind | Not transient; fix the argument, create the missing tracker project or tag first, or point at an `access: "api"` instance, then retry once. **Never exit 42** - unlike `new project`, every provider offering a write path at all has a real item-creation call, so there is no human-clearance step to wait on here | | `task list` | No `.wikitool-tasks.json` | Yes - read-only, nothing to leave half-written | Not transient; configure a tracker first, then retry once. A `--project` matching no tracker project is not an error here - see its Commands row | | `task close` | No `.wikitool-tasks.json`, an `--id` matching no tracker item right now, or a read-only access path (Super Productivity's `access: "snapshot"`) | Yes - a single API call; an unknown id is rejected by the provider itself (Super Productivity: `404 TASK_NOT_FOUND`) before anything is written | Not transient; fix the id (re-run `task list` or `review` to get a current one) or point at an `access: "api"` instance, then retry once. **Never exit 42**, same reasoning as `task new` | diff --git a/tools/chemenu/commands/doctor.py b/tools/chemenu/commands/doctor.py index 1536413..f89e14e 100644 --- a/tools/chemenu/commands/doctor.py +++ b/tools/chemenu/commands/doctor.py @@ -430,7 +430,9 @@ def check_tasks_provider() -> Check: 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. + finding, since this instance never touches it. `caldav` (Gitea #139) has + only one access mode - it always reports reachability and authentication + against the server, the same never-FAIL posture. """ from chemenu import config from chemenu.errors import ValidationError @@ -477,6 +479,19 @@ def check_tasks_provider() -> Check: "tasks-provider", "OK", f"superproductivity: access=snapshot; {read_state}", ) + if cfg.provider == "caldav": + from chemenu.tasks import caldav as cd + + try: + cd_cfg = cd.CalDAVConfig.from_dict(cfg.provider_config) + except ValidationError as exc: + return Check( + "tasks-provider", "FAIL", str(exc), + f"Fix the 'caldav' section of {config.TASKS_CONFIG_FILENAME}", + ) + state = cd.probe(cd_cfg) + return Check("tasks-provider", "OK", f"caldav: {state}") + return Check("tasks-provider", "OK", f"provider '{cfg.provider}' configured") diff --git a/tools/chemenu/commands/version_cmd.py b/tools/chemenu/commands/version_cmd.py index a9aca3e..cbdef7e 100644 --- a/tools/chemenu/commands/version_cmd.py +++ b/tools/chemenu/commands/version_cmd.py @@ -486,7 +486,7 @@ def bump_command( success( f"{current} -> {new_version}{boundary}. Wrote {version_mod.VERSION_FILENAME} and " f"the {version_mod.CHANGES_FILENAME} entry{impact_note} - write its prose before " - f"publishing, and `version release` once the candidate is ready to ship." + f"publishing." ) diff --git a/tools/chemenu/review.py b/tools/chemenu/review.py index 1e544c3..cec76b7 100644 --- a/tools/chemenu/review.py +++ b/tools/chemenu/review.py @@ -35,6 +35,17 @@ CHECK_UNPAGED_PROJECT = "unpaged_project" CHECK_NO_OPEN_LOOP = "no_open_loop" CHECK_SOMEDAY_STALE = "someday_stale" +# D3 (Gitea #139): a value a provider could not supply is reported as its own +# finding, under checks 2 and 3 respectively, rather than silently skipped - +# a real state both `superproductivity` (a task with neither `dueWithTime` +# nor `dueDay`) and `caldav` (no server in the account returns +# `DAV:creationdate`, so `created` falls back to the earliest item's own +# `CREATED`, `None` for an empty project) can produce. Not separate entries +# in `ALL_CHECKS`: each fires from within check 2's/check 3's own loop, so +# the five-checks-run count is unaffected. +CHECK_WAITING_NO_FOLLOWUP = "waiting_no_follow_up" +CHECK_PROJECT_AGE_UNKNOWN = "project_age_unknown" + # The checks that need the full tracker project list (#119 D26 checks 1/2/3/4) # - `projects()` failing skips all four together, since none of them can be # answered from `someday_items()` alone. @@ -187,10 +198,19 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport: checks_run.append(CHECK_STALLED) # Check 2 - waiting-for overdue: any WAITING item whose follow_up_at - # is older than the threshold (#119 D9/D30 - never the due date). + # is older than the threshold (#119 D9/D30 - never the due date). A + # WAITING item with no follow_up_at at all is its own finding (D3), + # not a silent skip - the provider genuinely has nothing to judge + # staleness against. for _key, (name, items) in open_items_by_key.items(): for item in items.waiting: if item.follow_up_at is None: + findings.append(Finding( + CHECK_WAITING_NO_FOLLOWUP, name, + f"'{item.title}' is WAITING but has no follow_up_at - cannot judge " + "whether it is overdue.", + item_id=item.id, + )) continue age_days = (today - item.follow_up_at).days if age_days > cfg.thresholds.stalled_waiting_days: @@ -204,9 +224,18 @@ def run_review(root: Path, *, today: Optional[date] = None) -> ReviewReport: # Check 3 - tracker project with no kb/ page, older than the # threshold (#119 D26's noise brake: an age threshold, not a marker). + # A project with no determinable creation date is its own finding + # (D3), not a silent skip - check 3 cannot judge its age either way. for project in tracker_projects: key = normalize_project_name(project.name) - if key in kb_projects or project.created is None: + if key in kb_projects: + continue + if project.created is None: + findings.append(Finding( + CHECK_PROJECT_AGE_UNKNOWN, project.name, + "No creation date available for this tracker project - cannot judge " + "whether it needs a kb/ page yet.", + )) continue age_weeks = _weeks_between(project.created, today) if age_weeks > cfg.thresholds.unpaged_project_weeks: diff --git a/tools/chemenu/tasks/__init__.py b/tools/chemenu/tasks/__init__.py index 8e39ac7..3a86ab7 100644 --- a/tools/chemenu/tasks/__init__.py +++ b/tools/chemenu/tasks/__init__.py @@ -35,6 +35,11 @@ def build_reader(cfg: TasksConfig) -> TaskReader: if sp_cfg.access == sp.ACCESS_API: return sp.SuperProductivityApiReader(sp_cfg) return sp.SuperProductivityReader(sp_cfg) + if cfg.provider == "caldav": + from chemenu.tasks import caldav as cd + + cd_cfg = cd.CalDAVConfig.from_dict(cfg.provider_config) + return cd.CalDAVReader(cd_cfg) raise ValidationError(f"No reader is wired up for task provider {cfg.provider!r}.") @@ -61,4 +66,9 @@ def build_writer(cfg: TasksConfig, reader: TaskReader) -> TaskWriter: "instance (Gitea #133)." ) return sp.SuperProductivityWriter(sp_cfg, reader) + if cfg.provider == "caldav": + from chemenu.tasks import caldav as cd + + cd_cfg = cd.CalDAVConfig.from_dict(cfg.provider_config) + return cd.CalDAVWriter(cd_cfg, reader) raise ValidationError(f"No writer is wired up for task provider {cfg.provider!r}.") diff --git a/tools/chemenu/tasks/caldav.py b/tools/chemenu/tasks/caldav.py new file mode 100644 index 0000000..1fec3d7 --- /dev/null +++ b/tools/chemenu/tasks/caldav.py @@ -0,0 +1,708 @@ +"""The CalDAV adapter (Gitea #139; #128's own six rules for a second adapter, +#119 D1/D2/D8/D9/D30). + +A standards-based second provider (RFC 4791/5545) so a private instance is +not bound to Super Productivity - built against Nextcloud Tasks with iOS +*Erinnerungen* as the mobile client, verified against a real account +2026-09-25 (see #139's own test-run table). Unlike `superproductivity` +(`chemenu.tasks.superproductivity`), there is only one access mode: CalDAV is +always a live network call, so `source()` always answers `kind: "api"` and +there is no `access` field to choose - the config section only ever needs one +shape. + +## Cut: what iOS is for, and what it is not (#139, Betreiber 2026-09-23) + +**Maintained at the desk, checked off on the phone.** iOS's own weaknesses for +a CalDAV list - no tags/categories editing, no subtasks, no flags, only one +date - only affect *maintaining* an item, never marking it done, which is the +only write this adapter ever asks a human to do through the phone. + +## Mapping (#139's own table, fixed conventions rather than config variants) + +- **Project** (`projects()`): a calendar collection whose + `supported-calendar-component-set` is *exactly* `{VTODO}`, `displayname` = + project name (D6, Variante A - one list per project, no nesting: RFC 4791 + forbids nested calendar collections and iOS list groups only exist for + iCloud). +- **Inbox**/**Someday**: two further fixed VTODO-only lists, named by + `inbox_list`/`someday_list` in config - never counted as projects. +- **Open item**: `STATUS` is neither `COMPLETED` nor `CANCELLED`. +- **`WAITING`** (#119 D9/D30): `CATEGORIES` contains `waiting` + case-insensitively - verified to survive every iOS action in the test run. +- **`follow_up_at`**: `DTSTART`, *never* `DUE` (#119 D9, #135's meaning-not-name + rule) - verified as exactly what iOS's own one settable date writes. +- **`ProjectSummary.created`**: no CalDAV server in the test account returns + `DAV:creationdate` on a calendar collection, so this is always the + substitute value - the earliest `CREATED` among the project's own items, or + `None` for an empty list. A `None` here is not silently skipped by + `chemenu.review`'s check 3 (#139 D3) - it is reported as its own finding. +- **`create_project`**: `MKCALENDAR`, automatic, no `HumanInterventionRequired` + (unlike Super Productivity, the CalDAV protocol has a real + collection-creation verb) - preflighted against *every* list in the + account (D5), not only the ones counted as projects, so a name collision + with an excluded or mixed list is still caught before it produces two + same-named lists on the phone. +- **`close_item`**: reads the current resource, changes only `STATUS`, + `COMPLETED`, `PERCENT-COMPLETE`, `LAST-MODIFIED`, `DTSTAMP`, and writes it + back with `If-Match: ` - every other property (an unknown `X-` + property, an alarm, `DTSTART`) survives untouched, and a changed resource + (412) writes nothing and raises rather than retrying. +- Nothing is ever deleted - no `DELETE` call exists in this module. + +## Addresses only ever come from the server (#139's own acceptance criterion) + +The configured `url` may be an alias - Nextcloud accepts a short username in +the URL but answers with the canonical user id in every `href` it returns. +So every address this module ever calls back into - a calendar's own href, an +item's own href - is the exact `href` a `PROPFIND`/`REPORT` response handed +back, resolved against the *request* URL via `urllib.parse.urljoin`, never +built by hand from the configured `url` and a name. + +## Existing lists in the account are ignored, not adopted (D5) + +`exclude_lists` names pre-existing VTODO-only lists that are not projects: a +project calendar is further required to carry *only* VTODO (D6's table) so a +mixed calendar (VEVENT alongside VTODO) is never mistaken for one, with no +config needed for that case. If a new project name collides with an ignored +list, `create_project` refuses (see above) - the operator renames the +existing list in Nextcloud by hand; this module never does that automatically. +""" +from __future__ import annotations + +import base64 +import uuid +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from datetime import date, datetime, timezone +from typing import Any, Optional +from urllib.parse import urljoin +from xml.sax.saxutils import escape as _xml_escape + +import icalendar +import urllib.error +import urllib.request + +from chemenu.errors import ValidationError +from chemenu.tasks.protocol import ( + OpenItem, + OpenItems, + ProjectSummary, + ReadSource, + SomedayItem, + WaitingItem, + normalize_project_name, +) + +NS_D = "DAV:" +NS_C = "urn:ietf:params:xml:ns:caldav" + +# The category that means "WAITING" (#119 D9/D30), matched case-insensitively - +# this instance's own convention, not something CalDAV/RFC 5545 defines. +WAITING_CATEGORY = "waiting" + +SOURCE_KIND = "api" + +_EXPECTED_CONFIG = ( + '{"url": "https:///remote.php/dav/calendars//", "username": "", ' + '"app_password": "...", "inbox_list": "Inbox", "someday_list": "Someday", ' + '"exclude_lists": ["..."]} - see INSTALL.md' +) + +_REQUIRED_STRING_FIELDS = ("url", "username", "app_password", "inbox_list", "someday_list") +_KNOWN_FIELDS = frozenset({*_REQUIRED_STRING_FIELDS, "exclude_lists"}) + + +@dataclass(frozen=True) +class CalDAVConfig: + """This provider's own section of `.wikitool-tasks.json` + (`TasksConfig.provider_config`). `url` is normalized to always end in + `/` so every later `urljoin` treats it as a directory, never a document - + a bare `urljoin` on a slash-less base silently drops its last path + segment.""" + + url: str + username: str + app_password: str + inbox_list: str + someday_list: str + exclude_lists: tuple[str, ...] + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CalDAVConfig": + if not isinstance(data, dict): + raise ValidationError(f"caldav config must be an object. Expected: {_EXPECTED_CONFIG}") + + missing = [ + key for key in _REQUIRED_STRING_FIELDS + if not isinstance(data.get(key), str) or not data[key] + ] + if missing: + raise ValidationError( + f"caldav config is missing or has an empty {', '.join(missing)}. " + f"Expected: {_EXPECTED_CONFIG}" + ) + + extra = sorted(set(data) - _KNOWN_FIELDS) + if extra: + raise ValidationError( + f"caldav config does not take {extra} - unexpected field(s). Expected: {_EXPECTED_CONFIG}" + ) + + exclude_lists = data.get("exclude_lists", []) + if not isinstance(exclude_lists, list) or not all(isinstance(x, str) for x in exclude_lists): + raise ValidationError( + f"caldav config: 'exclude_lists' must be a list of strings if given. " + f"Expected: {_EXPECTED_CONFIG}" + ) + + url = data["url"] if data["url"].endswith("/") else data["url"] + "/" + return cls( + url=url, + username=data["username"], + app_password=data["app_password"], + inbox_list=data["inbox_list"], + someday_list=data["someday_list"], + exclude_lists=tuple(exclude_lists), + ) + + +# --- the one HTTP/WebDAV transport --------------------------------------------- + +class _DavClient: + """The one HTTP transport every read/write below goes through - a thin, + loudly-failing wrapper over `urllib`, not a general WebDAV client. HTTP + error responses (4xx/5xx) are returned, not raised: different callers + need to branch on the exact status (a 404 on `GET` means "no such item", + a 412 on `PUT` means "changed since read", both are ordinary outcomes a + caller decides about) - only a transport failure (unreachable host, DNS, + timeout) raises, since no caller can do anything useful with that beyond + reporting it.""" + + def __init__(self, cfg: CalDAVConfig): + self._cfg = cfg + credentials = f"{cfg.username}:{cfg.app_password}".encode("utf-8") + self._auth_header = "Basic " + base64.b64encode(credentials).decode("ascii") + + def propfind(self, url: str, *, depth: int, body: bytes) -> tuple[int, dict[str, str], bytes]: + return self._request( + "PROPFIND", url, body=body, depth=depth, + extra_headers={"Content-Type": 'application/xml; charset="utf-8"'}, + ) + + def report(self, url: str, *, depth: int, body: bytes) -> tuple[int, dict[str, str], bytes]: + return self._request( + "REPORT", url, body=body, depth=depth, + extra_headers={"Content-Type": 'application/xml; charset="utf-8"'}, + ) + + def mkcalendar(self, url: str, body: bytes) -> tuple[int, dict[str, str], bytes]: + return self._request( + "MKCALENDAR", url, body=body, + extra_headers={"Content-Type": 'application/xml; charset="utf-8"'}, + ) + + def put( + self, url: str, body: bytes, *, if_none_match: Optional[str] = None, + if_match: Optional[str] = None, + ) -> tuple[int, dict[str, str], bytes]: + headers = {"Content-Type": 'text/calendar; charset="utf-8"'} + if if_none_match is not None: + headers["If-None-Match"] = if_none_match + if if_match is not None: + headers["If-Match"] = if_match + return self._request("PUT", url, body=body, extra_headers=headers) + + def get(self, url: str) -> tuple[int, dict[str, str], bytes]: + return self._request("GET", url) + + def _request( + self, method: str, url: str, *, body: Optional[bytes] = None, + depth: Optional[int] = None, extra_headers: Optional[dict[str, str]] = None, + timeout: float = 15.0, + ) -> tuple[int, dict[str, str], bytes]: + headers = {"Authorization": self._auth_header} + if depth is not None: + headers["Depth"] = str(depth) + if extra_headers: + headers.update(extra_headers) + request = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 + return response.status, dict(response.headers), response.read() + except urllib.error.HTTPError as exc: + return exc.code, dict(exc.headers or {}), exc.read() + except (urllib.error.URLError, OSError) as exc: + raise ValidationError( + f"caldav: {method} {url} failed - server not reachable ({exc})." + ) from exc + + +# --- multistatus parsing --------------------------------------------------------- + +def _multistatus_responses(body: bytes) -> list[ET.Element]: + """Every `` in a WebDAV multistatus body, or a loud refusal - + #139's own acceptance criterion: a reply this module does not recognize + must fail loudly, never read as an empty result.""" + try: + root = ET.fromstring(body) + except ET.ParseError as exc: + raise ValidationError(f"caldav: server response is not valid XML ({exc}).") from exc + if root.tag != f"{{{NS_D}}}multistatus": + raise ValidationError( + f"caldav: expected a DAV multistatus response, got <{root.tag}> instead." + ) + return root.findall(f"{{{NS_D}}}response") + + +def _prop_ok(response_el: ET.Element) -> Optional[ET.Element]: + """The `` of the first `2xx` `` in one response + element, or `None` if every propstat failed.""" + for propstat in response_el.findall(f"{{{NS_D}}}propstat"): + status_el = propstat.find(f"{{{NS_D}}}status") + if status_el is None or not status_el.text: + continue + parts = status_el.text.split(" ") + if len(parts) >= 2 and parts[1].startswith("2"): + return propstat.find(f"{{{NS_D}}}prop") + return None + + +# --- listing calendars ----------------------------------------------------------- + +_PROPFIND_CALENDARS_BODY = b""" + + + + + + + +""" + +_PROPFIND_SELF_BODY = b""" + + + + + +""" + + +@dataclass(frozen=True) +class _CalendarInfo: + href: str + displayname: str + components: frozenset + + +def _list_calendars(client: _DavClient, base_url: str) -> list[_CalendarInfo]: + """Every calendar collection directly under `base_url` (Depth: 1) - the + account's full list, unfiltered. Callers decide which of these count as + a "project" (see `_is_project_calendar`); `create_project`'s own + collision preflight (D5) deliberately calls this instead of the filtered + form, since a collision with an *excluded* list must be caught too.""" + status, _headers, body = client.propfind(base_url, depth=1, body=_PROPFIND_CALENDARS_BODY) + if status != 207: + raise ValidationError( + f"caldav: listing calendars under {base_url} failed - expected HTTP 207 " + f"Multi-Status, got {status}." + ) + result: list[_CalendarInfo] = [] + for response_el in _multistatus_responses(body): + href_el = response_el.find(f"{{{NS_D}}}href") + if href_el is None or not href_el.text: + continue + prop = _prop_ok(response_el) + if prop is None: + continue + resourcetype = prop.find(f"{{{NS_D}}}resourcetype") + if resourcetype is None: + continue + type_tags = {child.tag for child in resourcetype} + if f"{{{NS_C}}}calendar" not in type_tags: + continue # not a calendar collection - e.g. the home-set itself + displayname_el = prop.find(f"{{{NS_D}}}displayname") + displayname = (displayname_el.text or "") if displayname_el is not None else "" + comp_set_el = prop.find(f"{{{NS_C}}}supported-calendar-component-set") + components = frozenset( + (comp.get("name") or "").upper() + for comp in comp_set_el + if comp.tag == f"{{{NS_C}}}comp" and comp.get("name") + ) if comp_set_el is not None else frozenset() + href = urljoin(base_url, href_el.text) + result.append(_CalendarInfo(href=href, displayname=displayname, components=components)) + return result + + +def _is_project_calendar(cal: _CalendarInfo, cfg: CalDAVConfig) -> bool: + """D6's own filter: VTODO-only (never VEVENT-mixed), and not one of the + two fixed lists or an `exclude_lists` entry (D5), matched case-normalized + the same way a project name is (#119 D8).""" + if cal.components != frozenset({"VTODO"}): + return False + excluded = { + normalize_project_name(name) + for name in (cfg.inbox_list, cfg.someday_list, *cfg.exclude_lists) + } + return normalize_project_name(cal.displayname) not in excluded + + +def _list_project_calendars(client: _DavClient, cfg: CalDAVConfig) -> list[_CalendarInfo]: + return [cal for cal in _list_calendars(client, cfg.url) if _is_project_calendar(cal, cfg)] + + +def _find_calendar(client: _DavClient, cfg: CalDAVConfig, name: str) -> Optional[_CalendarInfo]: + """Any calendar in the account matching `name` case-normalized - + unfiltered, so this also finds the fixed inbox/someday lists.""" + target = normalize_project_name(name) + for cal in _list_calendars(client, cfg.url): + if normalize_project_name(cal.displayname) == target: + return cal + return None + + +# --- reading VTODO resources ------------------------------------------------------- + +_REPORT_VTODOS_BODY = b""" + + + + + + + + + + + +""" + + +@dataclass(frozen=True) +class _VTodoResource: + href: str + etag: Optional[str] + todo: Any # icalendar.Todo + + +def _fetch_vtodos(client: _DavClient, calendar_href: str) -> list[_VTodoResource]: + """Every VTODO resource in one calendar collection, open and closed alike + - status filtering happens in Python (`_is_open`), not via a server-side + filter, so this reads the same way regardless of which CalDAV server is + behind `calendar_href`. A resource whose `calendar-data` does not parse + as a VTODO fails loudly (#139's own acceptance criterion), never gets + silently dropped.""" + status, _headers, body = client.report(calendar_href, depth=1, body=_REPORT_VTODOS_BODY) + if status != 207: + raise ValidationError( + f"caldav: reading items from {calendar_href} failed - expected HTTP 207 " + f"Multi-Status, got {status}." + ) + result: list[_VTodoResource] = [] + for response_el in _multistatus_responses(body): + href_el = response_el.find(f"{{{NS_D}}}href") + if href_el is None or not href_el.text: + continue + prop = _prop_ok(response_el) + if prop is None: + continue + data_el = prop.find(f"{{{NS_C}}}calendar-data") + if data_el is None or not data_el.text: + continue + etag_el = prop.find(f"{{{NS_D}}}getetag") + etag = etag_el.text if etag_el is not None else None + try: + calendar = icalendar.Calendar.from_ical(data_el.text) + except (ValueError, IndexError) as exc: + raise ValidationError( + f"caldav: {href_el.text} under {calendar_href} is not a parsable VTODO ({exc})." + ) from exc + todos = calendar.walk("VTODO") + if not todos: + raise ValidationError( + f"caldav: {href_el.text} under {calendar_href} has no VTODO component." + ) + href = urljoin(calendar_href, href_el.text) + result.append(_VTodoResource(href=href, etag=etag, todo=todos[0])) + return result + + +def _prop_date(todo: Any, name: str) -> Optional[date]: + prop = todo.get(name) + if prop is None: + return None + value = getattr(prop, "dt", None) + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return None + + +def _summary(todo: Any) -> str: + return str(todo.get("SUMMARY", "")) + + +def _is_waiting(todo: Any) -> bool: + prop = todo.get("CATEGORIES") + if prop is None: + return False + cats = getattr(prop, "cats", None) or [] + return any(str(cat).strip().casefold() == WAITING_CATEGORY for cat in cats) + + +def _is_open(todo: Any) -> bool: + status = str(todo.get("STATUS", "NEEDS-ACTION")).strip().upper() + return status not in ("COMPLETED", "CANCELLED") + + +# --- write bodies ------------------------------------------------------------------ + +def _mkcalendar_body(name: str) -> bytes: + return f""" + + + + {_xml_escape(name)} + + + + + + +""".encode("utf-8") + + +def _new_vtodo_ics( + *, uid: str, title: str, waiting: bool, follow_up_at: Optional[date], notes: Optional[str], +) -> bytes: + calendar = icalendar.Calendar() + calendar.add("prodid", "-//chemenu//caldav//EN") + calendar.add("version", "2.0") + todo = icalendar.Todo() + todo.add("uid", uid) + todo.add("summary", title) + todo.add("status", "NEEDS-ACTION") + now = datetime.now(timezone.utc) + todo.add("dtstamp", now) + todo.add("created", now) + if notes: + todo.add("description", notes) + if waiting: + todo.add("categories", [WAITING_CATEGORY]) + if follow_up_at is not None: + todo.add("dtstart", follow_up_at) # a `date`, not `datetime` -> all-day + calendar.add_component(todo) + return calendar.to_ical() + + +def _set_prop(component: Any, name: str, value: Any) -> None: + """Replace `component[name]` with exactly one value - `.add()` alone can + duplicate a property that is already present, and `close_item` must leave + every property it does not name completely alone (#139's own acceptance + criterion), including this one having exactly the single value it sets.""" + if name in component: + del component[name] + component.add(name, value) + + +# --- doctor probe -------------------------------------------------------------------- + +def probe(cfg: CalDAVConfig, *, timeout: float = 5.0) -> str: + """A short, human-readable reachability/auth line for `doctor` - the same + posture `superproductivity.health` takes: never raises, provider + reachability is reported but never fails the check.""" + client = _DavClient(cfg) + try: + status, _headers, _body = client.propfind(cfg.url, depth=0, body=_PROPFIND_SELF_BODY) + except ValidationError as exc: + return f"unreachable ({exc})" + if status == 207: + return "reachable, authenticated" + if status in (401, 403): + return f"reachable, authentication failed (HTTP {status})" + return f"reachable, unexpected response (HTTP {status})" + + +# --- TaskReader ------------------------------------------------------------------------ + +class CalDAVReader: + """`TaskReader` over CalDAV - always live, re-reading on every call like + every other reader in this package.""" + + def __init__(self, cfg: CalDAVConfig): + self._cfg = cfg + self._client = _DavClient(cfg) + + def projects(self) -> list[ProjectSummary]: + result: list[ProjectSummary] = [] + for cal in _list_project_calendars(self._client, self._cfg): + vtodos = _fetch_vtodos(self._client, cal.href) + created_dates = [d for d in (_prop_date(v.todo, "CREATED") for v in vtodos) if d is not None] + result.append(ProjectSummary( + name=cal.displayname, created=min(created_dates) if created_dates else None, + )) + return result + + def open_items(self, project_name: str) -> OpenItems: + target = normalize_project_name(project_name) + cal = next( + (c for c in _list_project_calendars(self._client, self._cfg) + if normalize_project_name(c.displayname) == target), + None, + ) + if cal is None: + return OpenItems(count=0, waiting=(), items=()) + + waiting: list[WaitingItem] = [] + all_items: list[OpenItem] = [] + count = 0 + for v in _fetch_vtodos(self._client, cal.href): + if not _is_open(v.todo): + continue + count += 1 + title = _summary(v.todo) + is_waiting = _is_waiting(v.todo) + if is_waiting: + waiting.append(WaitingItem(id=v.href, title=title, follow_up_at=_prop_date(v.todo, "DTSTART"))) + all_items.append(OpenItem(id=v.href, title=title, waiting=is_waiting)) + return OpenItems(count=count, waiting=tuple(waiting), items=tuple(all_items)) + + def someday_items(self) -> list[SomedayItem]: + cal = _find_calendar(self._client, self._cfg, self._cfg.someday_list) + if cal is None: + raise ValidationError( + f"caldav: configured someday_list {self._cfg.someday_list!r} was not found in " + "this account." + ) + items: list[SomedayItem] = [] + for v in _fetch_vtodos(self._client, cal.href): + if not _is_open(v.todo): + continue + items.append(SomedayItem( + id=v.href, title=_summary(v.todo), modified=_prop_date(v.todo, "LAST-MODIFIED"), + )) + return items + + def source(self) -> ReadSource: + return ReadSource(kind=SOURCE_KIND, detail="live (CalDAV)") + + +# --- TaskWriter ------------------------------------------------------------------------ + +class CalDAVWriter: + """`TaskWriter` over CalDAV. Unlike `SuperProductivityWriter`, + `create_project` genuinely creates something - `MKCALENDAR` is a real + collection-creation verb, so this never raises + `chemenu.errors.HumanInterventionRequired`. `reader` is accepted for the + same signature `chemenu.tasks.build_writer` uses for every provider, but + is not consulted here: the writes below need a calendar's own `href`, + which `chemenu.tasks.protocol.ProjectSummary` deliberately does not + carry, so this reads calendars itself rather than through the generic + protocol object.""" + + def __init__(self, cfg: CalDAVConfig, reader: Any = None): # noqa: ARG002 - see class docstring + self._cfg = cfg + self._client = _DavClient(cfg) + + def create_project(self, name: str) -> None: + """`MKCALENDAR` under a fresh, opaque URL segment (a UUID, never the + project name - see the module docstring's "Addresses only ever come + from the server"). Preflighted against *every* list in the account + (D5), not only the ones counted as projects.""" + collision = _find_calendar(self._client, self._cfg, name) + if collision is not None: + raise ValidationError( + f"A list named '{collision.displayname}' (case-insensitively) already exists " + "in this CalDAV account - nothing was created." + ) + new_url = urljoin(self._cfg.url, uuid.uuid4().hex + "/") + status, _headers, body = self._client.mkcalendar(new_url, _mkcalendar_body(name)) + if status not in (200, 201): + raise ValidationError( + f"caldav: MKCALENDAR for '{name}' failed (HTTP {status}): " + f"{body[:300]!r}" + ) + + def create_item( + self, + title: str, + *, + project_name: Optional[str], + waiting: bool = False, + follow_up_at: Optional[date] = None, + notes: Optional[str] = None, + ) -> None: + if project_name is None: + cal = _find_calendar(self._client, self._cfg, self._cfg.inbox_list) + if cal is None: + raise ValidationError( + f"caldav: configured inbox_list {self._cfg.inbox_list!r} was not found in " + "this account." + ) + else: + cal = next( + (c for c in _list_project_calendars(self._client, self._cfg) + if normalize_project_name(c.displayname) == normalize_project_name(project_name)), + None, + ) + if cal is None: + raise ValidationError( + f"No project named '{project_name}' (case-insensitively) exists in this " + "CalDAV account - this command does not create one (Gitea #132 D6). Run " + "`wikitool new project` first, or pass --inbox." + ) + + uid = str(uuid.uuid4()) + href = urljoin(cal.href, uid + ".ics") + body = _new_vtodo_ics(uid=uid, title=title, waiting=waiting, follow_up_at=follow_up_at, notes=notes) + status, _headers, response_body = self._client.put(href, body, if_none_match="*") + if status not in (200, 201, 204): + raise ValidationError( + f"caldav: creating '{title}' failed (HTTP {status}): {response_body[:300]!r}" + ) + + def close_item(self, item_id: str) -> None: + """`GET` the resource, change only `STATUS`/`COMPLETED`/ + `PERCENT-COMPLETE`/`LAST-MODIFIED`/`DTSTAMP`, `PUT` it back with + `If-Match: ` - see the module docstring. `item_id` is the + resource's own href, exactly as a read call returned it (#139's own + acceptance criterion on addresses).""" + status, headers, body = self._client.get(item_id) + if status == 404: + raise ValidationError(f"caldav: no item exists at {item_id} - nothing was closed.") + if status != 200: + raise ValidationError(f"caldav: reading {item_id} failed (HTTP {status}).") + etag = headers.get("ETag") or headers.get("etag") + if not etag: + raise ValidationError( + f"caldav: {item_id} carries no ETag - cannot write it back safely without one." + ) + try: + calendar = icalendar.Calendar.from_ical(body) + except (ValueError, IndexError) as exc: + raise ValidationError(f"caldav: {item_id} is not a parsable VTODO ({exc}).") from exc + todos = calendar.walk("VTODO") + if not todos: + raise ValidationError(f"caldav: {item_id} has no VTODO component.") + todo = todos[0] + + now = datetime.now(timezone.utc) + _set_prop(todo, "STATUS", "COMPLETED") + _set_prop(todo, "COMPLETED", now) + _set_prop(todo, "PERCENT-COMPLETE", 100) + _set_prop(todo, "LAST-MODIFIED", now) + _set_prop(todo, "DTSTAMP", now) + + put_status, _headers, response_body = self._client.put( + item_id, calendar.to_ical(), if_match=etag, + ) + if put_status == 412: + raise ValidationError( + f"caldav: {item_id} changed since it was read (ETag mismatch) - nothing was " + "written. Re-run the review/list and try again with the current id." + ) + if put_status not in (200, 204): + raise ValidationError( + f"caldav: closing {item_id} failed (HTTP {put_status}): {response_body[:300]!r}" + ) diff --git a/tools/chemenu/tasks/config.py b/tools/chemenu/tasks/config.py index f619db1..ea2e80e 100644 --- a/tools/chemenu/tasks/config.py +++ b/tools/chemenu/tasks/config.py @@ -18,7 +18,7 @@ from chemenu.errors import ValidationError # typo in `provider` fails here, at the one place that knows the full list, # rather than surfacing later as an unhelpful "unknown provider" from whatever # code tried to dispatch on it. -KNOWN_PROVIDERS = ("superproductivity",) +KNOWN_PROVIDERS = ("superproductivity", "caldav") @dataclass(frozen=True) diff --git a/tools/chemenu/tests/test_caldav.py b/tools/chemenu/tests/test_caldav.py new file mode 100644 index 0000000..aebc699 --- /dev/null +++ b/tools/chemenu/tests/test_caldav.py @@ -0,0 +1,873 @@ +"""Tests for `chemenu.tasks.caldav` (Gitea #139). + +Every test runs against a local stub HTTP server (`http.server`, a loopback +socket only) - never a real CalDAV server, per #139's own acceptance +criterion. Two kinds of stub are used: a *canned* server that answers fixed +(method, path) pairs with a fixed response, for read-path tests where the +exact request sequence is known in advance; and a small *stateful* server +that actually tracks calendars/resources across requests, for the write-path +tests (`create_project`/`create_item`/`close_item`) where a later read must +see an earlier write. +""" +from __future__ import annotations + +import base64 +import contextlib +import http.server +import threading +from datetime import date, datetime, timezone +from typing import Optional +from xml.sax.saxutils import escape as _xml_escape + +import icalendar +import pytest + +from chemenu.errors import ValidationError +from chemenu.tasks import caldav as cd + +TOKEN = ("me", "app-secret") + + +def _auth_header() -> str: + return "Basic " + base64.b64encode(f"{TOKEN[0]}:{TOKEN[1]}".encode()).decode("ascii") + + +def _cfg(port: int, *, path: str = "/cal/", **overrides) -> cd.CalDAVConfig: + data = { + "url": f"http://127.0.0.1:{port}{path}", + "username": TOKEN[0], + "app_password": TOKEN[1], + "inbox_list": "Inbox", + "someday_list": "Someday", + **overrides, + } + return cd.CalDAVConfig.from_dict(data) + + +# --- fixture builders -------------------------------------------------------------- + +def _calendars_multistatus(entries: list[tuple[str, str, Optional[frozenset]]]) -> bytes: + """`entries`: (href, displayname, components-or-None). `None` means "not + a calendar collection at all" (e.g. the home-set entry itself).""" + responses = [] + for href, displayname, components in entries: + if components is None: + resourcetype = "" + comp_set = "" + else: + resourcetype = "" + comps = "".join(f'' for c in sorted(components)) + comp_set = f"{comps}" + responses.append(f""" + {_xml_escape(href)} + + + {resourcetype} + {_xml_escape(displayname)} + {comp_set} + + HTTP/1.1 200 OK + +""") + return f""" + +{"".join(responses)} +""".encode("utf-8") + + +def _vtodos_multistatus(entries: list[tuple[str, str, bytes]]) -> bytes: + """`entries`: (href, etag, raw ics bytes).""" + responses = [] + for href, etag, ics in entries: + responses.append(f""" + {_xml_escape(href)} + + + {_xml_escape(etag)} + {_xml_escape(ics.decode("utf-8"))} + + HTTP/1.1 200 OK + +""") + return f""" + +{"".join(responses)} +""".encode("utf-8") + + +def _vtodo_ics( + *, uid: str = "t1", summary: str = "Warte auf Angebot", status: str = "NEEDS-ACTION", + categories: Optional[list[str]] = None, dtstart: Optional[date] = None, + created: Optional[datetime] = None, due: Optional[date] = None, + extra_lines: Optional[list[str]] = None, +) -> bytes: + cal = icalendar.Calendar() + cal.add("prodid", "-//fixture//") + cal.add("version", "2.0") + todo = icalendar.Todo() + todo.add("uid", uid) + todo.add("summary", summary) + todo.add("status", status) + if categories: + todo.add("categories", categories) + if dtstart is not None: + todo.add("dtstart", dtstart) + if created is not None: + todo.add("created", created) + if due is not None: + todo.add("due", due) + cal.add_component(todo) + body = cal.to_ical() + if extra_lines: + # Inject raw lines (e.g. X-APPLE-SORT-ORDER, VALARM) right before END:VTODO, + # the way a real resource written by iOS would carry them. + text = body.decode("utf-8").replace("\r\n", "\n") + marker = "END:VTODO" + idx = text.index(marker) + text = text[:idx] + "\n".join(extra_lines) + "\n" + text[idx:] + body = text.encode("utf-8") + return body + + +# --- canned HTTP stub --------------------------------------------------------------- + +def _make_canned_handler(routes: dict[tuple[str, str], tuple[int, dict, bytes]]): + class Handler(http.server.BaseHTTPRequestHandler): + def _handle(self): + length = int(self.headers.get("Content-Length", "0")) + if length: + self.rfile.read(length) + if self.headers.get("Authorization") != _auth_header(): + self._reply(401, {}, b'{"error": "unauthorized"}') + return + key = (self.command, self.path) + if key not in routes: + self._reply(404, {}, b'{"error": "not found"}') + return + status, headers, body = routes[key] + self._reply(status, headers, body) + + def _reply(self, status, headers, body): + self.send_response(status) + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + do_PROPFIND = _handle + do_REPORT = _handle + do_GET = _handle + do_PUT = _handle + do_MKCALENDAR = _handle + + def log_message(self, *args): # silence stderr noise during the test run + pass + + return Handler + + +@contextlib.contextmanager +def _canned_server(routes: dict[tuple[str, str], tuple[int, dict, bytes]]): + handler_cls = _make_canned_handler(routes) + 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) + + +# --- stateful HTTP stub (write path) ------------------------------------------------- + +def _make_stateful_handler(state: dict): + """`state["calendars"]`: path -> {"displayname", "components", "resource_paths"}. + `state["resources"]`: path -> {"etag": str, "body": bytes}. `state["created"]` + collects every MKCALENDAR call's (path, displayname), so a test can assert + on it without inspecting the calendars dict directly.""" + + class Handler(http.server.BaseHTTPRequestHandler): + def _auth_ok(self) -> bool: + return self.headers.get("Authorization") == _auth_header() + + def _body(self) -> bytes: + length = int(self.headers.get("Content-Length", "0")) + return self.rfile.read(length) if length else b"" + + def _forced(self) -> bool: + """`state["force_status"][method]`, if set, short-circuits the + normal handling below with a fixed (status, headers, body) - for + tests exercising an "unexpected response" branch that a real + server would rarely produce (a 500, say) without simulating a + whole broken deployment.""" + forced = state.get("force_status", {}).get(self.command) + if forced is None: + return False + status, headers, body = forced + self._reply(status, headers, body) + return True + + def do_PROPFIND(self): # noqa: N802 + if not self._auth_ok(): + self._reply(401, {}, b"") + return + self._body() + if self._forced(): + return + depth = self.headers.get("Depth", "0") + if depth == "0": + # Only the probe / a single-collection lookup uses depth 0 here. + self._reply(207, {}, _calendars_multistatus([])) + return + entries = [ + (path, info["displayname"], info["components"]) + for path, info in state["calendars"].items() + ] + self._reply(207, {"Content-Type": "application/xml"}, _calendars_multistatus(entries)) + + def do_REPORT(self): # noqa: N802 + if not self._auth_ok(): + self._reply(401, {}, b"") + return + self._body() + info = state["calendars"].get(self.path) + if info is None: + self._reply(404, {}, b"") + return + entries = [ + (rpath, state["resources"][rpath]["etag"], state["resources"][rpath]["body"]) + for rpath in info["resource_paths"] + ] + self._reply(207, {"Content-Type": "application/xml"}, _vtodos_multistatus(entries)) + + def do_MKCALENDAR(self): # noqa: N802 + if not self._auth_ok(): + self._reply(401, {}, b"") + return + body_bytes = self._body() + if self._forced(): + return + body = body_bytes.decode("utf-8") + import re + + match = re.search(r"(.*?)", body) + displayname = match.group(1) if match else "" + state["calendars"][self.path] = { + "displayname": displayname, "components": frozenset({"VTODO"}), "resource_paths": set(), + } + state.setdefault("created", []).append((self.path, displayname)) + self._reply(201, {}, b"") + + def do_PUT(self): # noqa: N802 + if not self._auth_ok(): + self._reply(401, {}, b"") + return + body = self._body() + if self._forced(): + return + existing = state["resources"].get(self.path) + if_none_match = self.headers.get("If-None-Match") + if_match = self.headers.get("If-Match") + if if_none_match == "*" and existing is not None: + self._reply(412, {}, b"") + return + if if_match is not None: + if existing is None: + self._reply(404, {}, b"") + return + if existing["etag"] != if_match: + self._reply(412, {}, b"") + return + state.setdefault("etag_counter", 0) + state["etag_counter"] += 1 + new_etag = f'"etag-{state["etag_counter"]}"' + state["resources"][self.path] = {"etag": new_etag, "body": body} + if existing is None: + # Attach to whichever calendar's path is this resource's parent. + parent = max( + (p for p in state["calendars"] if self.path.startswith(p)), + key=len, default=None, + ) + if parent is not None: + state["calendars"][parent]["resource_paths"].add(self.path) + self._reply(201, {}, b"") + else: + self._reply(204, {}, b"") + + def do_GET(self): # noqa: N802 + if not self._auth_ok(): + self._reply(401, {}, b"") + return + if self._forced(): + return + resource = state["resources"].get(self.path) + if resource is None: + self._reply(404, {}, b"") + return + self._reply(200, {"ETag": resource["etag"]}, resource["body"]) + + def _reply(self, status, headers, body): + self.send_response(status) + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + if body: + self.wfile.write(body) + + def log_message(self, *args): # silence stderr noise during the test run + pass + + return Handler + + +@contextlib.contextmanager +def _stateful_server(state: dict): + handler_cls = _make_stateful_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 + finally: + server.shutdown() + thread.join(timeout=2) + + +def _fresh_state() -> dict: + return {"calendars": {}, "resources": {}, "created": []} + + +# --- CalDAVConfig.from_dict ----------------------------------------------------------- + +def test_from_dict_requires_every_string_field(): + with pytest.raises(ValidationError, match="url"): + cd.CalDAVConfig.from_dict({ + "username": "u", "app_password": "p", "inbox_list": "Inbox", "someday_list": "Someday", + }) + + +def test_from_dict_rejects_unknown_fields(): + with pytest.raises(ValidationError, match="does not take"): + cd.CalDAVConfig.from_dict({ + "url": "https://x/", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", "backups_dir": "/x", + }) + + +def test_from_dict_exclude_lists_must_be_a_list_of_strings(): + with pytest.raises(ValidationError, match="exclude_lists"): + cd.CalDAVConfig.from_dict({ + "url": "https://x/", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", "exclude_lists": "not-a-list", + }) + + +def test_from_dict_exclude_lists_defaults_to_empty(): + cfg = cd.CalDAVConfig.from_dict({ + "url": "https://x/", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", + }) + assert cfg.exclude_lists == () + + +def test_from_dict_normalizes_a_missing_trailing_slash(): + cfg = cd.CalDAVConfig.from_dict({ + "url": "https://x/cal", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", + }) + assert cfg.url == "https://x/cal/" + + +# --- projects() / open_items() / someday_items() ----------------------------------- + +def test_projects_lists_vtodo_only_calendars_and_excludes_fixed_lists(): + entries = [ + ("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"})), + ("/cal/proj2/", "Kueche renovieren", frozenset({"VTODO"})), + ("/cal/mixed/", "Mixed Calendar", frozenset({"VTODO", "VEVENT"})), + ("/cal/inbox/", "Inbox", frozenset({"VTODO"})), + ("/cal/someday/", "Someday", frozenset({"VTODO"})), + ("/cal/other/", "Other Existing List", frozenset({"VTODO"})), + ("/cal/", "", None), # the home-set itself, not a calendar + ] + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])), + ("REPORT", "/cal/proj2/"): (207, {}, _vtodos_multistatus([])), + }) as server: + cfg = _cfg(server.server_address[1], exclude_lists=["Other Existing List"]) + reader = cd.CalDAVReader(cfg) + names = {p.name for p in reader.projects()} + assert names == {"Ship Chemenu 7.0", "Kueche renovieren"} + + +def test_projects_created_is_the_earliest_item_created_or_none_for_an_empty_list(): + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + ics1 = _vtodo_ics(uid="t1", created=datetime(2026, 3, 1, tzinfo=timezone.utc)) + ics2 = _vtodo_ics(uid="t2", created=datetime(2026, 1, 15, tzinfo=timezone.utc)) + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): ( + 207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics1), ("/cal/proj1/t2.ics", '"e2"', ics2)]), + ), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + project = reader.projects()[0] + assert project.created == date(2026, 1, 15) + + +def test_projects_created_is_none_for_an_empty_project(): + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + assert reader.projects()[0].created is None + + +def test_open_items_excludes_completed_and_cancelled(): + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + open_ics = _vtodo_ics(uid="t1", status="NEEDS-ACTION") + done_ics = _vtodo_ics(uid="t2", status="COMPLETED") + cancelled_ics = _vtodo_ics(uid="t3", status="CANCELLED") + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([ + ("/cal/proj1/t1.ics", '"e1"', open_ics), + ("/cal/proj1/t2.ics", '"e2"', done_ics), + ("/cal/proj1/t3.ics", '"e3"', cancelled_ics), + ])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + result = reader.open_items("ship CHEMENU 7.0") + assert result.count == 1 + assert result.items[0].id.endswith("/cal/proj1/t1.ics") + + +def test_open_items_unknown_project_is_empty_not_an_error(): + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus([])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + result = reader.open_items("No Such Project") + assert result.count == 0 + assert result.waiting == () + assert result.items == () + + +def test_open_items_waiting_is_read_from_categories_case_insensitively(): + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + ics = _vtodo_ics(uid="t1", categories=["Waiting"], dtstart=date(2026, 3, 1)) + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + result = reader.open_items("Ship Chemenu 7.0") + assert len(result.waiting) == 1 + assert result.waiting[0].follow_up_at == date(2026, 3, 1) + assert result.items[0].waiting is True + + +def test_follow_up_at_is_dtstart_never_due(): + """#119 D9/#135's meaning-not-name rule - a CalDAV DUE must never leak + into follow_up_at even when set alongside DTSTART.""" + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + ics = _vtodo_ics(uid="t1", categories=["waiting"], dtstart=date(2026, 3, 1), due=date(2026, 4, 1)) + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + waiting = reader.open_items("Ship Chemenu 7.0").waiting[0] + assert waiting.follow_up_at == date(2026, 3, 1) + + +def test_waiting_item_with_no_dtstart_has_no_follow_up_at(): + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + ics = _vtodo_ics(uid="t1", categories=["waiting"]) + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + waiting = reader.open_items("Ship Chemenu 7.0").waiting[0] + assert waiting.follow_up_at is None + + +def test_someday_items_come_from_the_configured_someday_list(): + entries = [ + ("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"})), + ("/cal/someday/", "Someday", frozenset({"VTODO"})), + ] + ics = icalendar.Calendar() + todo = icalendar.Todo() + todo.add("uid", "s1") + todo.add("summary", "Irgendwann Keller aufraeumen") + todo.add("status", "NEEDS-ACTION") + todo.add("last-modified", datetime(2026, 1, 15, tzinfo=timezone.utc)) + ics.add_component(todo) + body = ics.to_ical() + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([("/cal/someday/s1.ics", '"e1"', body)])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + items = reader.someday_items() + assert len(items) == 1 + assert items[0].title == "Irgendwann Keller aufraeumen" + assert items[0].modified == date(2026, 1, 15) + + +def test_someday_items_raises_if_the_configured_list_is_missing(): + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus([])), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + with pytest.raises(ValidationError, match="Someday"): + reader.someday_items() + + +def test_source_is_always_live_caldav(): + with _canned_server({}) as server: + source = cd.CalDAVReader(_cfg(server.server_address[1])).source() + assert source.kind == "api" + assert "CalDAV" in source.detail + + +# --- addresses only ever come from the server (#139's own acceptance criterion) ----- + +def test_hrefs_from_a_different_user_id_than_the_configured_url_are_used_as_returned(): + """Nextcloud accepts an alias in the configured URL but answers with the + canonical user id in every href - the adapter must follow that, never + glue the project name onto the *configured* url's own path.""" + entries = [("/dav/calendars/canonical-id/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + with _canned_server({ + ("PROPFIND", "/dav/calendars/shortname/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/dav/calendars/canonical-id/proj1/"): (207, {}, _vtodos_multistatus([])), + }) as server: + cfg = _cfg(server.server_address[1], path="/dav/calendars/shortname/") + reader = cd.CalDAVReader(cfg) + result = reader.open_items("Ship Chemenu 7.0") + assert result.count == 0 # the REPORT route above only answers if the right href was used + + +# --- unrecognized responses fail loudly, never as an empty result ------------------- + +def test_non_multistatus_response_fails_loud(): + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, b"not xml at all"), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + with pytest.raises(ValidationError): + reader.projects() + + +def test_wrong_http_status_fails_loud(): + with _canned_server({ + ("PROPFIND", "/cal/"): (500, {}, b"boom"), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + with pytest.raises(ValidationError): + reader.projects() + + +def test_unparsable_vtodo_fails_loud_not_silent(): + entries = [("/cal/proj1/", "Ship Chemenu 7.0", frozenset({"VTODO"}))] + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): ( + 207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', b"NOT A VALID ICS")]), + ), + }) as server: + reader = cd.CalDAVReader(_cfg(server.server_address[1])) + with pytest.raises(ValidationError): + reader.open_items("Ship Chemenu 7.0") + + +def test_unreachable_server_fails_loud(): + cfg = cd.CalDAVConfig.from_dict({ + "url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", + }) + reader = cd.CalDAVReader(cfg) + with pytest.raises(ValidationError): + reader.projects() + + +# --- probe() (doctor) ---------------------------------------------------------------- + +def test_probe_reports_reachable_and_authenticated(): + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus([])), + }) as server: + assert cd.probe(_cfg(server.server_address[1])) == "reachable, authenticated" + + +def test_probe_reports_authentication_failure(): + with _canned_server({}) as server: # no route -> every request 401s at the auth check + state = cd.probe(_cfg(server.server_address[1], app_password="wrong")) + assert "authentication failed" in state + + +def test_probe_reports_unreachable(): + cfg = cd.CalDAVConfig.from_dict({ + "url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", + }) + assert "unreachable" in cd.probe(cfg, timeout=0.5) + + +# --- write path: create_project (D5, automatic, no HumanInterventionRequired) ------- + +def test_create_project_succeeds_and_uses_an_opaque_url_never_the_name(): + state = _fresh_state() + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + writer.create_project("Ship Chemenu 7.0") + + assert len(state["created"]) == 1 + path, displayname = state["created"][0] + assert displayname == "Ship Chemenu 7.0" + assert "Ship" not in path and "chemenu" not in path.lower() + assert state["calendars"][path]["components"] == frozenset({"VTODO"}) + + +def test_create_project_refuses_a_collision_against_any_list_in_the_account(): + """D5's own acceptance criterion: the collision check runs against every + list in the account, including one that would never count as a project - + an excluded list, the inbox, the someday list, or a mixed calendar.""" + state = _fresh_state() + state["calendars"]["/cal/excluded/"] = { + "displayname": "Legacy List", "components": frozenset({"VTODO"}), "resource_paths": set(), + } + with _stateful_server(state) as server: + writer = cd.CalDAVWriter( + _cfg(server.server_address[1], exclude_lists=["Legacy List"]), reader=None, + ) + with pytest.raises(ValidationError, match="Legacy List"): + writer.create_project("legacy list") + assert state["created"] == [] + + +# --- write path: create_item (Gitea #132 semantics, over CalDAV) -------------------- + +def test_create_item_posts_into_the_projects_own_href(): + state = _fresh_state() + state["calendars"]["/cal/proj1/"] = { + "displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), "resource_paths": set(), + } + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + writer.create_item("Rueckruf beim Kunden", project_name="ship CHEMENU 7.0") + + resource_paths = state["calendars"]["/cal/proj1/"]["resource_paths"] + assert len(resource_paths) == 1 + body = state["resources"][next(iter(resource_paths))]["body"] + todo = icalendar.Calendar.from_ical(body).walk("VTODO")[0] + assert str(todo["SUMMARY"]) == "Rueckruf beim Kunden" + + +def test_create_item_refuses_an_unknown_project_and_writes_nothing(): + state = _fresh_state() + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="No project named"): + writer.create_item("x", project_name="No Such Project") + assert state["resources"] == {} + + +def test_create_item_inbox_route_uses_the_configured_inbox_list(): + state = _fresh_state() + state["calendars"]["/cal/inbox/"] = { + "displayname": "Inbox", "components": frozenset({"VTODO"}), "resource_paths": set(), + } + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + writer.create_item("Beleg ablegen", project_name=None) + + assert len(state["calendars"]["/cal/inbox/"]["resource_paths"]) == 1 + + +def test_create_item_sets_categories_and_dtstart_and_description(): + state = _fresh_state() + state["calendars"]["/cal/proj1/"] = { + "displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), "resource_paths": set(), + } + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + writer.create_item( + "Nachfassen beim Elektriker", project_name="Ship Chemenu 7.0", + waiting=True, follow_up_at=date(2026, 4, 1), notes="Source - Kundenmail", + ) + + resource_path = next(iter(state["calendars"]["/cal/proj1/"]["resource_paths"])) + todo = icalendar.Calendar.from_ical(state["resources"][resource_path]["body"]).walk("VTODO")[0] + assert [str(c) for c in todo["CATEGORIES"].cats] == ["waiting"] + assert todo["DTSTART"].dt == date(2026, 4, 1) + assert str(todo["DESCRIPTION"]) == "Source - Kundenmail" + + +# --- write path: close_item (Gitea #138 semantics, over CalDAV) --------------------- + +def test_close_item_changes_only_the_five_named_fields(): + state = _fresh_state() + ics = _vtodo_ics( + uid="t1", summary="x", dtstart=date(2026, 3, 1), + extra_lines=["X-APPLE-SORT-ORDER:12345", "BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER:-PT15M", "END:VALARM"], + ) + state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics} + state["calendars"]["/cal/proj1/"] = { + "displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), + "resource_paths": {"/cal/proj1/t1.ics"}, + } + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + writer.close_item(item_id) + + updated = icalendar.Calendar.from_ical(state["resources"]["/cal/proj1/t1.ics"]["body"]) + todo = updated.walk("VTODO")[0] + assert str(todo["STATUS"]) == "COMPLETED" + assert int(todo["PERCENT-COMPLETE"]) == 100 + assert "COMPLETED" in todo + # Untouched: + assert todo["DTSTART"].dt == date(2026, 3, 1) + assert str(todo["X-APPLE-SORT-ORDER"]) == "12345" + alarms = updated.walk("VALARM") + assert len(alarms) == 1 + assert str(alarms[0]["TRIGGER"].to_ical().decode()) == "-PT15M" + + +def test_close_item_conflicting_etag_writes_nothing_and_raises(): + state = _fresh_state() + ics = _vtodo_ics(uid="t1") + state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics} + state["calendars"]["/cal/proj1/"] = { + "displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), + "resource_paths": {"/cal/proj1/t1.ics"}, + } + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + # A concurrent change bumps the etag between the GET and the PUT this + # writer will do - simulate it by mutating state directly. + original_body = state["resources"]["/cal/proj1/t1.ics"]["body"] + + real_get = writer._client.get + + def _get_then_conflict(url): + result = real_get(url) + state["resources"]["/cal/proj1/t1.ics"]["etag"] = '"e2-changed-concurrently"' + return result + + writer._client.get = _get_then_conflict + with pytest.raises(ValidationError, match="changed since"): + writer.close_item(item_id) + + assert state["resources"]["/cal/proj1/t1.ics"]["body"] == original_body + + +def test_close_item_unknown_id_refuses_and_writes_nothing(): + state = _fresh_state() + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/no-such.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="no item"): + writer.close_item(item_id) + assert state["resources"] == {} + + +def test_close_item_never_sends_delete(): + """No `DELETE` route exists on the stateful stub at all - if `close_item` + ever sent one, the request would 501 (BaseHTTPRequestHandler's default + for an undefined `do_*`) and the test below would fail loudly instead of + silently passing, which is the point: this locks in that no such call + happens by construction (Gitea #138 E7).""" + state = _fresh_state() + ics = _vtodo_ics(uid="t1") + state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics} + state["calendars"]["/cal/proj1/"] = { + "displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), + "resource_paths": {"/cal/proj1/t1.ics"}, + } + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + writer.close_item(item_id) + + assert "/cal/proj1/t1.ics" in state["resources"] # still there - marked done, not removed + + +# --- write path: unexpected server responses fail loudly ---------------------------- + +def test_create_project_unexpected_mkcalendar_status_fails_loud(): + state = _fresh_state() + state["force_status"] = {"MKCALENDAR": (500, {}, b"boom")} + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="MKCALENDAR"): + writer.create_project("New Project") + + +def test_create_item_unexpected_put_status_fails_loud(): + state = _fresh_state() + state["calendars"]["/cal/proj1/"] = { + "displayname": "Ship Chemenu 7.0", "components": frozenset({"VTODO"}), "resource_paths": set(), + } + state["force_status"] = {"PUT": (500, {}, b"boom")} + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="creating"): + writer.create_item("x", project_name="Ship Chemenu 7.0") + + +def test_create_item_refuses_when_the_configured_inbox_list_is_missing(): + state = _fresh_state() + with _stateful_server(state) as server: + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="Inbox"): + writer.create_item("x", project_name=None) + + +def test_close_item_unexpected_get_status_fails_loud(): + state = _fresh_state() + state["force_status"] = {"GET": (500, {}, b"boom")} + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="reading"): + writer.close_item(item_id) + + +def test_close_item_unparsable_resource_fails_loud(): + state = _fresh_state() + state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": b"NOT A VALID ICS"} + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + with pytest.raises(ValidationError, match="not a parsable VTODO"): + writer.close_item(item_id) + assert state["resources"]["/cal/proj1/t1.ics"]["body"] == b"NOT A VALID ICS" + + +def test_close_item_unexpected_put_status_fails_loud(): + state = _fresh_state() + ics = _vtodo_ics(uid="t1") + state["resources"]["/cal/proj1/t1.ics"] = {"etag": '"e1"', "body": ics} + with _stateful_server(state) as server: + item_id = f"http://127.0.0.1:{server.server_address[1]}/cal/proj1/t1.ics" + writer = cd.CalDAVWriter(_cfg(server.server_address[1]), reader=None) + + real_get = writer._client.get + + def _get_then_force_put_error(url): + result = real_get(url) + state["force_status"] = {"PUT": (500, {}, b"boom")} + return result + + writer._client.get = _get_then_force_put_error + with pytest.raises(ValidationError, match="closing"): + writer.close_item(item_id) diff --git a/tools/chemenu/tests/test_doctor.py b/tools/chemenu/tests/test_doctor.py index 0a4f1aa..b0f0856 100644 --- a/tools/chemenu/tests/test_doctor.py +++ b/tools/chemenu/tests/test_doctor.py @@ -413,6 +413,39 @@ def test_tasks_provider_ok_but_names_api_unreachable_when_configured(instance): assert "not reachable" in detail +def test_tasks_provider_fails_on_a_bad_caldav_section(instance): + (config.ROOT / config.TASKS_CONFIG_FILENAME).write_text( + '{"schema": 1, "provider": "caldav", "thresholds": ' + '{"stalled_waiting_days": 14, "unpaged_project_weeks": 3, "someday_stale_months": 5}, ' + '"caldav": {}}', + encoding="utf-8", + ) + checks = doctor.run_doctor() + assert _status(checks, "tasks-provider") == "FAIL" + + +def test_tasks_provider_ok_but_names_caldav_unreachable_when_configured(instance): + (config.ROOT / config.TASKS_CONFIG_FILENAME).write_text( + json.dumps({ + "schema": 1, "provider": "caldav", + "thresholds": { + "stalled_waiting_days": 14, "unpaged_project_weeks": 3, + "someday_stale_months": 5, + }, + "caldav": { + "url": "http://127.0.0.1:1/cal/", "username": "u", "app_password": "p", + "inbox_list": "Inbox", "someday_list": "Someday", + }, + }), + encoding="utf-8", + ) + checks = doctor.run_doctor() + assert _status(checks, "tasks-provider") == "OK" + detail = _detail(checks, "tasks-provider") + assert "caldav" in detail + assert "unreachable" in detail + + def test_missing_generated_file_fails(instance): config.LOG_FILE.unlink() checks = doctor.run_doctor() diff --git a/tools/chemenu/tests/test_review.py b/tools/chemenu/tests/test_review.py index dbf8cbb..9c2655e 100644 --- a/tools/chemenu/tests/test_review.py +++ b/tools/chemenu/tests/test_review.py @@ -24,12 +24,21 @@ from chemenu.frontmatter_io import write_page from chemenu.tests.conftest import use_shipped_type_specs from chemenu.review import ( CHECK_NO_OPEN_LOOP, + CHECK_PROJECT_AGE_UNKNOWN, CHECK_SOMEDAY_STALE, CHECK_STALLED, CHECK_UNPAGED_PROJECT, + CHECK_WAITING_NO_FOLLOWUP, CHECK_WAITING_OVERDUE, run_review, ) +from chemenu.tests.test_caldav import ( + TOKEN as _CALDAV_TOKEN, + _calendars_multistatus, + _canned_server, + _vtodo_ics, + _vtodos_multistatus, +) runner = CliRunner() @@ -81,6 +90,21 @@ def _write_tasks_config(root: Path, backups_dir: Path, thresholds: dict | None = ) +def _write_caldav_tasks_config(root: Path, *, url: str, thresholds: dict | None = None) -> None: + (root / ".wikitool-tasks.json").write_text( + json.dumps({ + "schema": 1, + "provider": "caldav", + "thresholds": thresholds or THRESHOLDS, + "caldav": { + "url": url, "username": _CALDAV_TOKEN[0], "app_password": _CALDAV_TOKEN[1], + "inbox_list": "Inbox", "someday_list": "Someday", + }, + }), + encoding="utf-8", + ) + + def _project_page(root: Path, name: str, state: str, *, area: str = "haus") -> None: write_page( root / "kb" / "gtd" / area / f"{name}.md", @@ -217,6 +241,117 @@ def test_check3_and_check2_are_case_normalized_and_report_no_mismatch(tmp_path): assert not any(f.check == CHECK_NO_OPEN_LOOP for f in report.findings) +# --- D3 (Gitea #139): a value the provider cannot supply is its own finding -------- + + +def test_d3_sp_waiting_with_no_follow_up_at_is_its_own_finding_not_a_silent_skip(tmp_path): + 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"]}} # no dueWithTime, no dueDay + 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) + findings = [f for f in report.findings if f.project == "Kueche renovieren"] + assert any(f.check == CHECK_WAITING_NO_FOLLOWUP for f in findings) + assert not any(f.check == CHECK_WAITING_OVERDUE for f in findings) + assert report.complete + + +def test_d3_sp_project_with_no_created_date_is_its_own_finding_not_a_silent_skip(tmp_path): + projects = {"p1": {"id": "p1", "title": "No Page Yet", "taskIds": [], "backlogTaskIds": []}} + backups_dir = _write_snapshot(tmp_path, projects, {}, {}) + _write_tasks_config(tmp_path, backups_dir) + (tmp_path / "kb" / "gtd").mkdir(parents=True) + + report = run_review(tmp_path, today=TODAY) + findings = [f for f in report.findings if f.project == "No Page Yet"] + assert any(f.check == CHECK_PROJECT_AGE_UNKNOWN for f in findings) + assert not any(f.check == CHECK_UNPAGED_PROJECT for f in findings) + assert report.complete + + +def test_d3_caldav_waiting_with_no_follow_up_at_is_its_own_finding(tmp_path): + entries = [ + ("/cal/proj1/", "Kueche renovieren", frozenset({"VTODO"})), + ("/cal/someday/", "Someday", frozenset({"VTODO"})), + ] + ics = _vtodo_ics(uid="t1", summary="Warte auf Angebot", categories=["waiting"]) # no dtstart + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([("/cal/proj1/t1.ics", '"e1"', ics)])), + ("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([])), + }) as server: + _write_caldav_tasks_config(tmp_path, url=f"http://127.0.0.1:{server.server_address[1]}/cal/") + _project_page(tmp_path, "Kueche renovieren", "active") + + report = run_review(tmp_path, today=TODAY) + + findings = [f for f in report.findings if f.project == "Kueche renovieren"] + assert any(f.check == CHECK_WAITING_NO_FOLLOWUP for f in findings) + assert not any(f.check == CHECK_WAITING_OVERDUE for f in findings) + assert report.complete + + +def test_d3_caldav_project_with_no_determinable_created_date_is_its_own_finding(tmp_path): + """No CalDAV server in #139's own test account ever returns + `DAV:creationdate` on a calendar - an empty project is the concrete case + where the substitute value (earliest item `CREATED`) has nothing to work + from.""" + entries = [ + ("/cal/proj1/", "No Page Yet", frozenset({"VTODO"})), + ("/cal/someday/", "Someday", frozenset({"VTODO"})), + ] + with _canned_server({ + ("PROPFIND", "/cal/"): (207, {}, _calendars_multistatus(entries)), + ("REPORT", "/cal/proj1/"): (207, {}, _vtodos_multistatus([])), + ("REPORT", "/cal/someday/"): (207, {}, _vtodos_multistatus([])), + }) as server: + _write_caldav_tasks_config(tmp_path, url=f"http://127.0.0.1:{server.server_address[1]}/cal/") + (tmp_path / "kb" / "gtd").mkdir(parents=True) + + report = run_review(tmp_path, today=TODAY) + + findings = [f for f in report.findings if f.project == "No Page Yet"] + assert any(f.check == CHECK_PROJECT_AGE_UNKNOWN for f in findings) + assert not any(f.check == CHECK_UNPAGED_PROJECT for f in findings) + assert report.complete + + +def test_d3_text_and_json_output_agree(tmp_path, monkeypatch): + from chemenu import config + + monkeypatch.setattr(config, "ROOT", tmp_path) + use_shipped_type_specs(monkeypatch) + projects = {"p1": {"id": "p1", "title": "Kueche renovieren", "created": _ms(2026, 1, 1), + "taskIds": ["t1"], "backlogTaskIds": []}} + tasks = {"t1": {"id": "t1", "title": "Warte auf Angebot", "isDone": False, "tagIds": ["tag-wait"]}} + 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") + + text_result = runner.invoke(app, ["review"]) + json_result = runner.invoke(app, ["review", "--json"]) + assert text_result.exit_code == 0 + assert json_result.exit_code == 0 + + payload = json.loads(json_result.output) + json_pairs = {(f["check"], f["project"]) for f in payload["findings"]} + + import re + + text_pairs = { + (m.group(1), m.group(2)) + for m in re.finditer(r"^\[(\S+)\] ([^:]+):", text_result.output, re.MULTILINE) + } + assert json_pairs == text_pairs + assert (CHECK_WAITING_NO_FOLLOWUP, "Kueche renovieren") in json_pairs + + # --- check 4: kb/ page with no open loop ------------------------------------- diff --git a/tools/requirements.txt b/tools/requirements.txt index 6ab17d9..b3e78ac 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -3,3 +3,4 @@ typer>=0.12 rich>=13.0 jsonschema>=4.18 referencing>=0.30 +icalendar>=6.0